瀏覽代碼

Add support for networked multiplayer (#163)

Fabio Alessandrelli 2 年之前
父節點
當前提交
bb833aa97c

+ 39 - 26
enemies/red_robot/parts/part.gd

@@ -2,41 +2,54 @@ extends RigidBody3D
 
 var puff_effect = preload("res://enemies/red_robot/parts/part_disappear_effect/part_disappear.tscn")
 
+var _mat : Material = null
+
 @export var lifetime: float = 3.0
 @export var lifetime_random: float = 3.0
 @export var disappearing_time: float = 0.5
+@export var fade_value : float = 0.0 :
+	set(value):
+		fade_value = value
+		if _mat:
+			_mat.next_pass.set_shader_parameter("emission_cutout", fade_value)
 
-var _lifetime
 var _disappearing_counter = 0.0
-var _is_disappearing = false
-var _mat
-
-@onready var _mesh = $Model.get_child(0)
-
 
 func _ready():
-	_mat = _mesh.mesh.surface_get_material(0).duplicate()
-	_mesh.mesh.surface_set_material(0, _mat)
-	_mat.next_pass = _mat.next_pass.duplicate()
-	randomize()
-	_lifetime = lifetime + lifetime_random * randf()
-
-
-func start_disappear_countdown():
-	await get_tree().create_timer(_lifetime).timeout
-	_is_disappearing = true
+	set_process(false)
+	if not OS.has_feature("dedicated_server"):
+		var mesh := $Model.get_child(0) as MeshInstance3D
+		_mat = mesh.mesh.surface_get_material(0).duplicate()
+		mesh.mesh.surface_set_material(0, _mat)
+		_mat.next_pass = _mat.next_pass.duplicate()
+
+
+func explode():
+	# Start synching.
+	$MultiplayerSynchronizer.public_visibility = true
+	freeze = false
+	if not multiplayer.is_server():
+		return
+	get_node("Col1").disabled = false
+	get_node("Col2").disabled = false
+	linear_velocity = 3 * (Vector3.UP).normalized()
+	angular_velocity = (Vector3(randf(), randf(), randf()).normalized() * 2 - Vector3.ONE) * 10
+	await get_tree().create_timer(lifetime + lifetime_random * randf()).timeout
+	set_process(true)
 
 
 func _process(delta):
-	if not _is_disappearing:
-		return
-	var curve_val = pow(_disappearing_counter / disappearing_time, 2.0)
-	_mat.next_pass.set_shader_parameter("emission_cutout", curve_val)
+	fade_value = pow(_disappearing_counter / disappearing_time, 2.0)
 	_disappearing_counter += delta
 	if _disappearing_counter >= disappearing_time - 0.2:
-		var puff = puff_effect.instantiate()
-		get_parent().add_child(puff)
-		puff.global_transform.origin = global_transform.origin
-		_is_disappearing = false
-		await get_tree().create_timer(0.2).timeout
-		queue_free()
+		destroy.rpc()
+		set_process(false)
+
+
+@rpc("call_local")
+func destroy():
+	var puff = puff_effect.instantiate()
+	get_parent().add_child(puff)
+	puff.global_transform.origin = global_transform.origin
+	await get_tree().create_timer(0.2).timeout
+	queue_free()

+ 81 - 62
enemies/red_robot/red_robot.gd

@@ -6,7 +6,7 @@ enum State {
 	SHOOTING = 2,
 }
 
-const PLAYER_AIM_TOLERANCE_DEGREES = 15
+const PLAYER_AIM_TOLERANCE_DEGREES = deg_to_rad(15)
 
 const SHOOT_WAIT = 6.0
 const AIM_TIME = 1
@@ -14,15 +14,18 @@ const AIM_TIME = 1
 const AIM_PREPARE_TIME = 0.5
 const BLEND_AIM_SPEED = 0.05
 
-@export var health: int = 5
+signal exploded()
+
 @export var test_shoot: bool = false
 
-var state = State.APPROACH
+@export var target_position := Vector3()
+@export var health: int = 5
+@export var state : State = State.APPROACH
+@export var dead = false
+@export var aim_preparing = AIM_PREPARE_TIME
 
 var shoot_countdown = SHOOT_WAIT
 var aim_countdown = AIM_TIME
-var aim_preparing = AIM_PREPARE_TIME
-var dead = false
 
 var player = null
 var orientation = Transform3D()
@@ -56,8 +59,13 @@ func _ready():
 	$AnimationTree.active = true
 	if test_shoot:
 		shoot_countdown = 0.0
-	animation_tree["parameters/state/transition_request"] = "idle" # Go idle.
 
+	if dead:
+		model.visible = false
+		collision_shape.disabled = true
+		animation_tree.active = false
+
+	animate()
 
 func resume_approach():
 	state = State.APPROACH
@@ -65,6 +73,7 @@ func resume_approach():
 	shoot_countdown = SHOOT_WAIT
 
 
+@rpc("call_local")
 func hit():
 	if dead:
 		return
@@ -74,39 +83,24 @@ func hit():
 	health -= 1
 	if health == 0:
 		dead = true
-		var base_xf = global_transform.basis
 		animation_tree.active = false
 		model.visible = false
 		death.visible = true
 		collision_shape.disabled = true
 
-		death_shield1.freeze = false
-		death_shield1.get_node("Col1").disabled = false
-		death_shield1.get_node("Col2").disabled = false
-
-		death_shield2.freeze = false
-		death_shield2.get_node("Col1").disabled = false
-		death_shield2.get_node("Col2").disabled = false
-
-		death_head.freeze = false
-		death_head.get_node("Col1").disabled = false
-		death_head.get_node("Col2").disabled = false
-
 		death_detach_spark1.emitting = true
 		death_detach_spark2.emitting = true
 
-		death_shield1.linear_velocity = 3 * (Vector3.UP - base_xf.x).normalized()
-		death_shield2.linear_velocity = 3 * (Vector3.UP + base_xf.x).normalized()
-		death_head.linear_velocity = 3 * (Vector3.UP).normalized()
-		death_shield1.angular_velocity = (Vector3(randf(), randf(), randf()).normalized() * 2 - Vector3.ONE) * 10
-		death_shield2.angular_velocity = (Vector3(randf(), randf(), randf()).normalized() * 2 - Vector3.ONE) * 10
-		death_head.angular_velocity = (Vector3(randf(), randf(), randf()).normalized() * 2 - Vector3.ONE) * 10
-
-		death_shield1.start_disappear_countdown()
-		death_shield2.start_disappear_countdown()
-		death_head.start_disappear_countdown()
+		death_shield1.explode()
+		death_shield2.explode()
+		death_head.explode()
 
 		explosion_sound.play()
+		exploded.emit()
+
+		if multiplayer.is_server():
+			await get_tree().create_timer(10.0).timeout
+			queue_free()
 
 
 func shoot():
@@ -136,38 +130,73 @@ func shoot():
 			player.add_camera_shake_trauma(13)
 
 
+func animate(delta:=0.0):
+	if state == State.APPROACH:
+		var to_player_local = target_position * global_transform
+		# The front of the robot is +Z, and atan2 is zero at +X, so we need to use the Z for the X parameter (second one).
+		var angle_to_player = atan2(to_player_local.x, to_player_local.z)
+		if angle_to_player > PLAYER_AIM_TOLERANCE_DEGREES:
+			animation_tree["parameters/state/transition_request"] = "turn_left"
+		elif angle_to_player < -PLAYER_AIM_TOLERANCE_DEGREES:
+			animation_tree["parameters/state/transition_request"] = "turn_right"
+		elif target_position == Vector3.ZERO:
+			animation_tree["parameters/state/transition_request"] = "idle"
+		else:
+			animation_tree["parameters/state/transition_request"] = "walk"
+	else:
+		animation_tree["parameters/state/transition_request"] = "idle"
+
+	# Aiming or shooting
+	if target_position != Vector3.ZERO:
+		animation_tree["parameters/aiming/blend_amount"] = clamp(aim_preparing / AIM_PREPARE_TIME, 0, 1)
+
+		var to_cannon_local = target_position + Vector3.UP * ray_mesh.global_transform
+		var h_angle = rad_to_deg(atan2( to_cannon_local.x, -to_cannon_local.z ))
+		var v_angle = rad_to_deg(atan2( to_cannon_local.y, -to_cannon_local.z ))
+		var blend_pos = animation_tree.get("parameters/aim/blend_position")
+		var h_motion = BLEND_AIM_SPEED * delta * -h_angle
+		blend_pos.x += h_motion
+		blend_pos.x = clamp(blend_pos.x, -1, 1)
+
+		var v_motion = BLEND_AIM_SPEED * delta * v_angle
+		blend_pos.y += v_motion
+		blend_pos.y = clamp(blend_pos.y, -1, 1)
+
+		animation_tree["parameters/aim/blend_position"] = blend_pos
+
+
 func _physics_process(delta):
+	if dead:
+		return
+
+	if not multiplayer.is_server():
+		animate(delta)
+		return
+
 	if test_shoot:
 		shoot()
 		test_shoot = false
 
-	if dead:
-		return
-
 	if not player:
-		animation_tree["parameters/state/transition_request"] = "idle" # Go idle.
+		target_position = Vector3()
+		animate(delta)
 		set_velocity(gravity * delta)
 		set_up_direction(Vector3.UP)
 		move_and_slide()
 		return
 
+	target_position = player.global_transform.origin
+
 	if state == State.APPROACH:
 		if aim_preparing > 0:
 			aim_preparing -= delta
 			if aim_preparing < 0:
 				aim_preparing = 0
-			animation_tree["parameters/aiming/blend_amount"] = aim_preparing / AIM_PREPARE_TIME
 
-		var to_player_local = player.global_transform.origin * global_transform
+		var to_player_local = target_position * global_transform
 		# The front of the robot is +Z, and atan2 is zero at +X, so we need to use the Z for the X parameter (second one).
 		var angle_to_player = atan2(to_player_local.x, to_player_local.z)
-		var tolerance = deg_to_rad(PLAYER_AIM_TOLERANCE_DEGREES)
-		if angle_to_player > tolerance:
-			animation_tree["parameters/state/transition_request"] = "turn_left"
-		elif angle_to_player < -tolerance:
-			animation_tree["parameters/state/transition_request"] = "turn_right"
-		else:
-			animation_tree["parameters/state/transition_request"] = "walk"
+		if angle_to_player > -PLAYER_AIM_TOLERANCE_DEGREES and angle_to_player < PLAYER_AIM_TOLERANCE_DEGREES:
 			# Facing player, try to shoot.
 			shoot_countdown -= delta
 			if shoot_countdown < 0:
@@ -180,7 +209,6 @@ func _physics_process(delta):
 					state = State.AIM
 					aim_countdown = AIM_TIME
 					aim_preparing = 0
-					animation_tree["parameters/state/transition_request"] = "idle"
 				else:
 					# Player not in sight, do nothing.
 					shoot_countdown = SHOOT_WAIT
@@ -195,34 +223,19 @@ func _physics_process(delta):
 			if aim_preparing > AIM_PREPARE_TIME:
 				aim_preparing = AIM_PREPARE_TIME
 
-		animation_tree["parameters/aiming/blend_amount"] = clamp(aim_preparing / AIM_PREPARE_TIME, 0, 1)
 		aim_countdown -= delta
 		if aim_countdown < 0 and state == State.AIM:
 			var ray_origin = ray_from.global_transform.origin
-			var ray_to = player.global_transform.origin + Vector3.UP # Above middle of player.
+			var ray_to = target_position + Vector3.UP
 			var col = get_world_3d().direct_space_state.intersect_ray(PhysicsRayQueryParameters3D.create(ray_origin, ray_to, 0xFFFFFFFF, [self]))
 			if not col.is_empty() and col.collider == player:
 				state = State.SHOOTING
-				shoot_animation.play("shoot")
 				shoot_countdown = SHOOT_WAIT
+				play_shoot.rpc()
 			else:
 				resume_approach()
 
-		if animation_tree.active:
-			var to_cannon_local = player.global_transform.origin + Vector3.UP * ray_mesh.global_transform
-			var h_angle = rad_to_deg(atan2( to_cannon_local.x, -to_cannon_local.z ))
-			var v_angle = rad_to_deg(atan2( to_cannon_local.y, -to_cannon_local.z ))
-			var blend_pos = animation_tree.get("parameters/aim/blend_position")
-			var h_motion = BLEND_AIM_SPEED * delta * -h_angle
-			blend_pos.x += h_motion
-			blend_pos.x = clamp(blend_pos.x, -1, 1)
-
-			var v_motion = BLEND_AIM_SPEED * delta * v_angle
-			blend_pos.y += v_motion
-			blend_pos.y = clamp(blend_pos.y, -1, 1)
-
-			animation_tree["parameters/aim/blend_position"] = blend_pos
-
+	animate(delta)
 	# Apply root motion to orientation.
 	orientation *= Transform3D(animation_tree.get_root_motion_rotation(), animation_tree.get_root_motion_position())
 
@@ -240,13 +253,19 @@ func _physics_process(delta):
 	global_transform.basis = orientation.basis
 
 
+@rpc("call_local")
+func play_shoot():
+	shoot_animation.play("shoot")
+
+
 func shoot_check():
 	test_shoot = true
 
 
 func _clip_ray(length):
 	var mesh_offset = ray_mesh.position.z
-	ray_mesh.get_surface_override_material(0).set_shader_parameter("clip", length + mesh_offset)
+	if not OS.has_feature("dedicated_server"):
+		ray_mesh.get_surface_override_material(0).set_shader_parameter("clip", length + mesh_offset)
 
 
 func _on_area_body_entered(body):

+ 82 - 31
enemies/red_robot/red_robot.tscn

@@ -1,4 +1,4 @@
-[gd_scene load_steps=74 format=3 uid="uid://byi6b08jpb2iw"]
+[gd_scene load_steps=76 format=3 uid="uid://byi6b08jpb2iw"]
 
 [ext_resource type="Script" path="res://enemies/red_robot/red_robot.gd" id="1"]
 [ext_resource type="PackedScene" uid="uid://cmy0u0gubi0bw" path="res://enemies/red_robot/model/red_robot.glb" id="2_g6060"]
@@ -24,6 +24,23 @@
 [ext_resource type="ArrayMesh" uid="uid://cna0fa1wdq56d" path="res://enemies/red_robot/parts/sparks_effect/SparkParticle_Sphere.mesh" id="22"]
 [ext_resource type="Script" path="res://enemies/red_robot/parts/part.gd" id="24"]
 
+[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_h6xi0"]
+properties/0/path = NodePath(".:global_transform")
+properties/0/spawn = true
+properties/0/sync = true
+properties/1/path = NodePath(".:health")
+properties/1/spawn = true
+properties/1/sync = false
+properties/2/path = NodePath(".:state")
+properties/2/spawn = true
+properties/2/sync = true
+properties/3/path = NodePath(".:target_position")
+properties/3/spawn = true
+properties/3/sync = true
+properties/4/path = NodePath(".:dead")
+properties/4/spawn = true
+properties/4/sync = false
+
 [sub_resource type="ShaderMaterial" id="ShaderMaterial_p5m1n"]
 resource_local_to_scene = true
 render_priority = 0
@@ -406,6 +423,23 @@ _data = {
 
 [sub_resource type="PhysicsMaterial" id="56"]
 
+[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_hqtbc"]
+properties/0/path = NodePath(".:fade_value")
+properties/0/spawn = true
+properties/0/sync = true
+properties/1/path = NodePath(".:position")
+properties/1/spawn = true
+properties/1/sync = true
+properties/2/path = NodePath(".:rotation")
+properties/2/spawn = true
+properties/2/sync = true
+properties/3/path = NodePath(".:linear_velocity")
+properties/3/spawn = true
+properties/3/sync = true
+properties/4/path = NodePath(".:angular_velocity")
+properties/4/spawn = true
+properties/4/sync = true
+
 [sub_resource type="Curve" id="57"]
 _data = [Vector2(0, 0), 0.0, 0.0, 0, 0, Vector2(0.260504, 1), 0.0, 0.0, 0, 0]
 point_count = 2
@@ -563,58 +597,63 @@ collision_layer = 3
 collision_mask = 3
 script = ExtResource("1")
 
+[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="."]
+replication_config = SubResource("SceneReplicationConfig_h6xi0")
+
 [node name="RedRobotModel" parent="." instance=ExtResource("2_g6060")]
 
 [node name="Skeleton3D" parent="RedRobotModel/Armature" index="0"]
-bones/2/position = Vector3(0, 1.20114, -0.00472171)
-bones/2/rotation = Quaternion(-0.00153308, 0.031529, 0.0319448, 0.998991)
+bones/2/position = Vector3(0, 1.25468, -0.00440677)
+bones/2/rotation = Quaternion(1.24077e-05, 0.0162802, -0.0078425, 0.999837)
 bones/3/rotation = Quaternion(-0.499911, -0.499911, -0.500089, 0.500089)
-bones/4/rotation = Quaternion(-0.469672, 0.468593, -0.534448, 0.523635)
-bones/5/rotation = Quaternion(-0.00748047, 0.942303, 0.334674, 0.00133683)
-bones/7/rotation = Quaternion(-0.0944425, 0.000782496, 0.0158717, 0.995404)
-bones/8/rotation = Quaternion(-0.0841591, 0.000742766, 0.0150659, 0.996338)
+bones/4/rotation = Quaternion(-0.492198, 0.494069, -0.513766, 0.499682)
+bones/5/rotation = Quaternion(-0.0164489, 0.868829, 0.494835, 0.00200311)
+bones/7/rotation = Quaternion(-0.0691275, 3.34042e-05, 0.000678603, 0.997608)
+bones/8/rotation = Quaternion(-0.0662443, 2.82347e-05, 0.000573758, 0.997803)
 bones/9/rotation = Quaternion(-0.499911, 0.499911, 0.500089, 0.500089)
-bones/10/rotation = Quaternion(-0.252246, -0.213129, 0.643569, 0.690483)
-bones/11/rotation = Quaternion(-0.00158411, 0.869403, 0.00101085, -0.4941)
+bones/10/rotation = Quaternion(-0.272307, -0.235959, 0.642872, 0.675934)
+bones/11/rotation = Quaternion(-0.00159346, 0.876908, 0.00106308, -0.480655)
 bones/12/rotation = Quaternion(-1.85752e-07, -0.461677, 0.887048, -3.48556e-08)
-bones/13/rotation = Quaternion(-0.051257, 0.00060833, 0.0123369, 0.998609)
-bones/14/rotation = Quaternion(-0.0429339, 0.000646278, 0.0131067, 0.998992)
+bones/13/rotation = Quaternion(-0.0480126, 0.000109169, 0.00221309, 0.998844)
+bones/14/rotation = Quaternion(-0.0450677, 0.000110714, 0.00224438, 0.998981)
 bones/15/rotation = Quaternion(0.00118244, 0.707106, 0.707106, -0.00118238)
 bones/16/rotation = Quaternion(0.0487809, 4.36594e-08, -2.13228e-09, 0.99881)
-bones/18/rotation = Quaternion(-0.701524, -0.0886801, -0.0886801, 0.701524)
-bones/19/rotation = Quaternion(-0.370928, 0.0822236, -0.0403818, 0.924133)
-bones/20/rotation = Quaternion(0.284293, -0.138594, 0.0321782, 0.948121)
-bones/21/rotation = Quaternion(-0.00390792, -0.916789, 0.398597, -0.0245612)
-bones/22/rotation = Quaternion(0.443683, -0.108479, 0.0542043, 0.887941)
-bones/23/rotation = Quaternion(-0.305504, -0.066367, 0.296348, 0.902463)
-bones/24/rotation = Quaternion(0.249697, 0.16183, -0.0093886, 0.954659)
-bones/25/rotation = Quaternion(0.0260091, -0.788057, 0.613474, 0.0440335)
-bones/26/rotation = Quaternion(0.343803, 0.112506, -0.0248632, 0.931946)
-bones/29/rotation = Quaternion(0.707022, -0.0109696, 0.010969, 0.707022)
+bones/18/rotation = Quaternion(-0.701504, -0.088841, -0.088841, 0.701504)
+bones/19/rotation = Quaternion(-0.363391, 0.0779801, -0.0114141, 0.928297)
+bones/20/rotation = Quaternion(0.286004, -0.142677, 0.0312442, 0.947032)
+bones/21/rotation = Quaternion(-0.00485748, -0.916208, 0.399631, -0.0288885)
+bones/22/rotation = Quaternion(0.490723, -0.105412, 0.0599511, 0.862835)
+bones/23/rotation = Quaternion(-0.442592, -0.0834679, 0.349508, 0.821577)
+bones/24/rotation = Quaternion(0.270642, 0.140078, -0.0292164, 0.951986)
+bones/25/rotation = Quaternion(0.00458947, -0.915512, 0.401426, 0.0259662)
+bones/26/rotation = Quaternion(0.404702, 0.108449, -0.0182273, 0.907812)
+bones/29/rotation = Quaternion(0.707021, -0.011023, 0.0110224, 0.707021)
+bones/30/scale = Vector3(0.999697, 0.246276, 0.998156)
+bones/32/scale = Vector3(0.999662, 0.246276, 0.998192)
 bones/36/position = Vector3(1.25983, 1.30326, 0.185817)
 bones/37/rotation = Quaternion(5.33851e-08, -0.707107, 0.707107, 5.33851e-08)
-bones/39/rotation = Quaternion(0.998553, -0.0402177, 0.0103661, -0.0341592)
-bones/41/rotation = Quaternion(0.998887, -0.0396054, 0.0102083, -0.0234937)
+bones/39/rotation = Quaternion(0.998355, 0.0142137, -0.00366348, -0.0554225)
+bones/41/rotation = Quaternion(0.99851, 0.0142679, -0.00367745, -0.0525361)
 bones/42/rotation = Quaternion(-0.567204, -1.60356e-07, 1.10439e-07, 0.823577)
 bones/43/rotation = Quaternion(0.5665, 0.402705, 0.616376, 0.370118)
-bones/44/rotation = Quaternion(0.61946, 0.267158, 0.667495, 0.315193)
-bones/45/position = Vector3(-5.02977e-05, -0.361298, 0.0299357)
-bones/45/rotation = Quaternion(0.52941, 0.0640843, 0.839999, -0.100104)
+bones/44/rotation = Quaternion(0.61812, 0.32801, 0.628922, 0.338813)
+bones/45/position = Vector3(-6.36613e-05, -0.361298, 0.0298612)
+bones/45/rotation = Quaternion(0.526147, 0.0711666, 0.838967, -0.119331)
 bones/47/rotation = Quaternion(-0.599593, 0.374818, 0.599592, 0.374819)
 bones/48/position = Vector3(-1.25983, 1.30326, 1.13517)
 bones/49/rotation = Quaternion(-5.33851e-08, -0.707107, 0.707107, -5.33851e-08)
-bones/51/rotation = Quaternion(0.920414, -0.0382196, 0.00985076, 0.388947)
-bones/53/rotation = Quaternion(0.91713, -0.0382163, 0.00984991, 0.396631)
+bones/51/rotation = Quaternion(0.932596, 0.00199249, -0.000513713, 0.360916)
+bones/53/rotation = Quaternion(0.931511, 0.00200684, -0.000517411, 0.363706)
 bones/54/rotation = Quaternion(-0.567204, 1.60356e-07, -1.10439e-07, 0.823577)
 bones/55/rotation = Quaternion(-0.630266, 0.268009, 0.685756, -0.246322)
-bones/56/rotation = Quaternion(0.614619, -0.369837, -0.599625, 0.354843)
-bones/57/rotation = Quaternion(-0.662232, 0.0768283, 0.743989, -0.0450163)
+bones/56/rotation = Quaternion(0.6201, -0.327479, -0.628711, 0.336089)
+bones/57/rotation = Quaternion(-0.665172, 0.0854992, 0.741064, -0.0325496)
 bones/59/rotation = Quaternion(-0.396466, -0.585504, -0.396466, 0.585504)
 bones/60/position = Vector3(0.937933, 1.23785, -1.52284)
 bones/61/position = Vector3(-1.05149, 1.13607, -1.48048)
 
 [node name="RayFrom" type="BoneAttachment3D" parent="RedRobotModel/Armature/Skeleton3D" index="4"]
-transform = Transform3D(-0.995755, 0.0596829, -0.0700711, -0.0637111, 0.102512, 0.992689, 0.0664297, 0.99294, -0.098274, -0.110703, 2.02572, 0.183234)
+transform = Transform3D(-0.999233, 0.0372542, 0.0121102, 0.015681, 0.0971027, 0.995151, 0.0358976, 0.994577, -0.0976124, 0.0378045, 2.08186, 0.18545)
 bone_name = "CannonAnimRecoil"
 bone_idx = 17
 
@@ -785,6 +824,10 @@ freeze = true
 angular_damp = 0.3
 script = ExtResource("24")
 
+[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="Death/PartShield1"]
+replication_config = SubResource("SceneReplicationConfig_hqtbc")
+public_visibility = false
+
 [node name="StaticParticle" type="CPUParticles3D" parent="Death/PartShield1"]
 transform = Transform3D(1, 0, 0, 0, 0.965835, 0.259156, 0, -0.259156, 0.965835, 0, 0, 0)
 material_override = ExtResource("8")
@@ -832,6 +875,10 @@ physics_material_override = SubResource("61")
 freeze = true
 script = ExtResource("24")
 
+[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="Death/PartShield2"]
+replication_config = SubResource("SceneReplicationConfig_hqtbc")
+public_visibility = false
+
 [node name="StaticParticle2" type="CPUParticles3D" parent="Death/PartShield2"]
 transform = Transform3D(1, 0, 0, 0, 0.965835, 0.259156, 0, -0.259156, 0.965835, 0, 0, 0)
 material_override = ExtResource("8")
@@ -880,6 +927,10 @@ freeze = true
 angular_damp = 0.3
 script = ExtResource("24")
 
+[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="Death/PartHead"]
+replication_config = SubResource("SceneReplicationConfig_hqtbc")
+public_visibility = false
+
 [node name="StaticParticle3" type="CPUParticles3D" parent="Death/PartHead"]
 transform = Transform3D(1, 0, 0, 0, 0.965835, 0.259156, 0, -0.259156, 0.965835, 0, 0, 0)
 material_override = ExtResource("8")

+ 2 - 0
level/debug.gd

@@ -7,3 +7,5 @@ func _process(_delta):
 	text = "FPS: " + str(Engine.get_frames_per_second())
 	text += "\nVSync: " + ("checked" if ProjectSettings.get_setting("display/window/vsync/vsync_mode") else "unchecked")
 	text += "\nMemory: " + "%3.2f" % (OS.get_static_memory_usage() / 1048576.0) + " MiB"
+	text += "\nOnline: " + ("false" if multiplayer.multiplayer_peer is OfflineMultiplayerPeer else "true")
+	text += "\nMultiplayer ID: " + str(multiplayer.get_unique_id())

+ 49 - 0
level/level.gd

@@ -1,11 +1,16 @@
 extends Node3D
 
+const RedRobot = preload("res://enemies/red_robot/red_robot.tscn")
+const PlayerScene = preload("res://player/player.tscn")
 
 signal quit
 #warning-ignore:unused_signal
 signal replace_main_scene # Useless, but needed as there is no clean way to check if a node exposes a signal
 
 @onready var world_environment = $WorldEnvironment
+@onready var robot_spawn_points = $RobotSpawnpoints
+@onready var player_spawn_points = $PlayerSpawnpoints
+@onready var spawn_node = $SpawnedNodes
 
 func _ready():
 	if Settings.gi_quality == Settings.GIQuality.HIGH:
@@ -68,6 +73,50 @@ func _ready():
 		get_window().set_content_scale_aspect(Window.CONTENT_SCALE_ASPECT_EXPAND)
 		get_window().set_content_scale_size(minsize)
 
+	if multiplayer.is_server():
+		# Server will spawn the red robots
+		for c in robot_spawn_points.get_children():
+			spawn_robot(c)
+
+		# Then spawn already connected players at random location
+		randomize()
+		var spawn_points = player_spawn_points.get_children()
+		spawn_points.shuffle()
+		add_player(1, spawn_points.pop_front())
+		for id in multiplayer.get_peers():
+			add_player(id, spawn_points.pop_front())
+
+		# Then spawn/despawn players as they connect/disconnect
+		multiplayer.peer_connected.connect(add_player)
+		multiplayer.peer_disconnected.connect(del_player)
+
+
+func spawn_robot(spawn_point):
+	var robot = RedRobot.instantiate()
+	robot.transform = spawn_point.transform
+	robot.exploded.connect(_respawn_robot.bind(spawn_point))
+	spawn_node.add_child(robot, true)
+
+
+func _respawn_robot(spawn_point):
+	await get_tree().create_timer(15.0).timeout
+	spawn_robot(spawn_point)
+
+
+func del_player(id: int):
+	if not spawn_node.has_node(str(id)):
+		return
+	spawn_node.get_node(str(id)).queue_free()
+
+
+func add_player(id: int, spawn_point: Marker3D = null):
+	if spawn_point == null:
+		spawn_point = player_spawn_points.get_child(randi() % player_spawn_points.get_child_count())
+	var player = PlayerScene.instantiate()
+	player.name = str(id)
+	player.player_id = id
+	player.transform = spawn_point.transform
+	spawn_node.add_child(player)
 
 
 func _input(event):

+ 39 - 18
level/level.tscn

@@ -1,10 +1,8 @@
-[gd_scene load_steps=16 format=3 uid="uid://b51qillnxp84r"]
+[gd_scene load_steps=14 format=3 uid="uid://b51qillnxp84r"]
 
 [ext_resource type="Script" path="res://level/level.gd" id="1"]
 [ext_resource type="PackedScene" uid="uid://bpihm2o3g658" path="res://level/geometry/scenes/props.tscn" id="2"]
 [ext_resource type="VoxelGIData" uid="uid://bw86lhn5p1ovp" path="res://level/geometry/giprobe_data.res" id="5"]
-[ext_resource type="PackedScene" uid="uid://cs1k22tdf04k4" path="res://player/player.tscn" id="6"]
-[ext_resource type="PackedScene" uid="uid://byi6b08jpb2iw" path="res://enemies/red_robot/red_robot.tscn" id="7"]
 [ext_resource type="AudioStream" uid="uid://vxxm8xm1fr6y" path="res://level/level_music.ogg" id="8"]
 [ext_resource type="Script" path="res://level/debug.gd" id="9"]
 [ext_resource type="PackedScene" uid="uid://dln4kthc3tvfv" path="res://level/forklift/flying_forklift.tscn" id="12"]
@@ -41,6 +39,40 @@ _data = {
 [node name="Level" type="Node3D"]
 script = ExtResource("1")
 
+[node name="SpawnedNodes" type="Node3D" parent="."]
+
+[node name="RobotSpawnpoints" type="Node3D" parent="."]
+
+[node name="Marker3D1" type="Marker3D" parent="RobotSpawnpoints"]
+transform = Transform3D(0.843905, 0, -0.536493, 0, 1, 0, 0.536493, 0, 0.843905, 71.5907, -6.05686, 46.2736)
+
+[node name="Marker3D2" type="Marker3D" parent="RobotSpawnpoints"]
+transform = Transform3D(0.338334, 0, 0.941027, 0, 1, 0, -0.941027, 0, 0.338334, 53.2126, -6.05686, 15.9321)
+
+[node name="Marker3D3" type="Marker3D" parent="RobotSpawnpoints"]
+transform = Transform3D(-0.164432, 0, 0.986389, 0, 1, 0, -0.986389, 0, -0.164432, -2.96096, -11.6923, 20.2343)
+
+[node name="Marker3D4" type="Marker3D" parent="RobotSpawnpoints"]
+transform = Transform3D(-0.164432, 0, 0.986389, 0, 1, 0, -0.986389, 0, -0.164432, -9.15526, -11.6923, -16.9238)
+
+[node name="PlayerSpawnpoints" type="Node3D" parent="."]
+
+[node name="Marker3D1" type="Marker3D" parent="PlayerSpawnpoints"]
+transform = Transform3D(-0.575826, 0, -0.817573, 0, 1, 0, 0.817573, 0, -0.575826, 64.8183, -1.0765, 78.7639)
+
+[node name="Marker3D2" type="Marker3D" parent="PlayerSpawnpoints"]
+transform = Transform3D(-0.575826, 0, -0.817573, 0, 1, 0, 0.817573, 0, -0.575826, 64.818, -1.077, 80.764)
+
+[node name="Marker3D3" type="Marker3D" parent="PlayerSpawnpoints"]
+transform = Transform3D(-0.575826, 0, -0.817573, 0, 1, 0, 0.817573, 0, -0.575826, 64.818, -1.077, 76.764)
+
+[node name="Marker3D4" type="Marker3D" parent="PlayerSpawnpoints"]
+transform = Transform3D(-0.575826, 0, -0.817573, 0, 1, 0, 0.817573, 0, -0.575826, 64.818, -1.077, 74.764)
+
+[node name="MultiplayerSpawner" type="MultiplayerSpawner" parent="."]
+_spawnable_scenes = PackedStringArray("res://player/player.tscn", "res://enemies/red_robot/red_robot.tscn", "res://player/bullet/bullet.tscn")
+spawn_path = NodePath("../SpawnedNodes")
+
 [node name="Core" parent="." instance=ExtResource("14")]
 
 [node name="Structure" parent="." instance=ExtResource("15")]
@@ -76,21 +108,6 @@ box_projection = true
 transform = Transform3D(0.999799, 0, -0.0200534, 0, 1, 0, 0.0200534, 0, 0.999799, -0.204299, -8.98325, 0.0613261)
 size = Vector3(77.8268, 100, 74.2464)
 
-[node name="Player" parent="." instance=ExtResource("6")]
-transform = Transform3D(-0.575826, 0, -0.817573, 0, 1, 0, 0.817573, 0, -0.575826, 64.8183, -1.0765, 78.7639)
-
-[node name="RedRobot1" parent="." instance=ExtResource("7")]
-transform = Transform3D(0.843905, 0, -0.536493, 0, 1, 0, 0.536493, 0, 0.843905, 71.5907, -6.05686, 46.2736)
-
-[node name="RedRobot2" parent="." instance=ExtResource("7")]
-transform = Transform3D(0.338334, 0, 0.941027, 0, 1, 0, -0.941027, 0, 0.338334, 53.2126, -6.05686, 15.9321)
-
-[node name="RedRobot3" parent="." instance=ExtResource("7")]
-transform = Transform3D(-0.164432, 0, 0.986389, 0, 1, 0, -0.986389, 0, -0.164432, -2.96096, -11.6923, 20.2343)
-
-[node name="RedRobot4" parent="." instance=ExtResource("7")]
-transform = Transform3D(-0.164432, 0, 0.986389, 0, 1, 0, -0.986389, 0, -0.164432, -9.15526, -11.6923, -16.9238)
-
 [node name="Music" type="AudioStreamPlayer" parent="."]
 stream = ExtResource("8")
 autoplay = true
@@ -137,3 +154,7 @@ text = "FPS: 0
 VSync:
 Memory:"
 script = ExtResource("9")
+
+[node name="Camera3D" type="Camera3D" parent="."]
+transform = Transform3D(0.671837, 0, -0.740699, 0, 1, 0, 0.740699, 0, 0.671837, -4.05309, -1.12911, 4)
+current = true

+ 6 - 0
main/main.gd

@@ -1,12 +1,18 @@
 extends Node
 
 func _ready():
+	multiplayer.server_relay = false
+	if DisplayServer.get_name() == "headless":
+		Engine.max_fps = 60
+	randomize()
 	get_window().mode = Window.MODE_EXCLUSIVE_FULLSCREEN if (Settings.fullscreen) else Window.MODE_WINDOWED
 	go_to_main_menu()
 
 
 func go_to_main_menu():
 	var menu = ResourceLoader.load("res://menu/menu.tscn")
+	multiplayer.multiplayer_peer.close()
+	multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
 	change_scene_to_file(menu)
 
 

+ 38 - 0
menu/menu.gd

@@ -6,8 +6,11 @@ signal replace_main_scene
 #warning-ignore:unused_signal
 signal quit # Useless, but needed as there is no clean way to check if a node exposes a signal
 
+var peer : MultiplayerPeer = OfflineMultiplayerPeer.new()
+
 @onready var ui = $UI
 @onready var main = ui.get_node("Main")
+@onready var online = ui.get_node("Online")
 @onready var play_button = main.get_node("Play")
 @onready var settings_button = main.get_node("Settings")
 @onready var quit_button = main.get_node("Quit")
@@ -61,10 +64,16 @@ signal quit # Useless, but needed as there is no clean way to check if a node ex
 @onready var loading_done_timer = loading.get_node("DoneTimer")
 
 func _ready():
+	if DisplayServer.get_name() == "headless":
+		_on_host_pressed.call_deferred()
+
 	play_button.grab_focus()
 	var sound_effects = $BackgroundCache/RedRobot/SoundEffects
 	for child in sound_effects.get_children():
 		child.volume_db = -200
+	for menu in [gi_menu, aa_menu, fxaa_menu, ssao_menu, shadow_menu,
+			bloom_menu, resolution_menu, fullscreen_menu]:
+		_make_button_group(menu)
 
 func _process(_delta):
 	if loading.visible:
@@ -81,13 +90,22 @@ func _process(_delta):
 			main.show()
 			loading.hide()
 
+func _make_button_group(common_parent: Node):
+	var group = ButtonGroup.new()
+	for btn in common_parent.get_children():
+		if not btn is BaseButton:
+			continue
+		btn.button_group = group
+
 func _on_loading_done_timer_timeout():
+	multiplayer.multiplayer_peer = peer
 	emit_signal("replace_main_scene", ResourceLoader.load_threaded_get(path))
 
 func _on_play_pressed():
 	main.hide()
 	loading.show()
 	if ResourceLoader.has_cached(path):
+		multiplayer.multiplayer_peer = peer
 		emit_signal("replace_main_scene", ResourceLoader.load_threaded_get(path))
 	else:
 		ResourceLoader.load_threaded_request(path, "", true)
@@ -215,3 +233,23 @@ func _on_cancel_pressed():
 	main.show()
 	play_button.grab_focus()
 	settings_menu.hide()
+	online.hide()
+
+
+func _on_play_online_pressed():
+	online.show()
+	main.hide()
+
+
+func _on_host_pressed():
+	peer = ENetMultiplayerPeer.new()
+	peer.create_server(int($UI/Online/Port.value))
+	_on_play_pressed()
+	online.hide()
+
+
+func _on_connect_pressed():
+	peer = ENetMultiplayerPeer.new()
+	peer.create_client($UI/Online/Address.text, int($UI/Online/Port.value))
+	_on_play_pressed()
+	online.hide()

+ 140 - 65
menu/menu.tscn

@@ -270,25 +270,25 @@ environment = SubResource("2")
 transform = Transform3D(0.803991, 0, 0, 0, 0.803991, 0, 0, 0, 0.803991, 0, 0, 0)
 
 [node name="Skeleton3D" parent="PlayerModel/Robot_Skeleton" index="0"]
-bones/1/position = Vector3(0.268173, 2.09624, -0.316997)
-bones/1/rotation = Quaternion(0.112115, -0.00164791, -0.00604639, 0.993675)
-bones/2/position = Vector3(1.99957e-05, 0.243477, -0.000598437)
-bones/2/rotation = Quaternion(0.0224407, 0.0557744, -0.00913325, 0.998149)
-bones/3/position = Vector3(1.99616e-05, 0.106811, 0.000379546)
-bones/3/rotation = Quaternion(0.0137962, 0.0564812, 0.00201712, 0.998306)
-bones/4/position = Vector3(1.99659e-05, 0.121394, 0.00142952)
-bones/4/rotation = Quaternion(0.0768452, 0.0556128, 0.0100706, 0.99544)
+bones/1/position = Vector3(0.268173, 2.07149, -0.316997)
+bones/1/rotation = Quaternion(0.15086, -0.00141038, -0.00610617, 0.988535)
+bones/2/position = Vector3(9.32932e-06, 0.240736, -0.000315062)
+bones/2/rotation = Quaternion(0.00854929, 0.000726839, -0.00205447, 0.999961)
+bones/3/position = Vector3(9.31144e-06, 0.104066, 0.000141873)
+bones/3/rotation = Quaternion(-0.000110361, 0.00111678, -0.00187134, 0.999998)
+bones/4/position = Vector3(9.31593e-06, 0.118756, 0.000633525)
+bones/4/rotation = Quaternion(0.0630733, 0.00137283, -0.00169248, 0.998007)
 bones/5/rotation = Quaternion(0.180715, -8.52238e-05, -0.00187649, 0.983534)
-bones/6/rotation = Quaternion(0.109393, 0.916476, 0.384813, 0.00492179)
-bones/10/position = Vector3(-2.09419e-08, 0.245584, 0.126631)
-bones/11/rotation = Quaternion(4.16747e-07, 0.930597, 0.366045, -5.69876e-07)
-bones/12/rotation = Quaternion(4.16724e-07, 0.930597, 0.366045, -5.69556e-07)
-bones/13/rotation = Quaternion(0.0442982, 0.958142, -0.282457, 0.0147928)
-bones/14/rotation = Quaternion(2.09514e-07, 0.959165, -0.282846, -7.20715e-07)
-bones/15/rotation = Quaternion(-0.336513, -0.354554, -0.465867, 0.737576)
-bones/16/rotation = Quaternion(-0.694616, -0.0213221, -0.0375774, 0.718082)
-bones/17/rotation = Quaternion(0.0407905, 0.0023125, -0.346023, 0.937336)
-bones/18/rotation = Quaternion(0.0350425, -0.00183072, -0.333551, 0.942079)
+bones/6/rotation = Quaternion(-0.0217848, 0.923485, 0.382988, -0.00448905)
+bones/10/position = Vector3(-1.60723e-08, 0.245584, 0.126631)
+bones/11/rotation = Quaternion(4.17153e-07, 0.930597, 0.366045, -5.68804e-07)
+bones/12/rotation = Quaternion(4.17199e-07, 0.930597, 0.366045, -5.69729e-07)
+bones/13/rotation = Quaternion(0.0442977, 0.958142, -0.282457, 0.014794)
+bones/14/rotation = Quaternion(2.10544e-07, 0.959165, -0.282846, -7.20507e-07)
+bones/15/rotation = Quaternion(-0.338944, -0.353006, -0.469317, 0.735014)
+bones/16/rotation = Quaternion(-0.698876, -0.0149467, 0.201944, 0.685979)
+bones/17/rotation = Quaternion(-0.00445103, 0.00667346, -0.353641, 0.935347)
+bones/18/rotation = Quaternion(-0.00883807, -0.000936903, -0.316807, 0.948448)
 bones/20/rotation = Quaternion(-0.0338717, -0.0177171, 0.985158, 0.167341)
 bones/22/rotation = Quaternion(-0.153692, -0.0597802, 0.00318971, 0.986304)
 bones/23/rotation = Quaternion(-0.131041, -0.0202512, -0.000901781, 0.99117)
@@ -306,13 +306,13 @@ bones/40/rotation = Quaternion(-0.318888, -0.0177572, -0.0054272, 0.947611)
 bones/41/rotation = Quaternion(-0.450214, -0.118277, -0.0180463, 0.884869)
 bones/42/rotation = Quaternion(-0.447648, -0.0710501, -0.0232223, 0.89108)
 bones/45/rotation = Quaternion(0.189582, -0.137612, 0.960075, 0.152896)
-bones/47/rotation = Quaternion(-0.338619, 0.0145923, 0.0624174, 0.938737)
-bones/48/rotation = Quaternion(0.0919991, 0.0976502, 0.48054, 0.86665)
-bones/49/rotation = Quaternion(-0.204467, -0.0515048, -0.308255, 0.927642)
-bones/50/rotation = Quaternion(-0.35376, 0.371418, 0.462805, 0.72299)
-bones/51/rotation = Quaternion(-0.348653, 0.356107, 0.224401, 0.837421)
-bones/52/rotation = Quaternion(0.319716, 0.137526, 0.434502, 0.830708)
-bones/53/rotation = Quaternion(0.277844, 0.0549105, 0.340938, 0.896409)
+bones/47/rotation = Quaternion(-0.347178, 0.0118853, 0.26375, 0.899868)
+bones/48/rotation = Quaternion(0.127126, 0.0720878, 0.623277, 0.768224)
+bones/49/rotation = Quaternion(-0.284537, -0.0275523, -0.0810986, 0.954831)
+bones/50/rotation = Quaternion(-0.359243, 0.367696, 0.470204, 0.717393)
+bones/51/rotation = Quaternion(-0.384715, 0.431885, 0.458428, 0.67477)
+bones/52/rotation = Quaternion(0.237309, 0.0826185, 0.434046, 0.865137)
+bones/53/rotation = Quaternion(0.192097, 0.0307395, 0.333487, 0.922464)
 bones/55/rotation = Quaternion(-0.00907353, -0.999945, 0.00213467, 0.00491076)
 bones/56/rotation = Quaternion(0.0138917, -0.0168912, 0.990289, 0.137295)
 bones/56/scale = Vector3(0.766362, 0.766362, 0.766362)
@@ -358,46 +358,46 @@ bones/89/rotation = Quaternion(-0.000405133, 0.173646, 0.984808, -0.000801945)
 bones/90/position = Vector3(5.96046e-08, 0.185297, -0.0266095)
 bones/91/position = Vector3(2.98605e-08, -1.00086e-07, -0.202989)
 bones/92/position = Vector3(-5.96046e-08, 0.0660029, 0.0266979)
-bones/95/rotation = Quaternion(0.05799, 0.0219246, 0.189872, 0.97985)
-bones/96/rotation = Quaternion(0.174514, -0.158735, -0.21745, 0.947135)
-bones/97/rotation = Quaternion(0.0101153, 0.113078, 0.391096, 0.913321)
-bones/98/rotation = Quaternion(0.994392, -0.0944115, -0.00439916, -0.0474537)
-bones/99/rotation = Quaternion(0.646329, 0.235609, 0.593638, -0.417543)
-bones/100/rotation = Quaternion(-0.106422, -0.167151, 0.965412, 0.169454)
-bones/101/rotation = Quaternion(-0.222381, -0.211119, 0.930999, -0.198033)
-bones/102/rotation = Quaternion(0.328165, 0.691241, -0.597049, 0.240886)
-bones/104/rotation = Quaternion(-0.521977, 0.392636, 0.632836, 0.415807)
-bones/105/rotation = Quaternion(0.105477, -0.207515, 0.945947, -0.225825)
-bones/106/rotation = Quaternion(0.180097, -0.349346, 0.919203, -0.0242731)
-bones/107/rotation = Quaternion(-0.305892, 0.616796, -0.722653, -0.0613609)
-bones/112/rotation = Quaternion(0.999764, 0.00107883, -7.8484e-05, -0.0217104)
-bones/113/rotation = Quaternion(0.996348, -0.000928367, -0.000252604, -0.0853804)
-bones/114/position = Vector3(0.106977, 0.0982658, 0.149509)
-bones/114/rotation = Quaternion(0.0025418, 0.7006, 0.713545, 0.0024614)
-bones/115/position = Vector3(-0.0973112, 0.0983164, 0.149498)
-bones/115/rotation = Quaternion(0.00247134, 0.700582, 0.713564, 0.00242634)
-bones/116/rotation = Quaternion(0.869649, 0.214895, -0.112368, 0.430005)
-bones/117/rotation = Quaternion(0.268127, 1.68788e-07, -2.096e-08, 0.963384)
-bones/118/rotation = Quaternion(0.174225, 9.78156e-09, 2.88953e-09, 0.984706)
-bones/119/rotation = Quaternion(-0.532579, -0.0655485, 0.162004, 0.828141)
+bones/95/rotation = Quaternion(0.410091, 0.0465166, 0.520525, 0.747473)
+bones/96/rotation = Quaternion(0.255417, -0.195742, -0.0444677, 0.945764)
+bones/97/rotation = Quaternion(0.0716394, 0.124142, 0.535656, 0.832183)
+bones/98/rotation = Quaternion(0.995134, -0.08626, -0.00401302, -0.0474507)
+bones/99/rotation = Quaternion(0.706519, 0.147268, 0.492353, -0.486551)
+bones/100/rotation = Quaternion(-0.085158, -0.0870554, 0.961195, 0.247536)
+bones/101/rotation = Quaternion(-0.213099, -0.13988, 0.958105, -0.130607)
+bones/102/rotation = Quaternion(0.344475, 0.679952, -0.560254, 0.324219)
+bones/104/rotation = Quaternion(-0.442178, 0.446612, 0.716639, 0.302399)
+bones/105/rotation = Quaternion(0.086983, -0.134538, 0.943633, -0.28964)
+bones/106/rotation = Quaternion(0.0727779, -0.18822, 0.847484, -0.490967)
+bones/107/rotation = Quaternion(-0.276619, 0.533935, -0.794661, 0.0831215)
+bones/112/rotation = Quaternion(0.999613, 0.00106541, -7.40639e-05, -0.0278178)
+bones/113/rotation = Quaternion(0.996826, -0.000918132, -0.000247523, -0.0796083)
+bones/114/position = Vector3(0.106157, 0.100617, 0.143458)
+bones/114/rotation = Quaternion(0.00250625, 0.71076, 0.703426, 0.00249759)
+bones/115/position = Vector3(-0.0981304, 0.100667, 0.143446)
+bones/115/rotation = Quaternion(0.0024363, 0.710742, 0.703445, 0.00246153)
+bones/116/rotation = Quaternion(0.826144, 0.223353, -0.124416, 0.502116)
+bones/117/rotation = Quaternion(0.303606, 2.27544e-07, -5.04864e-08, 0.952798)
+bones/118/rotation = Quaternion(0.213488, 5.05391e-09, 1.60061e-09, 0.976946)
+bones/119/rotation = Quaternion(-0.555681, -0.0471448, 0.173221, 0.811782)
 bones/121/rotation = Quaternion(-0.384436, 5.74738e-10, -6.29245e-09, 0.923152)
-bones/122/rotation = Quaternion(0.981098, -4.60151e-07, 3.14232e-08, 0.193512)
-bones/124/rotation = Quaternion(0.934119, -0.18445, 0.110726, 0.28485)
-bones/125/rotation = Quaternion(0.325729, 1.42842e-07, -4.62522e-08, 0.945463)
-bones/126/rotation = Quaternion(0.204298, -2.80244e-09, 9.61849e-09, 0.978909)
-bones/127/rotation = Quaternion(-0.683833, -0.00722431, -0.18225, 0.706473)
+bones/122/rotation = Quaternion(0.975651, -5.71204e-07, 2.08716e-08, 0.219331)
+bones/124/rotation = Quaternion(0.91054, -0.192215, 0.115219, 0.347412)
+bones/125/rotation = Quaternion(0.355929, 2.18383e-07, -7.24127e-08, 0.934513)
+bones/126/rotation = Quaternion(0.23481, 2.48983e-09, 6.47078e-09, 0.972041)
+bones/127/rotation = Quaternion(-0.705478, -0.0159709, -0.190647, 0.682422)
 bones/128/rotation = Quaternion(6.14475e-09, 0.940004, -0.341165, -4.50954e-08)
 bones/129/rotation = Quaternion(-0.384436, -5.74738e-10, 4.27475e-09, 0.923152)
-bones/130/rotation = Quaternion(0.972073, -5.17376e-07, 3.08143e-08, 0.234681)
+bones/130/rotation = Quaternion(0.966531, -6.5885e-07, 3.37661e-08, 0.256549)
 bones/134/position = Vector3(0.735576, 1.3891, -0.136944)
-bones/136/position = Vector3(3.75821, 2.79657, 6.08647)
-bones/137/position = Vector3(0.782667, 2.29394, -0.38149)
+bones/136/position = Vector3(0.0801487, 2.72839, 7.7703)
+bones/137/position = Vector3(0.784511, 2.26249, -0.413285)
 bones/137/rotation = Quaternion(-0.707029, 0.0104927, -0.0104927, 0.707029)
-bones/139/position = Vector3(-0.160432, 2.92309, 0.289828)
+bones/139/position = Vector3(-0.165569, 2.85125, 0.311542)
 bones/139/rotation = Quaternion(-0.707029, 0.0104927, -0.0104927, 0.707029)
-bones/140/position = Vector3(-0.201236, 1.9972, -0.0890323)
-bones/143/position = Vector3(0.0110175, 3.50022, -1.24379)
-bones/143/scale = Vector3(1, 1.06456, 1)
+bones/140/position = Vector3(-0.120919, 1.9972, -0.0890323)
+bones/143/position = Vector3(0.0110175, 3.46286, -1.24379)
+bones/143/scale = Vector3(1, 1.03099, 1)
 
 [node name="00Robot_Body008" parent="PlayerModel/Robot_Skeleton/Skeleton3D" index="0"]
 surface_material_override/0 = ExtResource("3_xm1fn")
@@ -478,12 +478,22 @@ texture_normal = ExtResource("7")
 texture_pressed = ExtResource("7")
 texture_hover = ExtResource("8")
 
-[node name="Settings" type="TextureButton" parent="UI/Main"]
+[node name="PlayOnline" type="Button" parent="UI/Main"]
 layout_mode = 0
 offset_left = 100.0
 offset_top = 490.0
 offset_right = 400.0
 offset_bottom = 540.0
+theme_override_font_sizes/font_size = 30
+text = "Play Online"
+alignment = 0
+
+[node name="Settings" type="TextureButton" parent="UI/Main"]
+layout_mode = 0
+offset_left = 100.0
+offset_top = 590.0
+offset_right = 400.0
+offset_bottom = 640.0
 texture_normal = ExtResource("9")
 texture_pressed = ExtResource("9")
 texture_hover = ExtResource("10")
@@ -491,13 +501,74 @@ texture_hover = ExtResource("10")
 [node name="Quit" type="TextureButton" parent="UI/Main"]
 layout_mode = 0
 offset_left = 100.0
-offset_top = 590.0
+offset_top = 690.0
 offset_right = 400.0
-offset_bottom = 640.0
+offset_bottom = 740.0
 texture_normal = ExtResource("11")
 texture_pressed = ExtResource("11")
 texture_hover = ExtResource("12")
 
+[node name="Online" type="Control" parent="UI"]
+visible = false
+anchors_preset = 0
+anchor_left = -0.000673103
+anchor_top = -0.00189865
+anchor_right = 0.999327
+anchor_bottom = 0.998101
+offset_left = -4.10107
+offset_top = 2.05054
+offset_right = -4.10107
+offset_bottom = 2.05054
+
+[node name="Host" type="Button" parent="UI/Online"]
+layout_mode = 0
+offset_left = 100.0
+offset_top = 490.0
+offset_right = 400.0
+offset_bottom = 540.0
+theme_override_font_sizes/font_size = 30
+text = "Host"
+alignment = 0
+
+[node name="Port" type="SpinBox" parent="UI/Online"]
+layout_mode = 0
+offset_left = 500.0
+offset_top = 490.0
+offset_right = 800.0
+offset_bottom = 540.0
+min_value = 1025.0
+max_value = 49151.0
+value = 4383.0
+
+[node name="Connect" type="Button" parent="UI/Online"]
+layout_mode = 0
+offset_left = 100.0
+offset_top = 590.0
+offset_right = 400.0
+offset_bottom = 640.0
+theme_override_font_sizes/font_size = 30
+text = "Connect"
+alignment = 0
+
+[node name="Address" type="LineEdit" parent="UI/Online"]
+layout_mode = 0
+offset_left = 500.0
+offset_top = 590.0
+offset_right = 800.0
+offset_bottom = 640.0
+theme_override_font_sizes/font_size = 30
+text = "127.0.0.1"
+
+[node name="Back" type="Button" parent="UI/Online"]
+layout_mode = 0
+offset_left = 100.0
+offset_top = 690.0
+offset_right = 400.0
+offset_bottom = 740.0
+theme_override_font_sizes/font_size = 30
+text = "Back"
+alignment = 0
+
 [node name="Settings" type="VBoxContainer" parent="UI"]
 visible = false
 layout_mode = 0
@@ -759,8 +830,8 @@ one_shot = true
 transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -15, -10, -10)
 
 [node name="RedRobot" parent="BackgroundCache" instance=ExtResource("19")]
-health = 1
 test_shoot = true
+health = 1
 
 [node name="Floor" type="StaticBody3D" parent="BackgroundCache"]
 transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
@@ -781,12 +852,12 @@ shape = SubResource("21")
 [node name="Bullet" parent="BackgroundCache" instance=ExtResource("20")]
 transform = Transform3D(-1, 8.4334e-08, 3.14739e-07, 0, 0.965926, -0.258819, -3.25841e-07, -0.258819, -0.965926, 0, -20, -80)
 
-[node name="AnimationPlayer" parent="BackgroundCache/Bullet" index="3"]
+[node name="AnimationPlayer" parent="BackgroundCache/Bullet" index="4"]
 libraries = {
 "": SubResource("AnimationLibrary_ntwc6")
 }
 
-[node name="ExplosionAudio" parent="BackgroundCache/Bullet" index="4"]
+[node name="ExplosionAudio" parent="BackgroundCache/Bullet" index="5"]
 volume_db = -80.0
 
 [node name="MainBody" parent="BackgroundCache/Bullet/BulletBody" index="0"]
@@ -796,8 +867,12 @@ emitting = false
 emitting = false
 
 [connection signal="pressed" from="UI/Main/Play" to="." method="_on_play_pressed"]
+[connection signal="pressed" from="UI/Main/PlayOnline" to="." method="_on_play_online_pressed"]
 [connection signal="pressed" from="UI/Main/Settings" to="." method="_on_settings_pressed"]
 [connection signal="pressed" from="UI/Main/Quit" to="." method="_on_quit_pressed"]
+[connection signal="pressed" from="UI/Online/Host" to="." method="_on_host_pressed"]
+[connection signal="pressed" from="UI/Online/Connect" to="." method="_on_connect_pressed"]
+[connection signal="pressed" from="UI/Online/Back" to="." method="_on_cancel_pressed"]
 [connection signal="pressed" from="UI/Settings/Actions/Apply" to="." method="_on_apply_pressed"]
 [connection signal="pressed" from="UI/Settings/Actions/Cancel" to="." method="_on_cancel_pressed"]
 [connection signal="timeout" from="UI/Loading/DoneTimer" to="." method="_on_loading_done_timer_timeout"]

+ 20 - 3
player/bullet/bullet.gd

@@ -8,18 +8,35 @@ var hit = false
 @onready var animation_player = $AnimationPlayer
 @onready var collision_shape = $CollisionShape3D
 
+func _ready():
+	if not multiplayer.is_server():
+		set_physics_process(false)
+		collision_shape.disabled = true
+
+
 func _physics_process(delta):
 	if hit:
 		return
 	time_alive -= delta
 	if time_alive < 0:
 		hit = true
-		animation_player.play("explode")
+		explode.rpc()
 	var col = move_and_collide(-delta * BULLET_VELOCITY * transform.basis.z)
 	if col:
 		var collider = col.get_collider()
 		if collider and collider.has_method("hit"):
-			collider.hit()
+			collider.hit.rpc()
 		collision_shape.disabled = true
-		animation_player.play("explode")
+		explode.rpc()
 		hit = true
+
+
+@rpc("call_local")
+func explode():
+	animation_player.play("explode")
+
+
+func destroy():
+	if not multiplayer.is_server():
+		return
+	queue_free()

+ 11 - 2
player/bullet/bullet.tscn

@@ -1,4 +1,4 @@
-[gd_scene load_steps=56 format=3 uid="uid://jphgr3qep5"]
+[gd_scene load_steps=57 format=3 uid="uid://jphgr3qep5"]
 
 [ext_resource type="Script" path="res://player/bullet/bullet.gd" id="1"]
 [ext_resource type="ArrayMesh" uid="uid://dqkkefcnt0erw" path="res://effects_shared/BlastMesh_Sphere.mesh" id="2"]
@@ -8,6 +8,11 @@
 [ext_resource type="Texture2D" uid="uid://d17qvns23fvuv" path="res://player/bullet/blue_myst.png" id="8"]
 [ext_resource type="Material" path="res://player/bullet/bullet_material.tres" id="9"]
 
+[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_peqai"]
+properties/0/path = NodePath(".:global_transform")
+properties/0/spawn = true
+properties/0/sync = true
+
 [sub_resource type="SphereMesh" id="SphereMesh_ku2wu"]
 radial_segments = 9
 rings = 5
@@ -42,7 +47,7 @@ tracks/1/keys = {
 "transitions": PackedFloat32Array(1),
 "values": [{
 "args": [],
-"method": &"queue_free"
+"method": &"destroy"
 }]
 }
 tracks/2/type = "value"
@@ -405,8 +410,12 @@ point_count = 3
 [node name="Bullet" type="CharacterBody3D"]
 transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0.00535512)
 collision_layer = 0
+collision_mask = 3
 script = ExtResource("1")
 
+[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="."]
+replication_config = SubResource("SceneReplicationConfig_peqai")
+
 [node name="MeshInstance3D" type="MeshInstance3D" parent="."]
 transform = Transform3D(0.1, 0, 0, 0, 0.1, 0, 0, 0, 0.1, 0, 0, 0)
 visible = false

+ 94 - 143
player/player.gd

@@ -1,16 +1,7 @@
 class_name Player
 extends CharacterBody3D
 
-const CAMERA_MOUSE_ROTATION_SPEED = 0.001
-const CAMERA_CONTROLLER_ROTATION_SPEED = 3.0
-# A minimum angle lower than or equal to -90 breaks movement if the player is looking upward.
-const CAMERA_X_ROT_MIN = -89.9
-const CAMERA_X_ROT_MAX = 70
-
-# Release aiming if the mouse/gamepad button was held for longer than 0.4 seconds.
-# This works well for trackpads and is more accessible by not making long presses a requirement.
-# If the aiming button was held for less than 0.4 seconds, keep aiming until the aiming button is pressed again.
-const AIM_HOLD_THRESHOLD = 0.4
+enum ANIMATIONS {JUMP_UP, JUMP_DOWN, STRAFE, WALK}
 
 const DIRECTION_INTERPOLATE_SPEED = 1
 const MOTION_INTERPOLATE_SPEED = 10
@@ -25,179 +16,126 @@ var orientation = Transform3D()
 var root_motion = Transform3D()
 var motion = Vector2()
 
-var aiming = false
-
-# If `true`, the aim button was toggled checked by a short press (instead of being held down).
-var toggled_aim = false
-
-# The duration the aiming button was held for (in seconds).
-var aiming_timer = 0.0
-
-var camera_x_rot = 0.0
-
 @onready var initial_position = transform.origin
 @onready var gravity = ProjectSettings.get_setting("physics/3d/default_gravity") * ProjectSettings.get_setting("physics/3d/default_gravity_vector")
 
+@onready var player_input = $InputSynchronizer
 @onready var animation_tree = $AnimationTree
 @onready var player_model = $PlayerModel
 @onready var shoot_from = player_model.get_node("Robot_Skeleton/Skeleton3D/GunBone/ShootFrom")
-@onready var color_rect = $ColorRect
 @onready var crosshair = $Crosshair
 @onready var fire_cooldown = $FireCooldown
 
-@onready var camera_base = $CameraBase
-@onready var camera_animation = camera_base.get_node("Animation")
-@onready var camera_rot = camera_base.get_node("CameraRot")
-@onready var camera_spring_arm = camera_rot.get_node("SpringArm3D")
-@onready var camera_camera = camera_spring_arm.get_node("Camera3D")
-
 @onready var sound_effects = $SoundEffects
 @onready var sound_effect_jump = sound_effects.get_node("Jump")
 @onready var sound_effect_land = sound_effects.get_node("Land")
 @onready var sound_effect_shoot = sound_effects.get_node("Shoot")
 
-func _init():
-	Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
+@export var player_id := 1 :
+	set(value):
+		player_id = value
+		$InputSynchronizer.set_multiplayer_authority(value)
 
+@export var current_animation := ANIMATIONS.WALK
 
 func _ready():
 	# Pre-initialize orientation transform.
 	orientation = player_model.global_transform
 	orientation.origin = Vector3()
+	if not multiplayer.is_server():
+		set_process(false)
 
 
-func _process(delta):
-	# Fade out to black if falling out of the map. -17 is lower than
-	# the lowest valid position checked the map (which is a bit under -16).
-	# At 15 units below -17 (so -32), the screen turns fully black.
-	if transform.origin.y < -17:
-		color_rect.modulate.a = min((-17 - transform.origin.y) / 15, 1)
-		# If we're below -40, respawn (teleport to the initial position).
-		if transform.origin.y < -40:
-			transform.origin = initial_position
+func _physics_process(delta: float):
+	if multiplayer.is_server():
+		apply_input(delta)
 	else:
-		# Fade out the black ColorRect progressively after being teleported back.
-		color_rect.modulate.a *= 1.0 - delta * 4
-
-
-func _physics_process(delta):
-	var camera_move = Vector2(
-			Input.get_action_strength("view_right") - Input.get_action_strength("view_left"),
-			Input.get_action_strength("view_up") - Input.get_action_strength("view_down"))
-	var camera_speed_this_frame = delta * CAMERA_CONTROLLER_ROTATION_SPEED
-	if aiming:
-		camera_speed_this_frame *= 0.5
-	rotate_camera(camera_move * camera_speed_this_frame)
-	var motion_target = Vector2(
-			Input.get_action_strength("move_right") - Input.get_action_strength("move_left"),
-			Input.get_action_strength("move_back") - Input.get_action_strength("move_forward"))
-	motion = motion.lerp(motion_target, MOTION_INTERPOLATE_SPEED * delta)
-
-	var camera_basis = camera_rot.global_transform.basis
-	var camera_z = camera_basis.z
-	var camera_x = camera_basis.x
+		animate(current_animation, delta)
 
-	camera_z.y = 0
-	camera_z = camera_z.normalized()
-	camera_x.y = 0
-	camera_x = camera_x.normalized()
 
-	var current_aim = false
+func animate(anim: int, delta:=0.0):
+	current_animation = anim
 
-	# Keep aiming if the mouse wasn't held for long enough.
-	if Input.is_action_just_released("aim") and aiming_timer <= AIM_HOLD_THRESHOLD:
-		current_aim = true
-		toggled_aim = true
-	else:
-		current_aim = toggled_aim or Input.is_action_pressed("aim")
-		if Input.is_action_just_pressed("aim"):
-			toggled_aim = false
+	if anim == ANIMATIONS.JUMP_UP:
+		animation_tree["parameters/state/transition_request"] = "jump_up"
 
-	if current_aim:
-		aiming_timer += delta
-	else:
-		aiming_timer = 0.0
+	elif anim == ANIMATIONS.JUMP_DOWN:
+		animation_tree["parameters/state/transition_request"] = "jump_down"
 
-	if aiming != current_aim:
-		aiming = current_aim
-		if aiming:
-			camera_animation.play("shoot")
-		else:
-			camera_animation.play("far")
+	elif anim == ANIMATIONS.STRAFE:
+		animation_tree["parameters/state/transition_request"] = "strafe"
+		# Change aim according to camera rotation.
+		animation_tree["parameters/aim/add_amount"] = player_input.get_aim_rotation()
+		# The animation's forward/backward axis is reversed.
+		animation_tree["parameters/strafe/blend_position"] = Vector2(motion.x, -motion.y)
+
+	elif anim == ANIMATIONS.WALK:
+		# Aim to zero (no aiming while walking).
+		animation_tree["parameters/aim/add_amount"] = 0
+		# Change state to walk.
+		animation_tree["parameters/state/transition_request"] = "walk"
+		# Blend position for walk speed based checked motion.
+		animation_tree["parameters/walk/blend_position"] = Vector2(motion.length(), 0)
+
+
+func apply_input(delta: float):
+	motion = motion.lerp(player_input.motion, MOTION_INTERPOLATE_SPEED * delta)
+
+	var camera_basis : Basis = player_input.get_camera_rotation_basis()
+	var camera_z := camera_basis.z
+	var camera_x := camera_basis.x
+
+	camera_z.y = 0
+	camera_z = camera_z.normalized()
+	camera_x.y = 0
+	camera_x = camera_x.normalized()
 
 	# Jump/in-air logic.
 	airborne_time += delta
 	if is_on_floor():
 		if airborne_time > 0.5:
-			sound_effect_land.play()
+			land.rpc()
 		airborne_time = 0
 
 	var on_air = airborne_time > MIN_AIRBORNE_TIME
 
-	if not on_air and Input.is_action_just_pressed("jump"):
+	if not on_air and player_input.jumping:
 		velocity.y = JUMP_SPEED
 		on_air = true
 		# Increase airborne time so next frame on_air is still true
 		airborne_time = MIN_AIRBORNE_TIME
-		animation_tree["parameters/state/transition_request"] = "jump_up"
-		sound_effect_jump.play()
+		jump.rpc()
+
+	player_input.jumping = false
 
 	if on_air:
 		if (velocity.y > 0):
-			animation_tree["parameters/state/transition_request"] = "jump_up"
+			animate(ANIMATIONS.JUMP_UP, delta)
 		else:
-			animation_tree["parameters/state/transition_request"] = "jump_down"
-	elif aiming:
-		# Change state to strafe.
-		animation_tree["parameters/state/transition_request"] = "strafe"
-
-		# Change aim according to camera rotation.
-		if camera_x_rot >= 0: # Aim up.
-			animation_tree["parameters/aim/add_amount"] = -camera_x_rot / deg_to_rad(CAMERA_X_ROT_MAX)
-		else: # Aim down.
-			animation_tree["parameters/aim/add_amount"] = camera_x_rot / deg_to_rad(CAMERA_X_ROT_MIN)
-
+			animate(ANIMATIONS.JUMP_DOWN, delta)
+	elif player_input.aiming:
 		# Convert orientation to quaternions for interpolating rotation.
 		var q_from = orientation.basis.get_rotation_quaternion()
-		var q_to = camera_base.global_transform.basis.get_rotation_quaternion()
+		var q_to = player_input.get_camera_base_quaternion()
 		# Interpolate current rotation with desired one.
 		orientation.basis = Basis(q_from.slerp(q_to, delta * ROTATION_INTERPOLATE_SPEED))
 
-		# The animation's forward/backward axis is reversed.
-		animation_tree["parameters/strafe/blend_position"] = Vector2(motion.x, -motion.y)
+		# Change state to strafe.
+		animate(ANIMATIONS.STRAFE, delta)
 
 		root_motion = Transform3D(animation_tree.get_root_motion_rotation(), animation_tree.get_root_motion_position())
 
-		if Input.is_action_pressed("shoot") and fire_cooldown.time_left == 0:
+		if player_input.shooting and fire_cooldown.time_left == 0:
 			var shoot_origin = shoot_from.global_transform.origin
-
-			var ch_pos = crosshair.position + crosshair.size * 0.5
-			var ray_from = camera_camera.project_ray_origin(ch_pos)
-			var ray_dir = camera_camera.project_ray_normal(ch_pos)
-
-			var shoot_target
-			var col = get_world_3d().direct_space_state.intersect_ray(PhysicsRayQueryParameters3D.create(ray_from, ray_from + ray_dir * 1000, 0b11, [self]))
-			if col.is_empty():
-				shoot_target = ray_from + ray_dir * 1000
-			else:
-				shoot_target = col.position
-			var shoot_dir = (shoot_target - shoot_origin).normalized()
+			var shoot_dir = (player_input.shoot_target - shoot_origin).normalized()
 
 			var bullet = preload("res://player/bullet/bullet.tscn").instantiate()
-			get_parent().add_child(bullet)
+			get_parent().add_child(bullet, true)
 			bullet.global_transform.origin = shoot_origin
 			# If we don't rotate the bullets there is no useful way to control the particles ..
 			bullet.look_at(shoot_origin + shoot_dir, Vector3.UP)
 			bullet.add_collision_exception_with(self)
-			var shoot_particle = $PlayerModel/Robot_Skeleton/Skeleton3D/GunBone/ShootFrom/ShootParticle
-			shoot_particle.restart()
-			shoot_particle.emitting = true
-			var muzzle_particle = $PlayerModel/Robot_Skeleton/Skeleton3D/GunBone/ShootFrom/MuzzleFlash
-			muzzle_particle.restart()
-			muzzle_particle.emitting = true
-			fire_cooldown.start()
-			sound_effect_shoot.play()
-			camera_camera.add_trauma(0.35)
+			shoot.rpc()
 
 	else: # Not in air or aiming, idle.
 		# Convert orientation to quaternions for interpolating rotation.
@@ -208,12 +146,7 @@ func _physics_process(delta):
 			# Interpolate current rotation with desired one.
 			orientation.basis = Basis(q_from.slerp(q_to, delta * ROTATION_INTERPOLATE_SPEED))
 
-		# Aim to zero (no aiming while walking).
-		animation_tree["parameters/aim/add_amount"] = 0
-		# Change state to walk.
-		animation_tree["parameters/state/transition_request"] = "walk"
-		# Blend position for walk speed based checked motion.
-		animation_tree["parameters/walk/blend_position"] = Vector2(motion.length(), 0)
+		animate(ANIMATIONS.WALK, delta)
 
 		root_motion = Transform3D(animation_tree.get_root_motion_rotation(), animation_tree.get_root_motion_position())
 
@@ -233,23 +166,41 @@ func _physics_process(delta):
 
 	player_model.global_transform.basis = orientation.basis
 
+	# If we're below -40, respawn (teleport to the initial position).
+	if transform.origin.y < -40:
+		transform.origin = initial_position
+
+
+@rpc("call_local")
+func jump():
+	animate(ANIMATIONS.JUMP_UP)
+	sound_effect_jump.play()
+
+
+@rpc("call_local")
+func land():
+	animate(ANIMATIONS.JUMP_DOWN)
+	sound_effect_land.play()
+
 
-func _input(event):
-	if event is InputEventMouseMotion:
-		var camera_speed_this_frame = CAMERA_MOUSE_ROTATION_SPEED
-		if aiming:
-			camera_speed_this_frame *= 0.75
-		rotate_camera(event.relative * camera_speed_this_frame)
+@rpc("call_local")
+func shoot():
+	var shoot_particle = $PlayerModel/Robot_Skeleton/Skeleton3D/GunBone/ShootFrom/ShootParticle
+	shoot_particle.restart()
+	shoot_particle.emitting = true
+	var muzzle_particle = $PlayerModel/Robot_Skeleton/Skeleton3D/GunBone/ShootFrom/MuzzleFlash
+	muzzle_particle.restart()
+	muzzle_particle.emitting = true
+	fire_cooldown.start()
+	sound_effect_shoot.play()
+	add_camera_shake_trauma(0.35)
 
 
-func rotate_camera(move):
-	camera_base.rotate_y(-move.x)
-	# After relative transforms, camera needs to be renormalized.
-	camera_base.orthonormalize()
-	camera_x_rot += move.y
-	camera_x_rot = clamp(camera_x_rot, deg_to_rad(CAMERA_X_ROT_MIN), deg_to_rad(CAMERA_X_ROT_MAX))
-	camera_rot.rotation.x = camera_x_rot
+@rpc("call_local")
+func hit():
+	add_camera_shake_trauma(.75)
 
 
+@rpc("call_local")
 func add_camera_shake_trauma(amount):
-	camera_camera.add_trauma(amount)
+	player_input.camera_camera.add_trauma(amount)

+ 52 - 2
player/player.tscn

@@ -1,7 +1,8 @@
-[gd_scene load_steps=45 format=3 uid="uid://cs1k22tdf04k4"]
+[gd_scene load_steps=48 format=3 uid="uid://cs1k22tdf04k4"]
 
 [ext_resource type="Script" path="res://player/player.gd" id="1"]
 [ext_resource type="PackedScene" uid="uid://div25sd40yx1d" path="res://player/model/player.glb" id="2"]
+[ext_resource type="Script" path="res://player/player_input.gd" id="2_g11dy"]
 [ext_resource type="Texture2D" uid="uid://vpkjqsku35mr" path="res://player/crosshair.png" id="3"]
 [ext_resource type="AudioStream" uid="uid://do3ko5enqvvrb" path="res://player/audio/step_random_pitch.tres" id="4"]
 [ext_resource type="AudioStream" uid="uid://evwr35k4yn6g" path="res://player/audio/jump.wav" id="5"]
@@ -12,6 +13,43 @@
 [ext_resource type="Texture2D" uid="uid://dj46di0ip66q5" path="res://player/bullet/effect/FlarePolar.png" id="11"]
 [ext_resource type="AudioStream" uid="uid://glpjepixvmdl" path="res://player/audio/shoot.wav" id="11_cqsut"]
 
+[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_o4rt5"]
+properties/0/path = NodePath(".:transform")
+properties/0/spawn = true
+properties/0/sync = true
+properties/1/path = NodePath(".:player_id")
+properties/1/spawn = true
+properties/1/sync = false
+properties/2/path = NodePath("PlayerModel:transform")
+properties/2/spawn = true
+properties/2/sync = true
+properties/3/path = NodePath(".:motion")
+properties/3/spawn = true
+properties/3/sync = true
+properties/4/path = NodePath(".:current_animation")
+properties/4/spawn = true
+properties/4/sync = true
+
+[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_8yuxf"]
+properties/0/path = NodePath("CameraBase:rotation")
+properties/0/spawn = true
+properties/0/sync = true
+properties/1/path = NodePath("CameraBase/CameraRot:rotation")
+properties/1/spawn = true
+properties/1/sync = true
+properties/2/path = NodePath("InputSynchronizer:shoot_target")
+properties/2/spawn = true
+properties/2/sync = true
+properties/3/path = NodePath("InputSynchronizer:motion")
+properties/3/spawn = true
+properties/3/sync = true
+properties/4/path = NodePath("InputSynchronizer:shooting")
+properties/4/spawn = true
+properties/4/sync = true
+properties/5/path = NodePath("InputSynchronizer:aiming")
+properties/5/spawn = true
+properties/5/sync = true
+
 [sub_resource type="SphereMesh" id="2"]
 radius = 0.003
 height = 0.02
@@ -255,6 +293,19 @@ collision_layer = 6
 collision_mask = 7
 script = ExtResource("1")
 
+[node name="ServerSynchronizer" type="MultiplayerSynchronizer" parent="."]
+replication_config = SubResource("SceneReplicationConfig_o4rt5")
+
+[node name="InputSynchronizer" type="MultiplayerSynchronizer" parent="." node_paths=PackedStringArray("camera_animation", "crosshair", "camera_base", "camera_rot", "camera_camera", "color_rect")]
+replication_config = SubResource("SceneReplicationConfig_8yuxf")
+script = ExtResource("2_g11dy")
+camera_animation = NodePath("../CameraBase/Animation")
+crosshair = NodePath("../Crosshair")
+camera_base = NodePath("../CameraBase")
+camera_rot = NodePath("../CameraBase/CameraRot")
+camera_camera = NodePath("../CameraBase/CameraRot/SpringArm3D/Camera3D")
+color_rect = NodePath("../ColorRect")
+
 [node name="PlayerModel" parent="." instance=ExtResource("2")]
 
 [node name="Robot_Skeleton" parent="PlayerModel" index="0"]
@@ -502,7 +553,6 @@ spring_length = 2.4
 
 [node name="Camera3D" type="Camera3D" parent="CameraBase/CameraRot/SpringArm3D"]
 transform = Transform3D(1, 1.42109e-14, -2.84217e-14, 0, 1, 0, 0, 0, 1, 0, 0, 0)
-current = true
 far = 300.0
 script = ExtResource("8")
 

+ 140 - 0
player/player_input.gd

@@ -0,0 +1,140 @@
+extends MultiplayerSynchronizer
+
+const CAMERA_CONTROLLER_ROTATION_SPEED := 3.0
+const CAMERA_MOUSE_ROTATION_SPEED := 0.001
+# A minimum angle lower than or equal to -90 breaks movement if the player is looking upward.
+const CAMERA_X_ROT_MIN := deg_to_rad(-89.9)
+const CAMERA_X_ROT_MAX := deg_to_rad(70)
+
+# Release aiming if the mouse/gamepad button was held for longer than 0.4 seconds.
+# This works well for trackpads and is more accessible by not making long presses a requirement.
+# If the aiming button was held for less than 0.4 seconds, keep aiming until the aiming button is pressed again.
+const AIM_HOLD_THRESHOLD = 0.4
+
+# If `true`, the aim button was toggled checked by a short press (instead of being held down).
+var toggled_aim := false
+
+# The duration the aiming button was held for (in seconds).
+var aiming_timer := 0.0
+
+# Synchronized controls
+@export var aiming := false
+@export var shoot_target := Vector3()
+@export var motion := Vector2()
+@export var shooting := false
+# This is handled via RPC for now
+@export var jumping := false
+
+# Camera and effects
+@export var camera_animation : AnimationPlayer
+@export var crosshair : TextureRect
+@export var camera_base : Node3D
+@export var camera_rot : Node3D
+@export var camera_camera : Camera3D
+@export var color_rect : ColorRect
+
+
+func _ready():
+	if get_multiplayer_authority() == multiplayer.get_unique_id():
+		camera_camera.make_current()
+		Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
+	else:
+		set_process(false)
+		set_process_input(false)
+		color_rect.hide()
+
+func _process(delta):
+	motion = Vector2(
+			Input.get_action_strength("move_right") - Input.get_action_strength("move_left"),
+			Input.get_action_strength("move_back") - Input.get_action_strength("move_forward"))
+	var camera_move = Vector2(
+			Input.get_action_strength("view_right") - Input.get_action_strength("view_left"),
+			Input.get_action_strength("view_up") - Input.get_action_strength("view_down"))
+	var camera_speed_this_frame = delta * CAMERA_CONTROLLER_ROTATION_SPEED
+	if aiming:
+		camera_speed_this_frame *= 0.5
+	rotate_camera(camera_move * camera_speed_this_frame)
+	var current_aim = false
+
+	# Keep aiming if the mouse wasn't held for long enough.
+	if Input.is_action_just_released("aim") and aiming_timer <= AIM_HOLD_THRESHOLD:
+		current_aim = true
+		toggled_aim = true
+	else:
+		current_aim = toggled_aim or Input.is_action_pressed("aim")
+		if Input.is_action_just_pressed("aim"):
+			toggled_aim = false
+
+	if current_aim:
+		aiming_timer += delta
+	else:
+		aiming_timer = 0.0
+
+	if aiming != current_aim:
+		aiming = current_aim
+		if aiming:
+			camera_animation.play("shoot")
+		else:
+			camera_animation.play("far")
+
+	if Input.is_action_just_pressed("jump"):
+		jump.rpc()
+
+	shooting = Input.is_action_pressed("shoot")
+	if shooting:
+		var ch_pos = crosshair.position + crosshair.size * 0.5
+		var ray_from = camera_camera.project_ray_origin(ch_pos)
+		var ray_dir = camera_camera.project_ray_normal(ch_pos)
+
+		var col = get_parent().get_world_3d().direct_space_state.intersect_ray(PhysicsRayQueryParameters3D.create(ray_from, ray_from + ray_dir * 1000, 0b11, [self]))
+		if col.is_empty():
+			shoot_target = ray_from + ray_dir * 1000
+		else:
+			shoot_target = col.position
+
+	# Fade out to black if falling out of the map. -17 is lower than
+	# the lowest valid position checked the map (which is a bit under -16).
+	# At 15 units below -17 (so -32), the screen turns fully black.
+	var tr : Transform3D = get_parent().global_transform
+	if tr.origin.y < -17:
+		color_rect.modulate.a = min((-17 - tr.origin.y) / 15, 1)
+	else:
+		# Fade out the black ColorRect progressively after being teleported back.
+		color_rect.modulate.a *= 1.0 - delta * 4
+
+
+func _input(event):
+	if event is InputEventMouseMotion:
+		var camera_speed_this_frame = CAMERA_MOUSE_ROTATION_SPEED
+		if aiming:
+			camera_speed_this_frame *= 0.75
+		rotate_camera(event.relative * camera_speed_this_frame)
+
+
+func rotate_camera(move):
+	camera_base.rotate_y(-move.x)
+	# After relative transforms, camera needs to be renormalized.
+	camera_base.orthonormalize()
+	camera_rot.rotation.x = clamp(camera_rot.rotation.x + move.y, CAMERA_X_ROT_MIN, CAMERA_X_ROT_MAX)
+
+
+func get_aim_rotation():
+	var camera_x_rot = clamp(camera_rot.rotation.x, CAMERA_X_ROT_MIN, CAMERA_X_ROT_MAX)
+	# Change aim according to camera rotation.
+	if camera_x_rot >= 0: # Aim up.
+		return -camera_x_rot / CAMERA_X_ROT_MAX
+	else: # Aim down.
+		return camera_x_rot / CAMERA_X_ROT_MIN
+
+
+func get_camera_base_quaternion() -> Quaternion:
+	return camera_base.global_transform.basis.get_rotation_quaternion()
+
+
+func get_camera_rotation_basis() -> Basis:
+	return camera_rot.global_transform.basis
+
+
+@rpc("call_local")
+func jump():
+	jumping = true

+ 3 - 3
project.godot

@@ -194,13 +194,13 @@ view_down={
 }
 toggle_fullscreen={
 "deadzone": 0.5,
-"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":70,"physical_keycode":0,"key_label":0,"unicode":102,"echo":false,"script":null)
-, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":0,"key_label":0,"unicode":0,"echo":false,"script":null)
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":true,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194309,"physical_keycode":0,"key_label":0,"unicode":0,"echo":false,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194342,"physical_keycode":0,"key_label":0,"unicode":0,"echo":false,"script":null)
 ]
 }
 toggle_debug={
 "deadzone": 0.5,
-"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":88,"physical_keycode":0,"key_label":0,"unicode":120,"echo":false,"script":null)
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194334,"physical_keycode":0,"key_label":0,"unicode":0,"echo":false,"script":null)
 ]
 }