Feat/10 adding multiplayer fundamentals #14
+34
-19
@@ -293,25 +293,40 @@ func _spawn_player(pid: int) -> CharacterBody3D:
|
||||
# Use a slightly random spawn position to avoid exact overlapping
|
||||
player.position = Vector3(randf_range(-2, 2), 2.0, randf_range(-2, 2))
|
||||
|
||||
# Network Synchronizer
|
||||
var sync = MultiplayerSynchronizer.new()
|
||||
sync.name = "MultiplayerSynchronizer"
|
||||
sync.set_multiplayer_authority(pid)
|
||||
var rep_config = SceneReplicationConfig.new()
|
||||
rep_config.add_property(":position")
|
||||
rep_config.add_property(":rotation")
|
||||
rep_config.add_property("HeadPivot:rotation")
|
||||
rep_config.add_property(":synced_movement_state")
|
||||
rep_config.add_property(":synced_movement_speed")
|
||||
rep_config.add_property(":synced_is_crouching")
|
||||
rep_config.add_property(":synced_weapon_path")
|
||||
rep_config.add_property(":synced_loadout_p1")
|
||||
rep_config.add_property(":synced_loadout_p2")
|
||||
rep_config.add_property(":synced_loadout_sp")
|
||||
rep_config.add_property(":synced_loadout_melee")
|
||||
rep_config.add_property(":synced_loadout_ready")
|
||||
sync.replication_config = rep_config
|
||||
player.add_child(sync)
|
||||
# Server Synchronizer (Host is the ground truth)
|
||||
var server_sync = MultiplayerSynchronizer.new()
|
||||
server_sync.name = "ServerSynchronizer"
|
||||
server_sync.set_multiplayer_authority(1) # Host always controls these
|
||||
var server_rep_config = SceneReplicationConfig.new()
|
||||
server_rep_config.add_property(":position")
|
||||
server_rep_config.add_property(":synced_movement_state")
|
||||
server_rep_config.add_property(":synced_movement_speed")
|
||||
server_rep_config.add_property(":synced_is_crouching")
|
||||
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
|
||||
var col_shape := CollisionShape3D.new()
|
||||
|
||||
@@ -64,6 +64,8 @@ func _ready() -> void:
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if player and not player.multiplayer.is_server(): return
|
||||
|
||||
# Update coyote time
|
||||
if on_ground:
|
||||
coyote_timer = params.coyote_time
|
||||
|
||||
@@ -54,7 +54,13 @@ var grapple_rope: MeshInstance3D
|
||||
var synced_movement_state: String = "idle"
|
||||
var synced_movement_speed: float = 0.0
|
||||
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_p2: String = ""
|
||||
@@ -315,7 +321,17 @@ func _spawn_remote_tracer(origin: Vector3, target: Vector3) -> void:
|
||||
tracer.look_at(target, up_vec)
|
||||
|
||||
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()
|
||||
proj.name = "RemoteProjectile"
|
||||
|
||||
@@ -339,7 +355,7 @@ func _spawn_remote_projectile(origin: Vector3, fire_dir: Vector3, weapon_name: S
|
||||
light.omni_range = 4.0
|
||||
proj.add_child(light)
|
||||
else:
|
||||
# Rocket-shaped mesh (cone + cylinder)
|
||||
# Generic rocket fallback
|
||||
var mesh_inst = MeshInstance3D.new()
|
||||
var p_mesh = CylinderMesh.new()
|
||||
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)
|
||||
proj.add_child(mesh_inst)
|
||||
|
||||
# Smoke trail
|
||||
var smoke = GPUParticles3D.new()
|
||||
var smoke_mat = ParticleProcessMaterial.new()
|
||||
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
|
||||
die(impulse)
|
||||
|
||||
if is_multiplayer_authority():
|
||||
if multiplayer.is_server():
|
||||
if get_node_or_null("/root/NetworkManager") and multiplayer.has_multiplayer_peer():
|
||||
var victim_id = multiplayer.get_unique_id()
|
||||
var killer_id = attacker_id
|
||||
@@ -512,68 +527,34 @@ func _ensure_machine() -> MovementStateMachine:
|
||||
_machine = 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:
|
||||
if not is_multiplayer_authority(): return
|
||||
var speed = velocity.length()
|
||||
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
|
||||
# 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")
|
||||
|
||||
# Transform input direction by player yaw so movement is camera-relative
|
||||
var forward := -global_transform.basis.z
|
||||
forward.y = 0.0
|
||||
forward = forward.normalized()
|
||||
@@ -585,8 +566,70 @@ func _physics_process(_delta: float) -> void:
|
||||
if world_dir.length_squared() > 1.0:
|
||||
world_dir = world_dir.normalized()
|
||||
|
||||
sm.input_dir = raw_input
|
||||
sm.wish_dir_world = world_dir
|
||||
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 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
|
||||
var humanoid = get_node_or_null("HumanoidModel")
|
||||
@@ -873,9 +916,11 @@ func _on_respawn_pressed() -> void:
|
||||
|
||||
@rpc("any_peer", "call_local", "reliable")
|
||||
func rpc_respawn(spawn_pos: Vector3) -> void:
|
||||
if is_dead:
|
||||
is_dead = false
|
||||
health = max_health
|
||||
shield = 0.0
|
||||
shield = max_shield
|
||||
time_since_last_damage = 0.0
|
||||
|
||||
if is_instance_valid(ragdoll_instance):
|
||||
if ragdoll_instance.has_meta("spring_arm"):
|
||||
@@ -884,6 +929,9 @@ func rpc_respawn(spawn_pos: Vector3) -> void:
|
||||
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()
|
||||
@@ -906,11 +954,15 @@ func rpc_respawn(spawn_pos: Vector3) -> void:
|
||||
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
|
||||
position = spawn_pos
|
||||
velocity = Vector3.ZERO
|
||||
|
||||
# Optional: reset weapons
|
||||
var wman = camera.get_node_or_null("WeaponManager") if camera else null
|
||||
|
||||
@@ -137,7 +137,7 @@ func _on_hit(result: Dictionary) -> void:
|
||||
if not result.collider.has_method("take_damage"):
|
||||
if result.collider is StaticBody3D or result.collider is CSGShape3D:
|
||||
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)
|
||||
_explode(result.position)
|
||||
destroy()
|
||||
@@ -200,11 +200,11 @@ func _explode(pos: Vector3) -> void:
|
||||
if col == owner_player and not can_self_damage:
|
||||
# Skip self damage, but we still applied the knockback!
|
||||
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)
|
||||
|
||||
# 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)
|
||||
|
||||
# Local Visual Effect
|
||||
|
||||
@@ -102,11 +102,18 @@ func _physics_process(delta: float) -> void:
|
||||
|
||||
if ignited and is_instance_valid(weapon_source):
|
||||
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:
|
||||
var target: Vector3 = weapon_source.get("homing_target_pos")
|
||||
if target != null and target != Vector3.ZERO:
|
||||
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:
|
||||
|
||||
@@ -58,7 +58,7 @@ func _on_hit(result: Dictionary) -> void:
|
||||
final_damage = lerpf(damage, min_damage, falloff_factor)
|
||||
|
||||
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)
|
||||
elif result.collider is StaticBody3D or result.collider is CSGShape3D:
|
||||
var size = 0.4 if impact_type == "plasma" else 0.15
|
||||
|
||||
@@ -91,9 +91,18 @@ func _process(delta: float) -> void:
|
||||
(targeting_laser.mesh as CylinderMesh).height = 300.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:
|
||||
is_targeting = 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
|
||||
func _fire() -> void:
|
||||
|
||||
Reference in New Issue
Block a user