Compare commits

...
3 Commits
17 changed files with 713 additions and 192 deletions
+2 -1
View File
@@ -36,6 +36,8 @@ var movement_speed: float = 0.0
var _anim_time: float = 0.0 var _anim_time: float = 0.0
func _ready() -> void: func _ready() -> void:
rotation_degrees.y = 180
var mat := StandardMaterial3D.new() var mat := StandardMaterial3D.new()
mat.albedo_color = color mat.albedo_color = color
mat.roughness = 0.8 mat.roughness = 0.8
@@ -197,7 +199,6 @@ func set_weapon(script_path: String) -> void:
_set_shadows_recursive(w) _set_shadows_recursive(w)
# Force position after the weapon's _build_model() sets it for 1st person # Force position after the weapon's _build_model() sets it for 1st person
# root_pivot is rotated 180 deg around Y, so local +Z is FORWARD and local -X is RIGHT
w.position = Vector3(-0.15, 1.0, 0.4) w.position = Vector3(-0.15, 1.0, 0.4)
w.rotation_degrees = Vector3(0, 180, 0) w.rotation_degrees = Vector3(0, 180, 0)
) )
+27 -11
View File
@@ -9,18 +9,18 @@ func build_ragdoll(color: Color) -> void:
mat.roughness = 0.8 mat.roughness = 0.8
# Create bodies # Create bodies
torso_body = _create_body(Vector3(0.4, 0.7, 0.25), mat, Vector3(0, 1.15, 0), 20.0) torso_body = _create_body(Vector3(0.4, 0.7, 0.25), mat, Vector3(0, 1.15, 0), 20.0, "Torso")
var head_body = _create_body(Vector3(0.25, 0.25, 0.25), mat, Vector3(0, 1.65, 0), 5.0) var head_body = _create_body(Vector3(0.25, 0.25, 0.25), mat, Vector3(0, 1.65, 0), 5.0, "Head")
var upper_arm_l_body = _create_body(Vector3(0.12, 0.35, 0.12), mat, Vector3(-0.28, 1.275, 0), 3.0) var upper_arm_l_body = _create_body(Vector3(0.12, 0.35, 0.12), mat, Vector3(-0.28, 1.275, 0), 3.0, "UpperArmL")
var lower_arm_l_body = _create_body(Vector3(0.1, 0.35, 0.1), mat, Vector3(-0.28, 0.925, 0), 2.0) var lower_arm_l_body = _create_body(Vector3(0.1, 0.35, 0.1), mat, Vector3(-0.28, 0.925, 0), 2.0, "LowerArmL")
var upper_arm_r_body = _create_body(Vector3(0.12, 0.35, 0.12), mat, Vector3(0.28, 1.275, 0), 3.0) var upper_arm_r_body = _create_body(Vector3(0.12, 0.35, 0.12), mat, Vector3(0.28, 1.275, 0), 3.0, "UpperArmR")
var lower_arm_r_body = _create_body(Vector3(0.1, 0.35, 0.1), mat, Vector3(0.28, 0.925, 0), 2.0) var lower_arm_r_body = _create_body(Vector3(0.1, 0.35, 0.1), mat, Vector3(0.28, 0.925, 0), 2.0, "LowerArmR")
var thigh_l_body = _create_body(Vector3(0.15, 0.45, 0.15), mat, Vector3(-0.12, 0.625, 0), 5.0) var thigh_l_body = _create_body(Vector3(0.15, 0.45, 0.15), mat, Vector3(-0.12, 0.625, 0), 5.0, "ThighL")
var calf_l_body = _create_body(Vector3(0.13, 0.45, 0.13), mat, Vector3(-0.12, 0.175, 0), 3.0) var calf_l_body = _create_body(Vector3(0.13, 0.45, 0.13), mat, Vector3(-0.12, 0.175, 0), 3.0, "CalfL")
var thigh_r_body = _create_body(Vector3(0.15, 0.45, 0.15), mat, Vector3(0.12, 0.625, 0), 5.0) var thigh_r_body = _create_body(Vector3(0.15, 0.45, 0.15), mat, Vector3(0.12, 0.625, 0), 5.0, "ThighR")
var calf_r_body = _create_body(Vector3(0.13, 0.45, 0.13), mat, Vector3(0.12, 0.175, 0), 3.0) var calf_r_body = _create_body(Vector3(0.13, 0.45, 0.13), mat, Vector3(0.12, 0.175, 0), 3.0, "CalfR")
add_child(torso_body) add_child(torso_body)
add_child(head_body) add_child(head_body)
@@ -33,6 +33,21 @@ func build_ragdoll(color: Color) -> void:
add_child(thigh_r_body) add_child(thigh_r_body)
add_child(calf_r_body) add_child(calf_r_body)
var is_server = not multiplayer.has_multiplayer_peer() or multiplayer.is_server()
var rep = SceneReplicationConfig.new()
for child in get_children():
if child is RigidBody3D:
rep.add_property(NodePath(str(child.name) + ":position"))
rep.add_property(NodePath(str(child.name) + ":rotation"))
if not is_server:
child.freeze = true
child.freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC
var sync = MultiplayerSynchronizer.new()
sync.name = "RagdollSync"
sync.replication_config = rep
add_child(sync)
# Need to wait a frame for paths to be valid before creating joints # Need to wait a frame for paths to be valid before creating joints
call_deferred("_setup_joints", head_body, call_deferred("_setup_joints", head_body,
upper_arm_l_body, lower_arm_l_body, upper_arm_l_body, lower_arm_l_body,
@@ -76,8 +91,9 @@ func apply_initial_velocities(linear_vel: Vector3, impulse: Vector3) -> void:
if is_instance_valid(torso_body): if is_instance_valid(torso_body):
torso_body.apply_central_impulse(impulse * 2.0) torso_body.apply_central_impulse(impulse * 2.0)
func _create_body(size: Vector3, mat: Material, pos: Vector3, mass: float) -> RigidBody3D: func _create_body(size: Vector3, mat: Material, pos: Vector3, mass: float, bname: String) -> RigidBody3D:
var body = RigidBody3D.new() var body = RigidBody3D.new()
body.name = bname
body.position = pos body.position = pos
body.mass = mass body.mass = mass
+41 -12
View File
@@ -293,16 +293,40 @@ func _spawn_player(pid: int) -> CharacterBody3D:
# Use a slightly random spawn position to avoid exact overlapping # Use a slightly random spawn position to avoid exact overlapping
player.position = Vector3(randf_range(-2, 2), 2.0, randf_range(-2, 2)) player.position = Vector3(randf_range(-2, 2), 2.0, randf_range(-2, 2))
# Network Synchronizer # Server Synchronizer (Host is the ground truth)
var sync = MultiplayerSynchronizer.new() var server_sync = MultiplayerSynchronizer.new()
sync.name = "MultiplayerSynchronizer" server_sync.name = "ServerSynchronizer"
sync.set_multiplayer_authority(pid) server_sync.set_multiplayer_authority(1) # Host always controls these
var rep_config = SceneReplicationConfig.new() var server_rep_config = SceneReplicationConfig.new()
rep_config.add_property(":position") server_rep_config.add_property(":position")
rep_config.add_property(":rotation") server_rep_config.add_property(":synced_movement_state")
rep_config.add_property("HeadPivot:rotation") server_rep_config.add_property(":synced_movement_speed")
sync.replication_config = rep_config server_rep_config.add_property(":synced_is_crouching")
player.add_child(sync) server_rep_config.add_property(":health")
server_rep_config.add_property(":shield")
server_rep_config.add_property(":is_dead")
server_rep_config.add_property(":synced_grapple_point")
server_rep_config.add_property(":synced_is_grapple_shooting")
server_sync.replication_config = server_rep_config
player.add_child(server_sync)
# Client Synchronizer (Client dictates their aim and loadout setup)
var client_sync = MultiplayerSynchronizer.new()
client_sync.name = "MultiplayerSynchronizer" # Keep original name for compatibility if needed elsewhere
client_sync.set_multiplayer_authority(pid)
var client_rep_config = SceneReplicationConfig.new()
client_rep_config.add_property(":rotation")
client_rep_config.add_property("HeadPivot:rotation")
client_rep_config.add_property(":synced_weapon_path")
client_rep_config.add_property(":synced_loadout_p1")
client_rep_config.add_property(":synced_loadout_p2")
client_rep_config.add_property(":synced_loadout_sp")
client_rep_config.add_property(":synced_loadout_melee")
client_rep_config.add_property(":synced_loadout_ready")
client_rep_config.add_property(":synced_is_targeting")
client_rep_config.add_property(":synced_homing_target_pos")
client_sync.replication_config = client_rep_config
player.add_child(client_sync)
# Collision shape # Collision shape
var col_shape := CollisionShape3D.new() var col_shape := CollisionShape3D.new()
@@ -395,9 +419,10 @@ func _spawn_player(pid: int) -> CharacterBody3D:
player.set_process(true) player.set_process(true)
sm.set_physics_process(true) sm.set_physics_process(true)
else: else:
# Disable processing/input for remote players # Disable physics processing/input for remote players
# but keep _process enabled so synced state updates the HumanoidModel
player.set_physics_process(false) player.set_physics_process(false)
player.set_process(false) player.set_process(true)
player.set_process_input(false) player.set_process_input(false)
sm.set_physics_process(false) sm.set_physics_process(false)
sm.set_process(false) sm.set_process(false)
@@ -410,6 +435,10 @@ func _spawn_player(pid: int) -> CharacterBody3D:
wman.set_process_input(false) wman.set_process_input(false)
wman.set_process(false) wman.set_process(false)
wman.set_physics_process(false) wman.set_physics_process(false)
wman.visible = false
var cv = wman.get("canvas_layer")
if cv:
cv.visible = false
return player return player
+81 -43
View File
@@ -1,13 +1,14 @@
extends Area3D extends Area3D
class_name CombatArea class_name CombatArea
var time_left: float = 5.0 var out_of_bounds_players: Dictionary = {} # body -> { time_left: 5.0 }
var time_safe: float = 0.0 var recovery_players: Dictionary = {} # body -> { time_safe: 0.0, time_left: float }
var _player_out_of_bounds: Node3D = null
var _ui_layer: CanvasLayer = null var _ui_layer: CanvasLayer = null
var _ui_label: Label = null var _ui_label: Label = null
var _ui_overlay: ColorRect = null var _ui_overlay: ColorRect = null
var _is_local_player_out: bool = false
var _local_player_body: Node3D = null
func _ready() -> void: func _ready() -> void:
collision_layer = 0 collision_layer = 0
@@ -17,50 +18,85 @@ func _ready() -> void:
set_process(false) set_process(false)
func _process(delta: float) -> void: func _process(delta: float) -> void:
if _player_out_of_bounds: # Process players who are actively out of bounds
# If the player dies while out of bounds, stop the timer and remove the UI var to_kill: Array = []
if "is_dead" in _player_out_of_bounds and _player_out_of_bounds.is_dead: for body in out_of_bounds_players.keys():
_player_out_of_bounds = null if "is_dead" in body and body.is_dead:
out_of_bounds_players.erase(body)
if body == _local_player_body:
_remove_ui() _remove_ui()
return continue
# Player is outside the combat area! var data = out_of_bounds_players[body]
time_left -= delta data.time_left -= delta
if time_left <= 0.0: if data.time_left <= 0.0:
time_left = 0.0 data.time_left = 0.0
_kill_player() to_kill.append(body)
# Update UI if body == _local_player_body and _is_local_player_out and _ui_label and _ui_overlay:
if _ui_label and _ui_overlay: _ui_label.text = "WARNING: RETURN TO COMBAT AREA\n%.1f" % data.time_left
_ui_label.text = "WARNING: RETURN TO COMBAT AREA\n%.1f" % time_left _ui_overlay.color = Color(1.0, 0.0, 0.0, 0.8 * (1.0 - (data.time_left / 5.0)))
_ui_overlay.color = Color(1.0, 0.0, 0.0, 0.8 * (1.0 - (time_left / 5.0)))
else: for body in to_kill:
# Player is inside the combat area, but might have a damaged timer _kill_player(body)
if time_left < 5.0:
time_safe += delta # Process players who returned but are recovering
if time_safe >= 10.0: var recovered: Array = []
time_left = 5.0 for body in recovery_players.keys():
time_safe = 0.0 if "is_dead" in body and body.is_dead:
set_process(false) # Fully recovered, sleep recovered.append(body)
continue
var data = recovery_players[body]
data.time_safe += delta
if data.time_safe >= 10.0:
recovered.append(body)
for body in recovered:
recovery_players.erase(body)
if out_of_bounds_players.is_empty() and recovery_players.is_empty():
set_process(false)
func _on_body_exited(body: Node3D) -> void: func _on_body_exited(body: Node3D) -> void:
if body.name == "Player": if body is CharacterBody3D and body.has_method("take_damage") and str(body.name).is_valid_int():
# Don't trigger if the player is already dead or the scene is tearing down
if "is_dead" in body and body.is_dead: if "is_dead" in body and body.is_dead:
return return
if body.is_queued_for_deletion() or not is_inside_tree(): if body.is_queued_for_deletion() or not is_inside_tree():
return return
_player_out_of_bounds = body var start_time = 5.0
time_safe = 0.0 # Reset recovery timer if recovery_players.has(body):
start_time = recovery_players[body].time_left
recovery_players.erase(body)
out_of_bounds_players[body] = { "time_left": start_time }
var is_local = false
if multiplayer.has_multiplayer_peer():
if body.get_multiplayer_authority() == multiplayer.get_unique_id():
is_local = true
if is_local:
_is_local_player_out = true
_local_player_body = body
_create_ui() _create_ui()
set_process(true) set_process(true)
func _on_body_entered(body: Node3D) -> void: func _on_body_entered(body: Node3D) -> void:
if body == _player_out_of_bounds: if out_of_bounds_players.has(body):
_player_out_of_bounds = null var time_left_val = out_of_bounds_players[body].time_left
out_of_bounds_players.erase(body)
if time_left_val < 5.0:
recovery_players[body] = { "time_left": time_left_val, "time_safe": 0.0 }
set_process(true)
if body == _local_player_body and _is_local_player_out:
_remove_ui() _remove_ui()
# set_process(true) stays on to tick the time_safe recovery _is_local_player_out = false
_local_player_body = null
func _create_ui() -> void: func _create_ui() -> void:
if _ui_layer: return if _ui_layer: return
@@ -83,7 +119,10 @@ func _create_ui() -> void:
_ui_label.add_theme_color_override("font_color", Color(1, 0.2, 0.2)) _ui_label.add_theme_color_override("font_color", Color(1, 0.2, 0.2))
_ui_label.add_theme_color_override("font_outline_color", Color(0, 0, 0)) _ui_label.add_theme_color_override("font_outline_color", Color(0, 0, 0))
_ui_label.add_theme_constant_override("outline_size", 8) _ui_label.add_theme_constant_override("outline_size", 8)
_ui_label.text = "WARNING: RETURN TO COMBAT AREA\n%.1f" % time_left var display_time = 5.0
if out_of_bounds_players.has(_local_player_body):
display_time = out_of_bounds_players[_local_player_body].time_left
_ui_label.text = "WARNING: RETURN TO COMBAT AREA\n%.1f" % display_time
_ui_layer.add_child(_ui_label) _ui_layer.add_child(_ui_label)
@@ -97,15 +136,14 @@ func _remove_ui() -> void:
_ui_label = null _ui_label = null
_ui_overlay = null _ui_overlay = null
func _kill_player() -> void: func _kill_player(body: Node3D) -> void:
if _player_out_of_bounds: if body:
# PlayerMovementController's take_damage requires (amount, source) if multiplayer.is_server():
if _player_out_of_bounds.has_method("take_damage"): if body.has_method("take_damage"):
_player_out_of_bounds.take_damage(9999, Vector3.ZERO) body.take_damage(9999, Vector3.ZERO, null, Vector3.ZERO)
elif "health" in _player_out_of_bounds:
_player_out_of_bounds.health = 0
if "is_dead" in _player_out_of_bounds:
_player_out_of_bounds.is_dead = true
_player_out_of_bounds = null out_of_bounds_players.erase(body)
if body == _local_player_body and _is_local_player_out:
_remove_ui() _remove_ui()
_is_local_player_out = false
_local_player_body = null
+7 -8
View File
@@ -39,7 +39,7 @@ func _ready() -> void:
_update_label() _update_label()
func take_damage(amount: float, hit_position: Vector3, source: Node = null) -> void: func take_damage(amount: float, hit_position: Vector3, source: Node = null, _impulse: Vector3 = Vector3.ZERO) -> void:
if is_dead: if is_dead:
return return
@@ -141,14 +141,13 @@ func _die(_hit_position: Vector3, source: Node, damage_amount: float) -> void:
linear_vel = hit_dir * (damage_amount * 0.02) linear_vel = hit_dir * (damage_amount * 0.02)
impulse = hit_dir * (damage_amount * 0.5) impulse = hit_dir * (damage_amount * 0.5)
get_tree().create_timer(0.01).timeout.connect(func(): get_tree().create_timer(0.01).timeout.connect(_apply_ragdoll_velocity.bind(ragdoll_instance, linear_vel, impulse))
if is_instance_valid(ragdoll_instance):
ragdoll_instance.apply_initial_velocities(linear_vel, impulse)
)
get_tree().create_timer(3.0).timeout.connect(func(): get_tree().create_timer(3.0).timeout.connect(_respawn)
_respawn()
) func _apply_ragdoll_velocity(ragdoll: Node3D, l_vel: Vector3, imp: Vector3) -> void:
if is_instance_valid(ragdoll):
ragdoll.apply_initial_velocities(l_vel, imp)
func _respawn() -> void: func _respawn() -> void:
if is_instance_valid(ragdoll_instance): if is_instance_valid(ragdoll_instance):
+1 -1
View File
@@ -33,7 +33,7 @@ func _ready() -> void:
dps_label.modulate = Color(1, 0.8, 0) dps_label.modulate = Color(1, 0.8, 0)
add_child(dps_label) add_child(dps_label)
func take_damage(amount: float, hit_position: Vector3, source: Node = null) -> void: func take_damage(amount: float, hit_position: Vector3, source: Node = null, impulse: Vector3 = Vector3.ZERO) -> void:
var now = Time.get_ticks_msec() / 1000.0 var now = Time.get_ticks_msec() / 1000.0
damage_history.append({"time": now, "amount": amount}) damage_history.append({"time": now, "amount": amount})
+7 -9
View File
@@ -82,7 +82,7 @@ func _physics_process(delta: float) -> void:
var current_h_speed = Vector2(velocity.x, velocity.z).length() var current_h_speed = Vector2(velocity.x, velocity.z).length()
visual_node.update_state("ground", current_h_speed) visual_node.update_state("ground", current_h_speed)
func take_damage(amount: float, hit_position: Vector3, source: Node = null) -> void: func take_damage(amount: float, hit_position: Vector3, source: Node = null, impulse: Vector3 = Vector3.ZERO) -> void:
if is_dead: if is_dead:
return return
@@ -185,15 +185,13 @@ func _die(_hit_position: Vector3, source: Node, damage_amount: float) -> void:
linear_vel = hit_dir * (damage_amount * 0.02) linear_vel = hit_dir * (damage_amount * 0.02)
impulse = hit_dir * (damage_amount * 0.5) impulse = hit_dir * (damage_amount * 0.5)
get_tree().create_timer(0.01).timeout.connect(func(): get_tree().create_timer(0.01).timeout.connect(_apply_ragdoll_velocity.bind(ragdoll_instance, velocity + linear_vel, impulse))
if is_instance_valid(ragdoll_instance):
# The ragdoll also inherits the current walking velocity natively
ragdoll_instance.apply_initial_velocities(velocity + linear_vel, impulse)
)
get_tree().create_timer(3.0).timeout.connect(func(): get_tree().create_timer(3.0).timeout.connect(_respawn)
_respawn()
) func _apply_ragdoll_velocity(ragdoll: Node3D, l_vel: Vector3, imp: Vector3) -> void:
if is_instance_valid(ragdoll):
ragdoll.apply_initial_velocities(l_vel, imp)
func _respawn() -> void: func _respawn() -> void:
if is_instance_valid(ragdoll_instance): if is_instance_valid(ragdoll_instance):
+2
View File
@@ -64,6 +64,8 @@ func _ready() -> void:
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
if player and not player.multiplayer.is_server(): return
# Update coyote time # Update coyote time
if on_ground: if on_ground:
coyote_timer = params.coyote_time coyote_timer = params.coyote_time
+447 -94
View File
@@ -16,6 +16,7 @@ var health: float = 100.0
var max_shield: float = 100.0 var max_shield: float = 100.0
var shield: float = 100.0 var shield: float = 100.0
var time_since_last_damage: float = 0.0 var time_since_last_damage: float = 0.0
var death_count: int = 0
var is_dead: bool = false var is_dead: bool = false
# UI # UI
@@ -48,6 +49,25 @@ var _hit_marker_tween: Tween
# Grapple # Grapple
var grapple_rope: MeshInstance3D var grapple_rope: MeshInstance3D
# ── Multiplayer Sync Variables ─────────────────────────────────────────────
# These are written by the authority each frame and replicated to remote peers
# via the MultiplayerSynchronizer properties.
var synced_movement_state: String = "idle"
var synced_movement_speed: float = 0.0
var synced_is_crouching: bool = false
@export var synced_weapon_path: String = ""
@export var synced_grapple_point: Vector3 = Vector3.ZERO
@export var synced_is_grapple_shooting: bool = false
# For homing rocket
@export var synced_is_targeting: bool = false
@export var synced_homing_target_pos: Vector3 = Vector3.ZERO
var synced_loadout_p1: String = ""
var synced_loadout_p2: String = ""
var synced_loadout_sp: String = ""
var synced_loadout_melee: String = ""
var synced_loadout_ready: bool = false
func _ready() -> void: func _ready() -> void:
var sm := _ensure_machine() var sm := _ensure_machine()
@@ -61,17 +81,31 @@ func _ready() -> void:
if head_pivot: if head_pivot:
camera = head_pivot.get_node_or_null("Camera3D") camera = head_pivot.get_node_or_null("Camera3D")
_setup_audio()
_setup_grapple()
if is_multiplayer_authority():
_damage_layer = CanvasLayer.new() _damage_layer = CanvasLayer.new()
add_child(_damage_layer) add_child(_damage_layer)
_setup_audio()
_setup_hit_marker() _setup_hit_marker()
_setup_grapple()
_setup_hud() _setup_hud()
else:
call_deferred("_hide_remote_weapons")
set_process(true) set_process(true)
if is_multiplayer_authority():
set_physics_process(true) set_physics_process(true)
func _hide_remote_weapons() -> void:
if camera:
var wman = camera.get_node_or_null("WeaponManager")
if wman:
wman.visible = false
wman.process_mode = Node.PROCESS_MODE_DISABLED
var cv = wman.get("canvas_layer")
if cv:
cv.visible = false
func _setup_audio() -> void: func _setup_audio() -> void:
footstep_player = AudioStreamPlayer.new() footstep_player = AudioStreamPlayer.new()
footstep_player.bus = "SFX" footstep_player.bus = "SFX"
@@ -219,16 +253,9 @@ func apply_impulse(force: Vector3) -> void:
global_position.y += 0.1 global_position.y += 0.1
func take_damage(amount: float, _hit_pos: Vector3, _source: Node3D = null, impulse: Vector3 = Vector3.ZERO) -> void: func take_damage(amount: float, _hit_pos: Vector3, _source: Node3D = null, impulse: Vector3 = Vector3.ZERO) -> void:
if is_dead:
if is_instance_valid(ragdoll_instance):
ragdoll_instance.apply_initial_velocities(Vector3.ZERO, impulse)
return
# Show damage number for self-damage
spawn_damage_number(amount, _hit_pos)
# Handle damage tracking for assists
var attacker_id = 0 var attacker_id = 0
var weapon_name = "Killed"
if _source: if _source:
if _source.has_method("get_multiplayer_authority"): if _source.has_method("get_multiplayer_authority"):
attacker_id = _source.get_multiplayer_authority() attacker_id = _source.get_multiplayer_authority()
@@ -237,6 +264,210 @@ func take_damage(amount: float, _hit_pos: Vector3, _source: Node3D = null, impul
elif _source is CharacterBody3D: elif _source is CharacterBody3D:
attacker_id = int(str(_source.name)) attacker_id = int(str(_source.name))
if _source.has_method("get_weapon_name"):
weapon_name = _source.get_weapon_name()
# If this is singleplayer offline, just call it directly
if not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
rpc_take_damage(amount, _hit_pos, attacker_id, weapon_name, impulse)
else:
# Tell the server to handle the damage and broadcast it to everyone!
server_take_damage.rpc_id(1, amount, _hit_pos, attacker_id, weapon_name, impulse)
@rpc("any_peer", "call_local", "unreliable")
func server_play_fire_effects(origin: Vector3, target_or_dir: Vector3, weapon_name: String, is_hitscan: bool) -> void:
if not multiplayer.is_server(): return
rpc_play_fire_effects.rpc(origin, target_or_dir, weapon_name, is_hitscan)
@rpc("any_peer", "call_local", "unreliable")
func rpc_play_fire_effects(origin: Vector3, target_or_dir: Vector3, _weapon_name: String, is_hitscan: bool) -> void:
# Local player already spawned effects locally with 0 latency
if is_multiplayer_authority(): return
if is_hitscan:
# Spawn a standalone tracer directly into the scene
_spawn_remote_tracer(origin, target_or_dir)
else:
# Spawn a visual-only projectile flying from origin along the direction
_spawn_remote_projectile(origin, target_or_dir, _weapon_name)
func _spawn_remote_tracer(origin: Vector3, target: Vector3) -> void:
var tracer = Node3D.new()
tracer.set_script(load("res://weapons/hitscan_tracer.gd"))
var t_mesh_inst = MeshInstance3D.new()
var t_mesh = CapsuleMesh.new()
t_mesh.radius = 0.02
t_mesh.height = 1.0
var t_mat = StandardMaterial3D.new()
t_mat.albedo_color = Color(1.0, 0.8, 0.2)
t_mat.emission_enabled = true
t_mat.emission = Color(2.0, 1.5, 0.5)
t_mesh.material = t_mat
t_mesh_inst.mesh = t_mesh
t_mesh_inst.rotation.x = deg_to_rad(90)
t_mesh_inst.position = Vector3(0, 0, -0.5)
tracer.add_child(t_mesh_inst)
tracer.target_position = target
if origin.distance_squared_to(target) > 0.1:
get_tree().current_scene.add_child(tracer)
tracer.global_position = origin
var up_vec = Vector3.UP
if abs((target - origin).normalized().dot(up_vec)) > 0.99:
up_vec = Vector3.RIGHT
tracer.look_at(target, up_vec)
func _spawn_remote_projectile(origin: Vector3, fire_dir: Vector3, weapon_name: String) -> void:
# Try to find the actual weapon to spawn its custom projectile so it looks identical!
if camera:
var wman = camera.get_node_or_null("WeaponManager")
if wman:
var target_weapon = null
for w in wman.weapons.values():
if "weapon_name" in w and w.weapon_name == weapon_name:
target_weapon = w
break
if target_weapon and target_weapon.has_method("_spawn_custom_projectile"):
target_weapon._spawn_custom_projectile(origin, fire_dir)
return
# Generic fallback if weapon not found (e.g. joined late and weapons didn't build yet)
var proj = Node3D.new()
proj.name = "RemoteProjectile"
if weapon_name == "Plasma Gun":
var mesh_instance = MeshInstance3D.new()
var p_mesh = SphereMesh.new()
p_mesh.radius = 0.08
p_mesh.height = 0.16
var mat = StandardMaterial3D.new()
mat.albedo_color = Color(0.2, 0.6, 1.0)
mat.emission_enabled = true
mat.emission = Color(0.2, 0.6, 1.0)
mat.emission_energy_multiplier = 5.0
p_mesh.material = mat
mesh_instance.mesh = p_mesh
proj.add_child(mesh_instance)
var light = OmniLight3D.new()
light.light_color = Color(0.2, 0.6, 1.0)
light.light_energy = 2.0
light.omni_range = 4.0
proj.add_child(light)
else:
# Generic rocket fallback
var mesh_inst = MeshInstance3D.new()
var p_mesh = CylinderMesh.new()
p_mesh.top_radius = 0.0
p_mesh.bottom_radius = 0.05
p_mesh.height = 0.2
var mat = StandardMaterial3D.new()
mat.albedo_color = Color(0.8, 0.2, 0.2)
mat.emission_enabled = true
mat.emission = Color(1.5, 0.3, 0.1)
p_mesh.material = mat
mesh_inst.mesh = p_mesh
mesh_inst.rotation.x = deg_to_rad(-90)
proj.add_child(mesh_inst)
var smoke = GPUParticles3D.new()
var smoke_mat = ParticleProcessMaterial.new()
smoke_mat.direction = Vector3(0, 0, 1)
smoke_mat.spread = 5.0
smoke_mat.initial_velocity_min = 0.0
smoke_mat.initial_velocity_max = 0.5
smoke_mat.gravity = Vector3(0, 0.5, 0)
smoke.process_material = smoke_mat
smoke.amount = 20
smoke.lifetime = 0.5
smoke.position = Vector3(0, 0, 0.1)
var smoke_mesh = SphereMesh.new()
smoke_mesh.radius = 0.04
var smoke_mesh_mat = StandardMaterial3D.new()
smoke_mesh_mat.albedo_color = Color(0.5, 0.5, 0.5, 0.6)
smoke_mesh_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
smoke_mesh.material = smoke_mesh_mat
smoke.draw_pass_1 = smoke_mesh
proj.add_child(smoke)
# Orient the projectile in the direction of fire
var up_vec = Vector3.UP
if abs(fire_dir.normalized().dot(up_vec)) > 0.99:
up_vec = Vector3.RIGHT
get_tree().current_scene.add_child(proj)
proj.global_position = origin
proj.look_at(origin + fire_dir, up_vec)
# Animate movement using tween at appropriate speed
var speed = 35.0 if weapon_name != "Plasma Gun" else 80.0
var end_pos = origin + fire_dir.normalized() * (speed * 5.0)
var tween = get_tree().create_tween()
tween.tween_property(proj, "global_position", end_pos, 5.0)
tween.tween_callback(proj.queue_free)
@rpc("any_peer", "call_local", "reliable")
func server_play_explosion(pos: Vector3, radius: float) -> void:
if not multiplayer.is_server(): return
rpc_play_explosion.rpc(pos, radius)
@rpc("any_peer", "call_local", "reliable")
func rpc_play_explosion(pos: Vector3, radius: float) -> void:
if is_multiplayer_authority(): return
var visual = MeshInstance3D.new()
var s_mesh = SphereMesh.new()
s_mesh.radius = radius
s_mesh.height = radius * 2.0
visual.mesh = s_mesh
var mat = StandardMaterial3D.new()
mat.albedo_color = Color(1.0, 0.5, 0.0, 0.6) # Translucent orange
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.emission_enabled = true
mat.emission = Color(1.0, 0.3, 0.0)
visual.material_override = mat
visual.position = pos
get_tree().current_scene.add_child(visual)
var tween = visual.create_tween()
tween.tween_property(mat, "albedo_color:a", 0.0, 0.3)
tween.parallel().tween_property(visual, "scale", Vector3(1.1, 1.1, 1.1), 0.3)
tween.tween_callback(visual.queue_free)
var audio = AudioStreamPlayer3D.new()
audio.stream = load("res://assets/sounds/shotgun_fire.wav")
audio.bus = "SFX"
audio.pitch_scale = 0.7
audio.max_distance = 150.0
get_tree().current_scene.add_child(audio)
audio.global_position = pos
audio.play()
audio.finished.connect(audio.queue_free)
@rpc("any_peer", "call_local", "reliable")
func server_take_damage(amount: float, hit_pos: Vector3, attacker_id: int, weapon_name: String, impulse: Vector3) -> void:
if not multiplayer.is_server(): return
# Broadcast damage event to all peers so the victim dies on all screens
rpc_take_damage.rpc(amount, hit_pos, attacker_id, weapon_name, impulse)
@rpc("any_peer", "call_local", "reliable")
func rpc_take_damage(amount: float, hit_pos: Vector3, attacker_id: int, weapon_name: String, impulse: Vector3) -> void:
if is_dead:
if is_instance_valid(ragdoll_instance):
ragdoll_instance.apply_initial_velocities(Vector3.ZERO, impulse)
return
# Show damage number for the attacker
if not multiplayer.has_multiplayer_peer() or attacker_id == multiplayer.get_unique_id():
spawn_damage_number(amount, hit_pos)
if attacker_id != 0: if attacker_id != 0:
recent_attackers[attacker_id] = Time.get_ticks_msec() / 1000.0 recent_attackers[attacker_id] = Time.get_ticks_msec() / 1000.0
@@ -256,10 +487,10 @@ func take_damage(amount: float, _hit_pos: Vector3, _source: Node3D = null, impul
health = 0.0 health = 0.0
die(impulse) die(impulse)
if multiplayer.is_server():
if get_node_or_null("/root/NetworkManager") and multiplayer.has_multiplayer_peer(): if get_node_or_null("/root/NetworkManager") and multiplayer.has_multiplayer_peer():
var victim_id = multiplayer.get_unique_id() if is_multiplayer_authority() else int(str(name)) var victim_id = multiplayer.get_unique_id()
var killer_id = 0 var killer_id = attacker_id
var weapon_name = "Killed"
var assist_ids: Array = [] var assist_ids: Array = []
var now = Time.get_ticks_msec() / 1000.0 var now = Time.get_ticks_msec() / 1000.0
@@ -267,16 +498,6 @@ func take_damage(amount: float, _hit_pos: Vector3, _source: Node3D = null, impul
if aid != killer_id and now - recent_attackers[aid] <= 10.0: if aid != killer_id and now - recent_attackers[aid] <= 10.0:
assist_ids.append(aid) assist_ids.append(aid)
if _source and _source is Node:
if _source.has_method("get_multiplayer_authority"):
killer_id = _source.get_multiplayer_authority()
elif "owner_player" in _source and _source.owner_player:
killer_id = int(str(_source.owner_player.name))
elif _source is PlayerMovementController:
killer_id = int(str(_source.name))
if _source.has_method("get_weapon_name"):
weapon_name = _source.get_weapon_name()
get_node("/root/NetworkManager").register_kill.rpc(victim_id, killer_id, weapon_name, "", assist_ids) get_node("/root/NetworkManager").register_kill.rpc(victim_id, killer_id, weapon_name, "", assist_ids)
func get_weapon_name() -> String: func get_weapon_name() -> String:
@@ -310,68 +531,34 @@ func _ensure_machine() -> MovementStateMachine:
_machine = null _machine = null
return null return null
var _client_input_dir: Vector2 = Vector2.ZERO
var _client_wish_dir_world: Vector3 = Vector3.ZERO
var _client_jump: bool = false
var _client_jump_just_pressed: bool = false
var _client_crouch: bool = false
var _client_dash: bool = false
var _client_grapple: bool = false
var _client_grapple_just_pressed: bool = false
@rpc("any_peer", "call_local", "unreliable")
func server_receive_inputs(input_dir: Vector2, wish_dir: Vector3, jump: bool, jump_just: bool, crouch: bool, dash: bool, grapple: bool, grapple_just: bool) -> void:
if not multiplayer.is_server(): return
var sender = multiplayer.get_remote_sender_id()
if sender != str(name).to_int() and sender != 1: return # Accept from owner or host self
_client_input_dir = input_dir
_client_wish_dir_world = wish_dir
_client_jump = jump
_client_jump_just_pressed = jump_just
_client_crouch = crouch
_client_dash = dash
_client_grapple = grapple
_client_grapple_just_pressed = grapple_just
func _physics_process(_delta: float) -> void: func _physics_process(_delta: float) -> void:
if not is_multiplayer_authority(): return # Local client captures input and sends it
var speed = velocity.length() if is_multiplayer_authority():
var wind_factor = clampf((speed - 10.0) / 25.0, 0.0, 1.0)
# Decibel scale is non-linear, so lerping from -80 means it stays inaudible for a long time.
# -40 db is practically silent for a background loop, -15 db is our max quiet wind.
wind_player.volume_db = lerpf(-40.0, -15.0, wind_factor)
if not wind_player.playing and wind_factor > 0.0:
wind_player.play()
var sm := _ensure_machine()
if not sm:
return
# Poll input in _physics_process so it's synchronized with the state machine tick
sm.input_jump_just_pressed = Input.is_action_just_pressed("jump")
sm.input_jump_pressed = Input.is_action_pressed("jump")
sm.input_crouch = Input.is_action_pressed("crouch")
sm.input_dash = Input.is_action_just_pressed("dash")
sm.input_grapple = Input.is_action_pressed("grapple")
sm.input_grapple_just_pressed = Input.is_action_just_pressed("grapple")
# Update grapple rope visuals
if (sm.current_state == "grapple" or sm.is_grapple_shooting) and camera:
grapple_rope.visible = true
# For shoot, start at right side of camera slightly below and closer
var start_pos = camera.global_position + camera.global_transform.basis * Vector3(0.3, -0.3, -0.15)
var end_pos = sm.grapple_point
if sm.is_grapple_shooting and sm.grapple_travel_time > 0.0:
# Interpolate end_pos
var t = clampf(sm.grapple_shoot_time / sm.grapple_travel_time, 0.0, 1.0)
end_pos = start_pos.lerp(sm.grapple_point, t)
var dist = start_pos.distance_to(end_pos)
if dist > 0.01:
grapple_rope.global_position = (start_pos + end_pos) / 2.0
# Look at end point
var up_vec = Vector3.UP
var dir = (end_pos - start_pos).normalized()
if abs(dir.dot(up_vec)) > 0.99:
up_vec = Vector3.RIGHT
# Set rotation first, which resets scale
grapple_rope.transform.basis = Basis.looking_at(dir, up_vec) * Basis.from_euler(Vector3(PI/2.0, 0, 0))
# Then apply the scale
grapple_rope.scale = Vector3(1.0, dist, 1.0)
else:
if grapple_rope:
grapple_rope.visible = false
# Swing sound
if sm.current_state == "grapple" and sm.player.velocity.length() > 5.0:
grapple_swing_player.volume_db = lerpf(grapple_swing_player.volume_db, -15.0, _delta * 10.0)
else:
grapple_swing_player.volume_db = lerpf(grapple_swing_player.volume_db, -80.0, _delta * 15.0)
# Raw 2D input
var raw_input := Input.get_vector("move_left", "move_right", "move_forward", "move_back") var raw_input := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
# Transform input direction by player yaw so movement is camera-relative
var forward := -global_transform.basis.z var forward := -global_transform.basis.z
forward.y = 0.0 forward.y = 0.0
forward = forward.normalized() forward = forward.normalized()
@@ -383,8 +570,70 @@ func _physics_process(_delta: float) -> void:
if world_dir.length_squared() > 1.0: if world_dir.length_squared() > 1.0:
world_dir = world_dir.normalized() world_dir = world_dir.normalized()
sm.input_dir = raw_input var input_jump = Input.is_action_pressed("jump")
sm.wish_dir_world = world_dir var input_jump_just = Input.is_action_just_pressed("jump")
var input_crouch = Input.is_action_pressed("crouch")
var input_dash = Input.is_action_just_pressed("dash")
var input_grapple = Input.is_action_pressed("grapple")
var input_grapple_just = Input.is_action_just_pressed("grapple")
server_receive_inputs.rpc_id(1, raw_input, world_dir, input_jump, input_jump_just, input_crouch, input_dash, input_grapple, input_grapple_just)
var speed = velocity.length()
var wind_factor = clampf((speed - 10.0) / 25.0, 0.0, 1.0)
wind_player.volume_db = lerpf(-40.0, -15.0, wind_factor)
if not wind_player.playing and wind_factor > 0.0:
wind_player.play()
var sm := _ensure_machine()
if not sm:
return
# Only the Server sets input values to the StateMachine for evaluation
if multiplayer.is_server():
sm.input_dir = _client_input_dir
sm.wish_dir_world = _client_wish_dir_world
sm.input_jump_pressed = _client_jump
sm.input_jump_just_pressed = _client_jump_just_pressed
sm.input_crouch = _client_crouch
sm.input_dash = _client_dash
sm.input_grapple = _client_grapple
sm.input_grapple_just_pressed = _client_grapple_just_pressed
# Reset one-frame actions
_client_jump_just_pressed = false
_client_dash = false
_client_grapple_just_pressed = false
# Update synced properties for the grapple
if sm.current_state == "grapple" or sm.is_grapple_shooting:
synced_grapple_point = sm.grapple_point
synced_is_grapple_shooting = sm.is_grapple_shooting
# Update grapple rope visuals (runs on all clients for smooth interpolation)
if (synced_movement_state == "grapple" or synced_is_grapple_shooting) and camera:
grapple_rope.visible = true
var start_pos = camera.global_position + camera.global_transform.basis * Vector3(0.3, -0.3, -0.15)
var end_pos = synced_grapple_point
var dist = start_pos.distance_to(end_pos)
if dist > 0.01:
grapple_rope.global_position = (start_pos + end_pos) / 2.0
var up_vec = Vector3.UP
var dir = (end_pos - start_pos).normalized()
if abs(dir.dot(up_vec)) > 0.99:
up_vec = Vector3.RIGHT
grapple_rope.transform.basis = Basis.looking_at(dir, up_vec) * Basis.from_euler(Vector3(PI/2.0, 0, 0))
grapple_rope.scale = Vector3(1.0, dist, 1.0)
else:
if grapple_rope:
grapple_rope.visible = false
if synced_movement_state == "grapple" and velocity.length() > 5.0:
grapple_swing_player.volume_db = lerpf(grapple_swing_player.volume_db, -15.0, _delta * 10.0)
else:
grapple_swing_player.volume_db = lerpf(grapple_swing_player.volume_db, -80.0, _delta * 15.0)
# Update humanoid model animation state (runs everywhere)
# Update humanoid model animation state # Update humanoid model animation state
var humanoid = get_node_or_null("HumanoidModel") var humanoid = get_node_or_null("HumanoidModel")
@@ -392,6 +641,11 @@ func _physics_process(_delta: float) -> void:
var h_speed = Vector2(velocity.x, velocity.z).length() var h_speed = Vector2(velocity.x, velocity.z).length()
humanoid.update_state(sm.current_state, h_speed, sm.input_crouch) humanoid.update_state(sm.current_state, h_speed, sm.input_crouch)
# Write synced state for remote peers
synced_movement_state = sm.current_state
synced_movement_speed = Vector2(velocity.x, velocity.z).length()
synced_is_crouching = sm.input_crouch
func _on_movement_event(ev: String, data: Dictionary) -> void: func _on_movement_event(ev: String, data: Dictionary) -> void:
if ev == "chain_updated": if ev == "chain_updated":
@@ -402,7 +656,22 @@ func _on_movement_event(ev: String, data: Dictionary) -> void:
grapple_latch_player.play() grapple_latch_player.play()
func _process(delta: float) -> void: func _process(delta: float) -> void:
if not is_multiplayer_authority(): return # Remote players: apply synced movement state to their HumanoidModel
if not is_multiplayer_authority():
if synced_loadout_ready and not has_meta("remote_weapons_built"):
set_meta("remote_weapons_built", true)
var wman = camera.get_node_or_null("WeaponManager")
if wman and wman.has_method("_build_remote_loadout"):
wman._build_remote_loadout(synced_loadout_p1, synced_loadout_p2, synced_loadout_sp, synced_loadout_melee)
var humanoid = get_node_or_null("HumanoidModel")
if humanoid:
humanoid.update_state(synced_movement_state, synced_movement_speed, synced_is_crouching)
# Check for weapon changes
if synced_weapon_path != "" and synced_weapon_path != humanoid.get_meta("current_weapon_path", ""):
humanoid.set_weapon(synced_weapon_path)
humanoid.set_meta("current_weapon_path", synced_weapon_path)
return
if is_dead: if is_dead:
# Continuously follow the ragdoll torso # Continuously follow the ragdoll torso
if is_instance_valid(ragdoll_instance) and is_instance_valid(camera): if is_instance_valid(ragdoll_instance) and is_instance_valid(camera):
@@ -538,6 +807,7 @@ func _setup_hud() -> void:
tw.tween_property(respawn_label, "modulate:a", 0.2, 0.8) tw.tween_property(respawn_label, "modulate:a", 0.2, 0.8)
tw.tween_property(respawn_label, "modulate:a", 1.0, 0.8) tw.tween_property(respawn_label, "modulate:a", 1.0, 0.8)
if is_multiplayer_authority():
_damage_layer.add_child(death_screen) _damage_layer.add_child(death_screen)
func die(impulse: Vector3 = Vector3.ZERO) -> void: func die(impulse: Vector3 = Vector3.ZERO) -> void:
@@ -559,17 +829,16 @@ func die(impulse: Vector3 = Vector3.ZERO) -> void:
col.set_deferred("disabled", true) col.set_deferred("disabled", true)
# Spawn true physics ragdoll # Spawn true physics ragdoll
death_count += 1
var ragdoll = load("res://characters/procedural_ragdoll.gd").new() var ragdoll = load("res://characters/procedural_ragdoll.gd").new()
ragdoll.name = "Ragdoll_%s_%d" % [self.name, death_count]
ragdoll_instance = ragdoll ragdoll_instance = ragdoll
get_tree().current_scene.add_child(ragdoll) get_tree().current_scene.add_child(ragdoll)
ragdoll.global_transform = global_transform ragdoll.global_transform = global_transform
ragdoll.build_ragdoll(Color(0.2, 0.4, 0.8)) # Blueish player color ragdoll.build_ragdoll(Color(0.2, 0.4, 0.8)) # Blueish player color
# Wait a frame for physics to initialize then apply velocity # Wait a frame for physics to initialize then apply velocity
get_tree().create_timer(0.01).timeout.connect(func(): get_tree().create_timer(0.01).timeout.connect(_apply_ragdoll_velocity.bind(ragdoll, velocity, impulse))
if is_instance_valid(ragdoll):
ragdoll.apply_initial_velocities(velocity, impulse)
)
# Move camera to 3rd person view using SpringArm3D # Move camera to 3rd person view using SpringArm3D
if is_instance_valid(camera): if is_instance_valid(camera):
@@ -630,9 +899,14 @@ func die(impulse: Vector3 = Vector3.ZERO) -> void:
ragdoll_instance.set_meta("spring_arm", spring_arm) ragdoll_instance.set_meta("spring_arm", spring_arm)
# Show death screen # Show death screen
if is_multiplayer_authority():
death_screen.visible = true death_screen.visible = true
set_process_input(true) set_process_input(true)
func _apply_ragdoll_velocity(ragdoll: Node3D, l_vel: Vector3, imp: Vector3) -> void:
if is_instance_valid(ragdoll):
ragdoll.apply_initial_velocities(l_vel, imp)
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if not is_multiplayer_authority(): return if not is_multiplayer_authority(): return
if is_dead and death_screen and death_screen.visible: if is_dead and death_screen and death_screen.visible:
@@ -641,5 +915,84 @@ func _input(event: InputEvent) -> void:
_on_respawn_pressed() _on_respawn_pressed()
func _on_respawn_pressed() -> void: func _on_respawn_pressed() -> void:
# Reload scene if multiplayer.is_server():
get_tree().reload_current_scene() var spawn_pos = Vector3(randf_range(-5, 5), 5, randf_range(-5, 5))
rpc_respawn.rpc(spawn_pos)
else:
rpc_request_respawn.rpc_id(1)
@rpc("any_peer", "call_local", "reliable")
func rpc_request_respawn() -> void:
if not multiplayer.is_server():
return
var sender_id = multiplayer.get_remote_sender_id()
if sender_id != 1 and sender_id != str(name).to_int():
return
if is_dead:
var spawn_pos = Vector3(randf_range(-5, 5), 5, randf_range(-5, 5))
rpc_respawn.rpc(spawn_pos)
else:
# Deal 9999 damage to trigger the full death sequence locally and over the network
take_damage(9999, global_position, null, Vector3.ZERO)
@rpc("any_peer", "call_local", "reliable")
func rpc_respawn(spawn_pos: Vector3) -> void:
if is_dead:
is_dead = false
health = max_health
shield = max_shield
time_since_last_damage = 0.0
if is_instance_valid(ragdoll_instance):
if ragdoll_instance.has_meta("spring_arm"):
var sa = ragdoll_instance.get_meta("spring_arm")
if is_instance_valid(sa):
sa.queue_free()
ragdoll_instance.queue_free()
visible = true
process_mode = Node.PROCESS_MODE_INHERIT
# Restore camera parent
if is_instance_valid(camera) and is_instance_valid(head_pivot):
var p = camera.get_parent()
if p != head_pivot:
p.remove_child(camera)
head_pivot.add_child(camera)
camera.position = Vector3.ZERO
camera.rotation = Vector3.ZERO
# Re-enable movement state machine
if _machine:
_machine.process_mode = Node.PROCESS_MODE_INHERIT
# Re-enable collision
var col = get_node_or_null("CollisionShape3D")
if col:
col.set_deferred("disabled", false)
var visual_model = get_node_or_null("HumanoidModel")
if visual_model:
visual_model.visible = true
# Server sets the actual position to sync to everyone
if multiplayer.is_server() or not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
position = spawn_pos
velocity = Vector3.ZERO
# Local client resets UI and rebuilds weapons for their view
if is_multiplayer_authority():
if death_screen:
death_screen.visible = false
# Optional: reset weapons
var wman = camera.get_node_or_null("WeaponManager") if camera else null
if wman:
wman.visible = true
wman.process_mode = Node.PROCESS_MODE_INHERIT
var cv = wman.get("canvas_layer")
if cv:
cv.visible = true
if wman.has_method("_build_loadout"):
wman._build_loadout()
+2 -2
View File
@@ -104,8 +104,8 @@ func _respawn() -> void:
if spawner: if spawner:
local_player = spawner.get_node_or_null(str(pid)) local_player = spawner.get_node_or_null(str(pid))
if local_player and local_player.has_method("take_damage"): if local_player and local_player.has_method("rpc_request_respawn"):
local_player.take_damage(9999, Vector3.ZERO) local_player.rpc_request_respawn.rpc_id(1)
func _build_ui() -> void: func _build_ui() -> void:
bg = ColorRect.new() bg = ColorRect.new()
+5
View File
@@ -161,7 +161,12 @@ func _shoot_hitscan() -> void:
hits_remaining -= 1 hits_remaining -= 1
current_damage_mult *= penetration_damage_penalty current_damage_mult *= penetration_damage_penalty
final_target = result.position final_target = result.position
_spawn_tracer(origin, final_target)
if multiplayer.has_multiplayer_peer() and multiplayer.multiplayer_peer is not OfflineMultiplayerPeer:
player.server_play_fire_effects.rpc_id(1, origin, final_target, name, true)
func _spawn_tracer(_origin: Vector3, final_target: Vector3) -> void:
# Spawn cosmetic tracer # Spawn cosmetic tracer
var tracer = Node3D.new() var tracer = Node3D.new()
tracer.set_script(load("res://weapons/hitscan_tracer.gd")) tracer.set_script(load("res://weapons/hitscan_tracer.gd"))
+3
View File
@@ -121,6 +121,9 @@ func _shoot_projectile() -> void:
_spawn_custom_projectile(origin, fire_dir) _spawn_custom_projectile(origin, fire_dir)
if multiplayer.has_multiplayer_peer() and multiplayer.multiplayer_peer is not OfflineMultiplayerPeer:
player.server_play_fire_effects.rpc_id(1, origin, fire_dir, weapon_name, false)
func _spawn_custom_projectile(_origin: Vector3, _fire_dir: Vector3) -> void: func _spawn_custom_projectile(_origin: Vector3, _fire_dir: Vector3) -> void:
# Virtual method for custom projectiles (e.g., bouncing, homing) # Virtual method for custom projectiles (e.g., bouncing, homing)
pass pass
+9 -3
View File
@@ -137,6 +137,8 @@ func _on_hit(result: Dictionary) -> void:
if not result.collider.has_method("take_damage"): if not result.collider.has_method("take_damage"):
if result.collider is StaticBody3D or result.collider is CSGShape3D: if result.collider is StaticBody3D or result.collider is CSGShape3D:
ImpactSpawner.spawn(get_tree(), "crater", result.position, result.normal, explosion_radius * 1.5) ImpactSpawner.spawn(get_tree(), "crater", result.position, result.normal, explosion_radius * 1.5)
elif owner_player and owner_player.multiplayer.is_server():
result.collider.take_damage(damage, result.position, owner_player)
_explode(result.position) _explode(result.position)
destroy() destroy()
@@ -198,10 +200,14 @@ func _explode(pos: Vector3) -> void:
if col == owner_player and not can_self_damage: if col == owner_player and not can_self_damage:
# Skip self damage, but we still applied the knockback! # Skip self damage, but we still applied the knockback!
pass pass
else: elif owner_player and owner_player.multiplayer.is_server():
col.take_damage(final_damage, target_pos, owner_player) col.take_damage(final_damage, target_pos, owner_player, dir * final_knockback)
# Visual Effect # Broadcast Visual Explosion Effect to other peers
if owner_player and owner_player.is_multiplayer_authority() and owner_player.has_method("server_play_explosion") and owner_player.multiplayer.has_multiplayer_peer() and owner_player.multiplayer.multiplayer_peer is not OfflineMultiplayerPeer:
owner_player.server_play_explosion.rpc_id(1, pos, explosion_radius)
# Local Visual Effect
var visual = MeshInstance3D.new() var visual = MeshInstance3D.new()
var s_mesh = SphereMesh.new() var s_mesh = SphereMesh.new()
s_mesh.radius = explosion_radius s_mesh.radius = explosion_radius
+10 -3
View File
@@ -102,11 +102,18 @@ func _physics_process(delta: float) -> void:
if ignited and is_instance_valid(weapon_source): if ignited and is_instance_valid(weapon_source):
var target_dir: Vector3 = initial_aim_dir var target_dir: Vector3 = initial_aim_dir
var is_painting = weapon_source.get("is_targeting") var is_painting = false
var target: Vector3 = Vector3.ZERO
if weapon_source.get("player") and weapon_source.player:
is_painting = weapon_source.player.synced_is_targeting
target = weapon_source.player.synced_homing_target_pos
else:
is_painting = weapon_source.get("is_targeting")
if is_painting != null and is_painting: if is_painting != null and is_painting:
var target: Vector3 = weapon_source.get("homing_target_pos") target = weapon_source.get("homing_target_pos")
if target != null and target != Vector3.ZERO:
if is_painting and target != Vector3.ZERO:
target_dir = (target - global_position).normalized() target_dir = (target - global_position).normalized()
if target_dir != Vector3.ZERO: if target_dir != Vector3.ZERO:
+1
View File
@@ -58,6 +58,7 @@ func _on_hit(result: Dictionary) -> void:
final_damage = lerpf(damage, min_damage, falloff_factor) final_damage = lerpf(damage, min_damage, falloff_factor)
if result.collider.has_method("take_damage"): if result.collider.has_method("take_damage"):
if owner_player and owner_player.multiplayer.is_server():
result.collider.take_damage(final_damage, result.position, owner_player) result.collider.take_damage(final_damage, result.position, owner_player)
elif result.collider is StaticBody3D or result.collider is CSGShape3D: elif result.collider is StaticBody3D or result.collider is CSGShape3D:
var size = 0.4 if impact_type == "plasma" else 0.15 var size = 0.4 if impact_type == "plasma" else 0.15
+22 -1
View File
@@ -91,9 +91,18 @@ func _process(delta: float) -> void:
(targeting_laser.mesh as CylinderMesh).height = 300.0 (targeting_laser.mesh as CylinderMesh).height = 300.0
targeting_laser.position.z = -150.0 targeting_laser.position.z = -150.0
if player and player.is_multiplayer_authority():
player.synced_is_targeting = true
player.synced_homing_target_pos = homing_target_pos
else:
if player and player.is_multiplayer_authority():
player.synced_is_targeting = false
func unequip() -> void: func unequip() -> void:
is_targeting = false is_targeting = false
targeting_laser.visible = false targeting_laser.visible = false
if player and player.is_multiplayer_authority():
player.synced_is_targeting = false
# Override fire to shoot 8 rockets at once # Override fire to shoot 8 rockets at once
func _fire() -> void: func _fire() -> void:
@@ -141,6 +150,9 @@ func _fire() -> void:
var fire_dir = (base_dir + right * x_dir + up * y_dir).normalized() var fire_dir = (base_dir + right * x_dir + up * y_dir).normalized()
_spawn_custom_projectile(proj_origin, fire_dir) _spawn_custom_projectile(proj_origin, fire_dir)
if multiplayer.has_multiplayer_peer() and multiplayer.multiplayer_peer is not OfflineMultiplayerPeer:
player.server_play_fire_effects.rpc_id(1, proj_origin, fire_dir, weapon_name, false)
var _current_aim_dir: Vector3 = Vector3.ZERO var _current_aim_dir: Vector3 = Vector3.ZERO
func _spawn_custom_projectile(origin: Vector3, fire_dir: Vector3) -> void: func _spawn_custom_projectile(origin: Vector3, fire_dir: Vector3) -> void:
@@ -176,7 +188,16 @@ func _spawn_custom_projectile(origin: Vector3, fire_dir: Vector3) -> void:
if player: if player:
proj.velocity += fire_dir * max(0.0, player.velocity.dot(fire_dir)) proj.velocity += fire_dir * max(0.0, player.velocity.dot(fire_dir))
proj.initial_aim_dir = _current_aim_dir var aim_dir = _current_aim_dir
# If remote player, use the synced head rotation to determine forward direction
if player and not player.is_multiplayer_authority():
var head = player.get_node_or_null("HeadPivot")
if head:
aim_dir = -head.global_transform.basis.z.normalized()
else:
aim_dir = fire_dir # Fallback
proj.initial_aim_dir = aim_dir
proj.cruise_speed = 65.0 proj.cruise_speed = 65.0
proj.ignition_timer = randf_range(0.3, 0.6) # Staggered ignition proj.ignition_timer = randf_range(0.3, 0.6) # Staggered ignition
+42
View File
@@ -61,6 +61,17 @@ func _process(_delta: float) -> void:
vm_camera.fov = camera.fov vm_camera.fov = camera.fov
func _build_loadout() -> void: func _build_loadout() -> void:
var is_auth = false
if player:
is_auth = player.is_multiplayer_authority()
else:
is_auth = is_multiplayer_authority()
print("DEBUG [", multiplayer.get_unique_id(), "]: _build_loadout is_auth=", is_auth, " player=", str(player.name) if player else "null")
if not is_auth:
return # Let the player_movement_controller's remote _process call _build_remote_loadout
# Clear existing weapons # Clear existing weapons
for w in weapons.values(): for w in weapons.values():
if is_instance_valid(w): if is_instance_valid(w):
@@ -68,6 +79,17 @@ func _build_loadout() -> void:
weapons.clear() weapons.clear()
var l = LoadoutManager.get_active_loadout() var l = LoadoutManager.get_active_loadout()
print("DEBUG [", multiplayer.get_unique_id(), "]: Loadout primary1=", l["primary_1"])
if player:
player.synced_loadout_p1 = l["primary_1"]
player.synced_loadout_p2 = l["primary_2"]
player.synced_loadout_sp = l["special"]
if l.has("melee"):
player.synced_loadout_melee = l["melee"]
else:
player.synced_loadout_melee = ""
player.synced_loadout_ready = true
_spawn_weapon(1, l["primary_1"]) _spawn_weapon(1, l["primary_1"])
_spawn_weapon(2, l["primary_2"]) _spawn_weapon(2, l["primary_2"])
@@ -77,17 +99,34 @@ func _build_loadout() -> void:
_equip_slot(1) _equip_slot(1)
func _build_remote_loadout(p1: String, p2: String, sp: String, melee: String) -> void:
for w in weapons.values():
if is_instance_valid(w):
w.queue_free()
weapons.clear()
_spawn_weapon(1, p1)
_spawn_weapon(2, p2)
_spawn_weapon(3, sp)
if melee != "":
_spawn_weapon(4, melee)
_equip_slot(1)
func _spawn_weapon(slot: int, weapon_id: String) -> void: func _spawn_weapon(slot: int, weapon_id: String) -> void:
if weapon_id == "" or weapon_id == "none" or not LoadoutManager.weapon_db.has(weapon_id): if weapon_id == "" or weapon_id == "none" or not LoadoutManager.weapon_db.has(weapon_id):
print("DEBUG [", multiplayer.get_unique_id(), "]: _spawn_weapon failed check for ", weapon_id)
return return
var w_data = LoadoutManager.weapon_db[weapon_id] var w_data = LoadoutManager.weapon_db[weapon_id]
var script_path = w_data["script"] var script_path = w_data["script"]
if script_path == "": if script_path == "":
print("DEBUG [", multiplayer.get_unique_id(), "]: _spawn_weapon failed script path for ", weapon_id)
return return
var script = load(script_path) var script = load(script_path)
if script: if script:
print("DEBUG [", multiplayer.get_unique_id(), "]: Successfully loaded script, adding weapon ", weapon_id)
var w = script.new() var w = script.new()
w.name = "Weapon_" + str(slot) + "_" + weapon_id w.name = "Weapon_" + str(slot) + "_" + weapon_id
# Assuming all weapons have player and camera vars # Assuming all weapons have player and camera vars
@@ -184,6 +223,9 @@ func _equip_slot(slot: int) -> void:
var humanoid = player.get_node("HumanoidModel") var humanoid = player.get_node("HumanoidModel")
if humanoid.has_method("set_weapon") and w.has_meta("script_path"): if humanoid.has_method("set_weapon") and w.has_meta("script_path"):
humanoid.set_weapon(w.get_meta("script_path")) humanoid.set_weapon(w.get_meta("script_path"))
# Update the synced variable so remote peers pick it up
if "synced_weapon_path" in player:
player.synced_weapon_path = w.get_meta("script_path")
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED: if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED: