Feat/10 adding multiplayer fundamentals #14

Merged
Dotts merged 16 commits from feat/10-adding-multiplayer-fundamentals into main 2026-06-06 19:31:25 -07:00
7 changed files with 194 additions and 109 deletions
Showing only changes of commit dbbfbef4a4 - Show all commits
+34 -19
View File
@@ -293,25 +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")
rep_config.add_property(":synced_movement_state") server_rep_config.add_property(":synced_is_crouching")
rep_config.add_property(":synced_movement_speed") server_rep_config.add_property(":health")
rep_config.add_property(":synced_is_crouching") server_rep_config.add_property(":shield")
rep_config.add_property(":synced_weapon_path") server_rep_config.add_property(":is_dead")
rep_config.add_property(":synced_loadout_p1") server_rep_config.add_property(":synced_grapple_point")
rep_config.add_property(":synced_loadout_p2") server_rep_config.add_property(":synced_is_grapple_shooting")
rep_config.add_property(":synced_loadout_sp") server_sync.replication_config = server_rep_config
rep_config.add_property(":synced_loadout_melee") player.add_child(server_sync)
rep_config.add_property(":synced_loadout_ready")
sync.replication_config = rep_config # Client Synchronizer (Client dictates their aim and loadout setup)
player.add_child(sync) 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()
+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
+129 -77
View File
@@ -54,7 +54,13 @@ var grapple_rope: MeshInstance3D
var synced_movement_state: String = "idle" var synced_movement_state: String = "idle"
var synced_movement_speed: float = 0.0 var synced_movement_speed: float = 0.0
var synced_is_crouching: bool = false var synced_is_crouching: bool = false
var synced_weapon_path: String = "" @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_p1: String = ""
var synced_loadout_p2: String = "" var synced_loadout_p2: String = ""
@@ -315,7 +321,17 @@ func _spawn_remote_tracer(origin: Vector3, target: Vector3) -> void:
tracer.look_at(target, up_vec) tracer.look_at(target, up_vec)
func _spawn_remote_projectile(origin: Vector3, fire_dir: Vector3, weapon_name: String) -> void: func _spawn_remote_projectile(origin: Vector3, fire_dir: Vector3, weapon_name: String) -> void:
# Spawn a visual-only projectile (no damage, just the mesh flying) # 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:
for w in wman.weapons.values():
if "weapon_name" in w and w.weapon_name == weapon_name:
if w.has_method("_spawn_custom_projectile"):
w._spawn_custom_projectile(origin, fire_dir)
return
# Fallback if weapon not found (e.g. joined late and weapons didn't build yet)
var proj = Node3D.new() var proj = Node3D.new()
proj.name = "RemoteProjectile" proj.name = "RemoteProjectile"
@@ -339,7 +355,7 @@ func _spawn_remote_projectile(origin: Vector3, fire_dir: Vector3, weapon_name: S
light.omni_range = 4.0 light.omni_range = 4.0
proj.add_child(light) proj.add_child(light)
else: else:
# Rocket-shaped mesh (cone + cylinder) # Generic rocket fallback
var mesh_inst = MeshInstance3D.new() var mesh_inst = MeshInstance3D.new()
var p_mesh = CylinderMesh.new() var p_mesh = CylinderMesh.new()
p_mesh.top_radius = 0.0 p_mesh.top_radius = 0.0
@@ -354,7 +370,6 @@ func _spawn_remote_projectile(origin: Vector3, fire_dir: Vector3, weapon_name: S
mesh_inst.rotation.x = deg_to_rad(-90) mesh_inst.rotation.x = deg_to_rad(-90)
proj.add_child(mesh_inst) proj.add_child(mesh_inst)
# Smoke trail
var smoke = GPUParticles3D.new() var smoke = GPUParticles3D.new()
var smoke_mat = ParticleProcessMaterial.new() var smoke_mat = ParticleProcessMaterial.new()
smoke_mat.direction = Vector3(0, 0, 1) smoke_mat.direction = Vector3(0, 0, 1)
@@ -468,7 +483,7 @@ func rpc_take_damage(amount: float, hit_pos: Vector3, attacker_id: int, weapon_n
health = 0.0 health = 0.0
die(impulse) die(impulse)
if is_multiplayer_authority(): 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() var victim_id = multiplayer.get_unique_id()
var killer_id = attacker_id var killer_id = attacker_id
@@ -512,12 +527,56 @@ 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
if is_multiplayer_authority():
var raw_input := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
var forward := -global_transform.basis.z
forward.y = 0.0
forward = forward.normalized()
var right := global_transform.basis.x
right.y = 0.0
right = right.normalized()
var world_dir := (forward * (-raw_input.y) + right * raw_input.x)
if world_dir.length_squared() > 1.0:
world_dir = world_dir.normalized()
var input_jump = Input.is_action_pressed("jump")
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 speed = velocity.length()
var wind_factor = clampf((speed - 10.0) / 25.0, 0.0, 1.0) 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) wind_player.volume_db = lerpf(-40.0, -15.0, wind_factor)
if not wind_player.playing and wind_factor > 0.0: if not wind_player.playing and wind_factor > 0.0:
wind_player.play() wind_player.play()
@@ -526,67 +585,51 @@ func _physics_process(_delta: float) -> void:
if not sm: if not sm:
return return
# Poll input in _physics_process so it's synchronized with the state machine tick # Only the Server sets input values to the StateMachine for evaluation
sm.input_jump_just_pressed = Input.is_action_just_pressed("jump") if multiplayer.is_server():
sm.input_jump_pressed = Input.is_action_pressed("jump") sm.input_dir = _client_input_dir
sm.input_crouch = Input.is_action_pressed("crouch") sm.wish_dir_world = _client_wish_dir_world
sm.input_dash = Input.is_action_just_pressed("dash") 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
sm.input_grapple = Input.is_action_pressed("grapple") # Reset one-frame actions
sm.input_grapple_just_pressed = Input.is_action_just_pressed("grapple") _client_jump_just_pressed = false
_client_dash = false
_client_grapple_just_pressed = false
# Update grapple rope visuals # Update synced properties for the grapple
if (sm.current_state == "grapple" or sm.is_grapple_shooting) and camera: 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 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 start_pos = camera.global_position + camera.global_transform.basis * Vector3(0.3, -0.3, -0.15)
var end_pos = sm.grapple_point var end_pos = synced_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) var dist = start_pos.distance_to(end_pos)
if dist > 0.01: if dist > 0.01:
grapple_rope.global_position = (start_pos + end_pos) / 2.0 grapple_rope.global_position = (start_pos + end_pos) / 2.0
# Look at end point
var up_vec = Vector3.UP var up_vec = Vector3.UP
var dir = (end_pos - start_pos).normalized() var dir = (end_pos - start_pos).normalized()
if abs(dir.dot(up_vec)) > 0.99: if abs(dir.dot(up_vec)) > 0.99:
up_vec = Vector3.RIGHT 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)) 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) grapple_rope.scale = Vector3(1.0, dist, 1.0)
else: else:
if grapple_rope: if grapple_rope:
grapple_rope.visible = false grapple_rope.visible = false
# Swing sound if synced_movement_state == "grapple" and velocity.length() > 5.0:
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) grapple_swing_player.volume_db = lerpf(grapple_swing_player.volume_db, -15.0, _delta * 10.0)
else: else:
grapple_swing_player.volume_db = lerpf(grapple_swing_player.volume_db, -80.0, _delta * 15.0) grapple_swing_player.volume_db = lerpf(grapple_swing_player.volume_db, -80.0, _delta * 15.0)
# Raw 2D input # Update humanoid model animation state (runs everywhere)
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
forward.y = 0.0
forward = forward.normalized()
var right := global_transform.basis.x
right.y = 0.0
right = right.normalized()
var world_dir := (forward * (-raw_input.y) + right * raw_input.x)
if world_dir.length_squared() > 1.0:
world_dir = world_dir.normalized()
sm.input_dir = raw_input
sm.wish_dir_world = world_dir
# Update humanoid model animation state # Update humanoid model animation state
var humanoid = get_node_or_null("HumanoidModel") var humanoid = get_node_or_null("HumanoidModel")
@@ -873,44 +916,53 @@ func _on_respawn_pressed() -> void:
@rpc("any_peer", "call_local", "reliable") @rpc("any_peer", "call_local", "reliable")
func rpc_respawn(spawn_pos: Vector3) -> void: func rpc_respawn(spawn_pos: Vector3) -> void:
is_dead = false if is_dead:
health = max_health is_dead = false
shield = 0.0 health = max_health
shield = max_shield
time_since_last_damage = 0.0
if is_instance_valid(ragdoll_instance): if is_instance_valid(ragdoll_instance):
if ragdoll_instance.has_meta("spring_arm"): if ragdoll_instance.has_meta("spring_arm"):
var sa = ragdoll_instance.get_meta("spring_arm") var sa = ragdoll_instance.get_meta("spring_arm")
if is_instance_valid(sa): if is_instance_valid(sa):
sa.queue_free() sa.queue_free()
ragdoll_instance.queue_free() ragdoll_instance.queue_free()
# Restore camera parent visible = true
if is_instance_valid(camera) and is_instance_valid(head_pivot): process_mode = Node.PROCESS_MODE_INHERIT
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 # Restore camera parent
if _machine: if is_instance_valid(camera) and is_instance_valid(head_pivot):
_machine.process_mode = Node.PROCESS_MODE_INHERIT 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 collision # Re-enable movement state machine
var col = get_node_or_null("CollisionShape3D") if _machine:
if col: _machine.process_mode = Node.PROCESS_MODE_INHERIT
col.set_deferred("disabled", false)
var visual_model = get_node_or_null("HumanoidModel") # Re-enable collision
if visual_model: var col = get_node_or_null("CollisionShape3D")
visual_model.visible = true 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 is_multiplayer_authority():
if death_screen: if death_screen:
death_screen.visible = false death_screen.visible = false
position = spawn_pos
velocity = Vector3.ZERO
# Optional: reset weapons # Optional: reset weapons
var wman = camera.get_node_or_null("WeaponManager") if camera else null var wman = camera.get_node_or_null("WeaponManager") if camera else null
+3 -3
View File
@@ -137,7 +137,7 @@ 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 not owner_player or owner_player.is_multiplayer_authority(): elif owner_player and owner_player.multiplayer.is_server():
result.collider.take_damage(damage, result.position, owner_player) result.collider.take_damage(damage, result.position, owner_player)
_explode(result.position) _explode(result.position)
destroy() destroy()
@@ -200,11 +200,11 @@ 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
elif not owner_player or owner_player.is_multiplayer_authority(): elif owner_player and owner_player.multiplayer.is_server():
col.take_damage(final_damage, target_pos, owner_player, dir * final_knockback) col.take_damage(final_damage, target_pos, owner_player, dir * final_knockback)
# Broadcast Visual Explosion Effect to other peers # Broadcast Visual Explosion Effect to other peers
if owner_player and owner_player.has_method("server_play_explosion") and owner_player.multiplayer.has_multiplayer_peer() and owner_player.multiplayer.multiplayer_peer is not OfflineMultiplayerPeer: 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) owner_player.server_play_explosion.rpc_id(1, pos, explosion_radius)
# Local Visual Effect # Local Visual Effect
+12 -5
View File
@@ -102,12 +102,19 @@ 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 is_painting != null and is_painting: if weapon_source.get("player") and weapon_source.player:
var target: Vector3 = weapon_source.get("homing_target_pos") is_painting = weapon_source.player.synced_is_targeting
if target != null and target != Vector3.ZERO: target = weapon_source.player.synced_homing_target_pos
target_dir = (target - global_position).normalized() else:
is_painting = weapon_source.get("is_targeting")
if is_painting != null and is_painting:
target = weapon_source.get("homing_target_pos")
if is_painting and target != Vector3.ZERO:
target_dir = (target - global_position).normalized()
if target_dir != Vector3.ZERO: if target_dir != Vector3.ZERO:
var current_dir = velocity.normalized() var current_dir = velocity.normalized()
+1 -1
View File
@@ -58,7 +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 not owner_player or owner_player.is_multiplayer_authority(): 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
+9
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: