diff --git a/network/network_session.gd b/network/network_session.gd index 99c77bc..8d7205a 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -12,6 +12,7 @@ const SNAPSHOT_INTERVAL: float = 1.0 / 30.0 const MOVEMENT_SNAPSHOT_BATCH_SIZE: int = 8 const MOVEMENT_SNAPSHOT_FIELD_COUNT: int = 12 const MAX_MOVEMENT_INPUT_SEQUENCE: int = 2147483647 +const MAX_MOVEMENT_ONE_WAY_TRANSIT_SECONDS: float = 0.25 signal state_changed(state: State) signal status_message_changed(message: String) @@ -1890,6 +1891,9 @@ func receive_movement_snapshots(encoded_snapshots: Array) -> void: if state != State.JOINED_CLIENT: return var local_peer_id: int = multiplayer.get_unique_id() + var estimated_transit_seconds: float = ( + _estimated_movement_transit_seconds() + ) for value: Variant in encoded_snapshots: var snapshot: Dictionary = _decode_movement_snapshot(value) if snapshot.is_empty(): @@ -1905,9 +1909,25 @@ func receive_movement_snapshots(encoded_snapshots: Array) -> void: snapshot, _input_sequence, INPUT_INTERVAL, + estimated_transit_seconds, ) else: - avatar.push_network_snapshot(snapshot) + avatar.push_network_snapshot( + snapshot, + estimated_transit_seconds, + ) + + +func _estimated_movement_transit_seconds() -> float: + if state != State.JOINED_CLIENT: + return -1.0 + var round_trip_msec: int = get_peer_rtt_ms(get_local_peer_id()) + if round_trip_msec < 0: + return -1.0 + return minf( + float(round_trip_msec) / 2000.0, + MAX_MOVEMENT_ONE_WAY_TRANSIT_SECONDS, + ) func publish_authoritative_teleport(peer_id: int) -> void: @@ -2149,6 +2169,11 @@ func _fail(message: String) -> void: func _teardown_peer() -> void: + var local_avatar: Player + if _spawn_service != null: + local_avatar = _spawn_service.get_local_player() + if local_avatar != null: + local_avatar.reset_network_movement_state() for peer_id: int in _recovery_attempts.keys(): remote_recovery_presentation_changed.emit( peer_id, false, _recovery_attempts[peer_id] @@ -2166,6 +2191,7 @@ func _teardown_peer() -> void: _transport.disconnect_transport() multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new() _connection_deadline = 0.0 + _input_sequence = 0 _input_accumulator = 0.0 _snapshot_accumulator = 0.0 _server_capabilities = PackedStringArray() diff --git a/network/player_spawn_service.gd b/network/player_spawn_service.gd index 1352758..ae6ee00 100644 --- a/network/player_spawn_service.gd +++ b/network/player_spawn_service.gd @@ -78,6 +78,10 @@ func get_avatar(peer_id: int) -> Player: return _avatars.get(peer_id) +func get_local_player() -> Player: + return _local_player + + func set_peer_presentation_visible(peer_id: int, should_be_visible: bool) -> void: var avatar: Player = _avatars.get(peer_id) if avatar != null and is_instance_valid(avatar): diff --git a/player/player.gd b/player/player.gd index efc288b..da23e45 100644 --- a/player/player.gd +++ b/player/player.gd @@ -104,8 +104,15 @@ const CHARACTER_CALL_MOUTH_ID: String = "open_ah" const CHARACTER_CALL_MOUTH_DURATION_SECONDS: float = 0.16 const BASE_REEL_SPEED: float = 0.16 const LANDING_DUST_MIN_FALL_SPEED: float = 2.5 -const NETWORK_EXTRAPOLATION_LIMIT_SECONDS: float = 0.5 +const NETWORK_EXTRAPOLATION_LIMIT_SECONDS: float = 0.25 +const NETWORK_EXPECTED_SNAPSHOT_INTERVAL_SECONDS: float = 1.0 / 30.0 +const NETWORK_SNAPSHOT_JITTER_LIMIT_SECONDS: float = 0.1 +const NETWORK_SNAPSHOT_JITTER_WEIGHT: float = 0.15 +const NETWORK_REMOTE_SMOOTHING_RATE: float = 12.0 +const NETWORK_REMOTE_JITTER_SMOOTHING_RATE: float = 6.0 +const NETWORK_INPUT_STALE_TIMEOUT_SECONDS: float = 0.25 const LOCAL_PREDICTION_EXTRAPOLATION_LIMIT_SECONDS: float = 0.25 +const LOCAL_PREDICTION_FALLBACK_TRANSIT_RATIO: float = 0.5 const LOCAL_PREDICTION_CORRECTION_THRESHOLD: float = 0.12 const LOCAL_PREDICTION_SNAP_DISTANCE: float = 2.0 const LOCAL_PREDICTION_CORRECTION_WEIGHT: float = 0.18 @@ -408,6 +415,9 @@ var _network_sprint: bool = false var _network_sneak: bool = false var _network_slow_walk: bool = false var _last_network_input_sequence: int = 0 +var _network_input_age: float = 0.0 +var _network_input_stale: bool = false +var _network_jump_intent_active: bool = false var _network_target_position: Vector3 var _network_target_velocity: Vector3 var _network_target_visual_yaw: float = 0.0 @@ -418,6 +428,9 @@ var _network_target_animation_action_sequence: int = 0 var _network_target_animation_action_elapsed: float = 0.0 var _network_snapshot_ready: bool = false var _network_snapshot_age: float = 0.0 +var _network_snapshot_jitter: float = 0.0 +var _local_network_jump_intent_pending: bool = false +var _local_network_jump_intent_sequence: int = -1 var _animation_action_id: StringName = &"" var _animation_action_sequence: int = 0 var _animation_action_elapsed: float = 0.0 @@ -648,6 +661,8 @@ func _physics_process(delta: float) -> void: if _network_interpolation_enabled: _update_network_interpolation(delta) return + if _network_authoritative_simulation: + _update_network_input_freshness(delta) if local_control_enabled and _free_camera_active: _update_free_camera_physics() velocity.x = 0.0 @@ -657,11 +672,21 @@ func _physics_process(delta: float) -> void: move_and_slide() _network_jump_pending = false return + var local_jump_pressed: bool = ( + local_control_enabled + and Input.is_action_just_pressed("jump") + ) + if ( + local_jump_pressed + and _is_movement_input_enabled() + and not _free_camera_active + ): + _queue_local_network_jump_intent() var jump_requested: bool = ( _is_movement_input_enabled() and not _free_camera_active and ( - (local_control_enabled and Input.is_action_just_pressed("jump")) + local_jump_pressed or (_network_authoritative_simulation and _network_jump_pending) ) ) @@ -1436,6 +1461,7 @@ func set_local_control(enabled: bool) -> void: _camera.current = enabled if not enabled: _set_camera_dragging(false) + _clear_local_network_jump_intent() func set_network_peer_id(peer_id: int) -> void: @@ -1450,8 +1476,17 @@ func configure_network_remote(authoritative_simulation: bool) -> void: set_local_control(false) _network_authoritative_simulation = authoritative_simulation _network_interpolation_enabled = not authoritative_simulation + _network_axis = Vector2.ZERO + _network_jump_pending = false + _network_sprint = false + _network_sneak = false + _network_slow_walk = false + _network_input_age = 0.0 + _network_input_stale = false + _network_jump_intent_active = false _network_snapshot_ready = false _network_snapshot_age = 0.0 + _network_snapshot_jitter = 0.0 _network_target_grounded = false _network_target_locomotion_state = LocomotionState.IDLE _network_target_animation_action_id = &"" @@ -1460,14 +1495,35 @@ func configure_network_remote(authoritative_simulation: bool) -> void: _camera.current = false +func reset_network_movement_state() -> void: + _clear_local_network_jump_intent() + _network_axis = Vector2.ZERO + _network_jump_pending = false + _network_sprint = false + _network_sneak = false + _network_slow_walk = false + _network_input_age = 0.0 + _network_input_stale = false + _network_jump_intent_active = false + _last_network_input_sequence = 0 + + func capture_network_input(sequence: int) -> Dictionary: if _sitting_intent_pending and _sitting_intent_sequence < 0: _sitting_intent_sequence = sequence + if ( + local_control_enabled + and _is_movement_input_enabled() + and not _free_camera_active + and Input.is_action_just_pressed("jump") + ): + _queue_local_network_jump_intent() if ( not _is_movement_input_enabled() or _water_recovery_active or _free_camera_active ): + _clear_local_network_jump_intent() return { "sequence": sequence, "axis": [0.0, 0.0], @@ -1488,11 +1544,16 @@ func capture_network_input(sequence: int) -> Dictionary: "move_forward", "move_backward" ) + if ( + _local_network_jump_intent_pending + and _local_network_jump_intent_sequence < 0 + ): + _local_network_jump_intent_sequence = sequence return { "sequence": sequence, "axis": [axis.x, axis.y], "camera_yaw": _camera_yaw.global_rotation.y, - "jump": Input.is_action_just_pressed("jump"), + "jump": _local_network_jump_intent_pending, "sprint": Input.is_action_pressed("sprint"), "sneak": Input.is_action_pressed("sneak"), "slow_walk": Input.is_action_pressed("slow_walk"), @@ -1510,9 +1571,14 @@ func apply_authoritative_network_input(data: Dictionary) -> void: if axis.size() != 2: return _last_network_input_sequence = sequence + _network_input_age = 0.0 + _network_input_stale = false _network_axis = Vector2(float(axis[0]), float(axis[1])).limit_length(1.0) _network_camera_yaw = float(data.get("camera_yaw", 0.0)) - _network_jump_pending = bool(data.get("jump", false)) + var jump_intent_active: bool = bool(data.get("jump", false)) + if jump_intent_active and not _network_jump_intent_active: + _network_jump_pending = true + _network_jump_intent_active = jump_intent_active _network_sprint = bool(data.get("sprint", false)) _network_sneak = bool(data.get("sneak", false)) _network_slow_walk = bool(data.get("slow_walk", false)) @@ -1546,11 +1612,37 @@ func make_network_snapshot(peer_id: int) -> Dictionary: } -func push_network_snapshot(snapshot: Dictionary) -> void: +func push_network_snapshot( + snapshot: Dictionary, + estimated_transit_seconds: float = -1.0, +) -> void: var parsed: Dictionary = _parse_network_snapshot(snapshot) if parsed.is_empty(): return - _network_target_position = parsed["position"] + if _network_snapshot_ready: + var arrival_error: float = absf( + _network_snapshot_age + - NETWORK_EXPECTED_SNAPSHOT_INTERVAL_SECONDS + ) + _network_snapshot_jitter = lerpf( + _network_snapshot_jitter, + minf(arrival_error, NETWORK_SNAPSHOT_JITTER_LIMIT_SECONDS), + NETWORK_SNAPSHOT_JITTER_WEIGHT, + ) + var transit_seconds: float = ( + clampf( + estimated_transit_seconds, + 0.0, + NETWORK_EXTRAPOLATION_LIMIT_SECONDS, + ) + if is_finite(estimated_transit_seconds) + and estimated_transit_seconds >= 0.0 + else 0.0 + ) + _network_target_position = ( + (parsed["position"] as Vector3) + + (parsed["velocity"] as Vector3) * transit_seconds + ) _network_target_velocity = parsed["velocity"] _network_target_visual_yaw = parsed["visual_yaw"] _apply_network_target_animation_state(parsed["animation_state"]) @@ -1568,6 +1660,7 @@ func apply_local_prediction_correction( snapshot: Dictionary, latest_input_sequence: int = 0, input_interval_seconds: float = 0.0, + estimated_transit_seconds: float = -1.0, ) -> void: var parsed: Dictionary = _parse_network_snapshot(snapshot) if parsed.is_empty(): @@ -1581,22 +1674,22 @@ func apply_local_prediction_correction( if sitting_intent_acknowledged: _sitting_intent_pending = false _sitting_intent_sequence = -1 + if ( + _local_network_jump_intent_pending + and _local_network_jump_intent_sequence >= 0 + and acknowledged_input >= _local_network_jump_intent_sequence + ): + _clear_local_network_jump_intent() if not _sitting_intent_pending: _set_sitting(bool(parsed["sitting"])) var authoritative_position: Vector3 = parsed["position"] - if ( - acknowledged_input > 0 - and latest_input_sequence > acknowledged_input - and input_interval_seconds > 0.0 - ): - # The snapshot describes the host's position when an older input was - # acknowledged. Project it through the measured input-sequence gap so - # ordinary round-trip latency is not mistaken for prediction error. - var sequence_gap: int = latest_input_sequence - acknowledged_input - var transit_seconds: float = minf( - float(sequence_gap) * input_interval_seconds, - LOCAL_PREDICTION_EXTRAPOLATION_LIMIT_SECONDS, - ) + var transit_seconds: float = resolve_local_prediction_transit_seconds( + acknowledged_input, + latest_input_sequence, + input_interval_seconds, + estimated_transit_seconds, + ) + if transit_seconds > 0.0: authoritative_position += ( (parsed["velocity"] as Vector3) * transit_seconds ) @@ -1612,6 +1705,35 @@ func apply_local_prediction_correction( ) +static func resolve_local_prediction_transit_seconds( + acknowledged_input: int, + latest_input_sequence: int, + input_interval_seconds: float, + estimated_transit_seconds: float, +) -> float: + if is_finite(estimated_transit_seconds) and estimated_transit_seconds >= 0.0: + return minf( + estimated_transit_seconds, + LOCAL_PREDICTION_EXTRAPOLATION_LIMIT_SECONDS, + ) + if ( + acknowledged_input <= 0 + or latest_input_sequence <= acknowledged_input + or input_interval_seconds <= 0.0 + ): + return 0.0 + # The input gap spans approximately the full round trip: from the input + # acknowledged by the host to the newest input at snapshot receipt. Only + # half of that interval lies between the host snapshot and the client now. + var sequence_gap: int = latest_input_sequence - acknowledged_input + return minf( + float(sequence_gap) + * input_interval_seconds + * LOCAL_PREDICTION_FALLBACK_TRANSIT_RATIO, + LOCAL_PREDICTION_EXTRAPOLATION_LIMIT_SECONDS, + ) + + func apply_network_teleport(snapshot: Dictionary) -> void: var parsed: Dictionary = _parse_network_snapshot(snapshot) if parsed.is_empty(): @@ -1807,23 +1929,38 @@ func _apply_network_casting(casting: bool) -> void: func _update_network_interpolation(delta: float) -> void: if not _network_snapshot_ready: return + var previous_snapshot_age: float = _network_snapshot_age _network_snapshot_age = minf( _network_snapshot_age + delta, NETWORK_EXTRAPOLATION_LIMIT_SECONDS, ) + var extrapolation_delta: float = ( + _network_snapshot_age - previous_snapshot_age + ) var predicted_position: Vector3 = ( _network_target_position + _network_target_velocity * _network_snapshot_age ) var projected_position: Vector3 = ( - global_position + _network_target_velocity * delta + global_position + _network_target_velocity * extrapolation_delta ) if projected_position.distance_to(predicted_position) > 2.0: global_position = predicted_position else: + var jitter_ratio: float = clampf( + _network_snapshot_jitter + / NETWORK_SNAPSHOT_JITTER_LIMIT_SECONDS, + 0.0, + 1.0, + ) + var smoothing_rate: float = lerpf( + NETWORK_REMOTE_SMOOTHING_RATE, + NETWORK_REMOTE_JITTER_SMOOTHING_RATE, + jitter_ratio, + ) global_position = projected_position.lerp( predicted_position, - 1.0 - exp(-10.0 * delta) + 1.0 - exp(-smoothing_rate * delta) ) velocity = _network_target_velocity _visuals.rotation.y = lerp_angle( @@ -1833,6 +1970,36 @@ func _update_network_interpolation(delta: float) -> void: ) +func _update_network_input_freshness(delta: float) -> void: + _network_input_age = minf( + _network_input_age + delta, + NETWORK_INPUT_STALE_TIMEOUT_SECONDS + 1.0, + ) + if ( + _network_input_stale + or _network_input_age <= NETWORK_INPUT_STALE_TIMEOUT_SECONDS + ): + return + _network_input_stale = true + _network_axis = Vector2.ZERO + _network_jump_pending = false + _network_sprint = false + _network_sneak = false + _network_slow_walk = false + + +func _queue_local_network_jump_intent() -> void: + if _local_network_jump_intent_pending: + return + _local_network_jump_intent_pending = true + _local_network_jump_intent_sequence = -1 + + +func _clear_local_network_jump_intent() -> void: + _local_network_jump_intent_pending = false + _local_network_jump_intent_sequence = -1 + + func is_local_control_enabled() -> bool: return local_control_enabled diff --git a/tests/movement_multiplayer_validation.gd b/tests/movement_multiplayer_validation.gd index 6926bf0..274544a 100644 --- a/tests/movement_multiplayer_validation.gd +++ b/tests/movement_multiplayer_validation.gd @@ -32,7 +32,16 @@ func _validate_latency_smoothing() -> void: await process_frame avatar.set_process(false) avatar.set_physics_process(false) - avatar.configure_network_remote(false) + _validate_compact_snapshot_encoding() + _validate_transit_estimation() + _validate_remote_snapshot_smoothing(avatar) + _validate_reliable_jump_intent(avatar) + _validate_stale_input_expiry(avatar) + avatar.queue_free() + await process_frame + + +func _validate_compact_snapshot_encoding() -> void: var moving_snapshot: Dictionary = _network_snapshot( Vector3.ZERO, Vector3(4.5, 0.0, 0.0), @@ -50,21 +59,148 @@ func _validate_latency_smoothing() -> void: encoded_snapshots[0] ) == moving_snapshot ) - avatar.push_network_snapshot(moving_snapshot) - for _step: int in 12: - avatar.call("_update_network_interpolation", 1.0 / 30.0) - assert(avatar.global_position.x > 1.5) + +func _validate_transit_estimation() -> void: + assert(is_equal_approx( + Player.resolve_local_prediction_transit_seconds( + 10, + 16, + 1.0 / 30.0, + 0.075, + ), + 0.075, + )) + # Six outstanding 30 Hz inputs span about 200 ms round trip. The fallback + # must use only the approximately 100 ms one-way half of that gap. + assert(is_equal_approx( + Player.resolve_local_prediction_transit_seconds( + 10, + 16, + 1.0 / 30.0, + -1.0, + ), + 0.1, + )) + assert(is_equal_approx( + Player.resolve_local_prediction_transit_seconds( + 1, + 100, + 1.0 / 30.0, + -1.0, + ), + Player.LOCAL_PREDICTION_EXTRAPOLATION_LIMIT_SECONDS, + )) + + +func _validate_remote_snapshot_smoothing(avatar: Player) -> void: avatar.configure_network_remote(false) - avatar.global_position = Vector3(1.35, 0.0, 0.0) + var moving_snapshot: Dictionary = _network_snapshot( + Vector3.ZERO, + Vector3(4.5, 0.0, 0.0), + 1, + ) + avatar.push_network_snapshot(moving_snapshot, 0.1) + assert(is_equal_approx(avatar.global_position.x, 0.45)) + assert(is_equal_approx( + float(avatar.get("_network_target_position").x), + 0.45, + )) + avatar.call("_update_network_interpolation", 0.12) + var before_delayed_snapshot: Vector3 = avatar.global_position + var delayed_snapshot: Dictionary = _network_snapshot( + Vector3(0.54, 0.0, 0.0), + Vector3(4.5, 0.0, 0.0), + 2, + ) + avatar.push_network_snapshot(delayed_snapshot, 0.1) + # Snapshot receipt updates the target without teleporting a presented + # remote avatar, even after a jittered packet interval. + assert(avatar.global_position == before_delayed_snapshot) + assert(float(avatar.get("_network_snapshot_jitter")) > 0.0) + avatar.call("_update_network_interpolation", 1.0 / 60.0) + assert( + avatar.global_position.distance_to(before_delayed_snapshot) < 0.12 + ) + # Simulate a burst of dropped snapshots. Extrapolation must stop at the + # tight limit instead of allowing the remote avatar to run indefinitely. + for _step: int in 20: + avatar.call("_update_network_interpolation", 1.0 / 30.0) + assert(is_equal_approx( + float(avatar.get("_network_snapshot_age")), + Player.NETWORK_EXTRAPOLATION_LIMIT_SECONDS, + )) + var maximum_extrapolated_x: float = ( + 0.54 + + 4.5 * (0.1 + Player.NETWORK_EXTRAPOLATION_LIMIT_SECONDS) + ) + assert(avatar.global_position.x <= maximum_extrapolated_x + 0.1) + + avatar.set_local_control(true) + avatar.global_position = Vector3(0.8, 0.0, 0.0) avatar.apply_local_prediction_correction( moving_snapshot, 10, 1.0 / 30.0, + 0.1, ) - assert(avatar.global_position.x > 1.25) - avatar.queue_free() - await process_frame + assert(avatar.global_position.x < 0.8) + assert(avatar.global_position.x > 0.7) + + +func _validate_reliable_jump_intent(avatar: Player) -> void: + avatar.set_local_control(true) + avatar.call("_queue_local_network_jump_intent") + var first: Dictionary = avatar.capture_network_input(20) + var repeated: Dictionary = avatar.capture_network_input(21) + assert(bool(first["jump"])) + assert(bool(repeated["jump"])) + assert(int(avatar.get("_local_network_jump_intent_sequence")) == 20) + avatar.apply_local_prediction_correction( + _network_snapshot(avatar.global_position, Vector3.ZERO, 20), + 21, + 1.0 / 30.0, + 0.0, + ) + assert(not bool(avatar.capture_network_input(22)["jump"])) + + avatar.configure_network_remote(true) + var first_host_jump: Dictionary = _movement_input(30, false, false) + first_host_jump["jump"] = true + avatar.apply_authoritative_network_input(first_host_jump) + assert(bool(avatar.get("_network_jump_pending"))) + avatar.set("_network_jump_pending", false) + var repeated_host_jump: Dictionary = _movement_input(31, false, false) + repeated_host_jump["jump"] = true + avatar.apply_authoritative_network_input(repeated_host_jump) + assert(not bool(avatar.get("_network_jump_pending"))) + avatar.apply_authoritative_network_input( + _movement_input(32, false, false) + ) + var next_host_jump: Dictionary = _movement_input(33, false, false) + next_host_jump["jump"] = true + avatar.apply_authoritative_network_input(next_host_jump) + assert(bool(avatar.get("_network_jump_pending"))) + avatar.reset_network_movement_state() + assert(not bool(avatar.get("_network_jump_pending"))) + assert(not bool(avatar.get("_local_network_jump_intent_pending"))) + assert(int(avatar.get("_last_network_input_sequence")) == 0) + + +func _validate_stale_input_expiry(avatar: Player) -> void: + avatar.configure_network_remote(true) + avatar.apply_authoritative_network_input(_movement_input(40, true)) + assert((avatar.get("_network_axis") as Vector2).length_squared() > 0.0) + avatar.call( + "_update_network_input_freshness", + Player.NETWORK_INPUT_STALE_TIMEOUT_SECONDS + 0.01, + ) + assert((avatar.get("_network_axis") as Vector2) == Vector2.ZERO) + assert(not bool(avatar.get("_network_sprint"))) + assert(bool(avatar.get("_network_input_stale"))) + avatar.apply_authoritative_network_input(_movement_input(41, false)) + assert(not bool(avatar.get("_network_input_stale"))) + assert((avatar.get("_network_axis") as Vector2).length_squared() > 0.0) func _network_snapshot(