From 79a399674a716de168d64c9086ea9a8e9dfb538b Mon Sep 17 00:00:00 2001 From: Voyager Date: Fri, 14 Aug 2026 22:12:13 -0400 Subject: [PATCH] Improve controller freecam and network movement --- network/network_session.gd | 120 +++++++++++++++++++++-- player/player.gd | 76 +++++++++++--- tests/art_tools_validation.gd | 22 ++++- tests/movement_multiplayer_validation.gd | 67 +++++++++++++ 4 files changed, 263 insertions(+), 22 deletions(-) diff --git a/network/network_session.gd b/network/network_session.gd index cd08600..8af4f4a 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -9,6 +9,8 @@ const CONNECTION_TIMEOUT_SECONDS: float = 10.0 const AUTHENTICATION_TIMEOUT_SECONDS: float = 60.0 const INPUT_INTERVAL: float = 1.0 / 30.0 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 signal state_changed(state: State) @@ -1775,24 +1777,122 @@ func _is_valid_movement_input(data: Dictionary) -> bool: func _broadcast_movement_snapshots() -> void: - var snapshots: Array[Dictionary] = [] + var snapshots: Array = [] for peer_id: int in _registry.get_peer_ids(): var avatar: Player = _spawn_service.get_avatar(peer_id) if avatar == null: continue - snapshots.append(avatar.make_network_snapshot(peer_id)) - receive_movement_snapshots.rpc(snapshots) + var encoded: Array = _encode_movement_snapshot( + avatar.make_network_snapshot(peer_id) + ) + if not encoded.is_empty(): + snapshots.append(encoded) + # The compact v5 representation keeps a normal eight-player update near + # 1 KiB instead of the roughly 3.7 KiB dictionary representation. Rooms + # configured above the normal cap are divided into the same safe size. + for start_index: int in range( + 0, + snapshots.size(), + MOVEMENT_SNAPSHOT_BATCH_SIZE, + ): + receive_movement_snapshots.rpc( + snapshots.slice( + start_index, + mini( + start_index + MOVEMENT_SNAPSHOT_BATCH_SIZE, + snapshots.size(), + ), + ) + ) + + +static func _encode_movement_snapshot(snapshot: Dictionary) -> Array: + var position: Variant = snapshot.get("position") + var snapshot_velocity: Variant = snapshot.get("velocity") + var animation_value: Variant = snapshot.get("animation_state") + if ( + typeof(position) != TYPE_ARRAY + or position.size() != 3 + or typeof(snapshot_velocity) != TYPE_ARRAY + or snapshot_velocity.size() != 3 + or not NetworkPlayerAnimationProtocol.validate_state(animation_value) + ): + return [] + var animation: Dictionary = animation_value + var action: Dictionary = animation["action"] + return [ + int(snapshot.get("peer_id", 0)), + int(snapshot.get("acknowledged_input", 0)), + Vector3(float(position[0]), float(position[1]), float(position[2])), + Vector3( + float(snapshot_velocity[0]), + float(snapshot_velocity[1]), + float(snapshot_velocity[2]), + ), + float(snapshot.get("visual_yaw", 0.0)), + str(animation["locomotion_id"]), + bool(animation["grounded"]), + str(action["id"]), + int(action["sequence"]), + float(action["elapsed"]), + bool(snapshot.get("sitting", false)), + bool(snapshot.get("casting", false)), + ] + + +static func _decode_movement_snapshot(value: Variant) -> Dictionary: + if typeof(value) != TYPE_ARRAY: + return {} + var fields: Array = value + if ( + fields.size() != MOVEMENT_SNAPSHOT_FIELD_COUNT + or typeof(fields[0]) != TYPE_INT + or typeof(fields[1]) != TYPE_INT + or typeof(fields[2]) != TYPE_VECTOR3 + or typeof(fields[3]) != TYPE_VECTOR3 + or typeof(fields[4]) not in [TYPE_FLOAT, TYPE_INT] + or typeof(fields[5]) not in [TYPE_STRING, TYPE_STRING_NAME] + or typeof(fields[6]) != TYPE_BOOL + or typeof(fields[7]) not in [TYPE_STRING, TYPE_STRING_NAME] + or typeof(fields[8]) != TYPE_INT + or typeof(fields[9]) not in [TYPE_FLOAT, TYPE_INT] + or typeof(fields[10]) != TYPE_BOOL + or typeof(fields[11]) != TYPE_BOOL + ): + return {} + var position: Vector3 = fields[2] + var snapshot_velocity: Vector3 = fields[3] + return { + "peer_id": int(fields[0]), + "acknowledged_input": int(fields[1]), + "position": [position.x, position.y, position.z], + "velocity": [ + snapshot_velocity.x, + snapshot_velocity.y, + snapshot_velocity.z, + ], + "visual_yaw": float(fields[4]), + "animation_state": NetworkPlayerAnimationProtocol.make_state( + StringName(str(fields[5])), + bool(fields[6]), + StringName(str(fields[7])), + int(fields[8]), + float(fields[9]), + ), + "sitting": bool(fields[10]), + "casting": bool(fields[11]), + } @rpc("authority", "call_remote", "unreliable_ordered", 2) -func receive_movement_snapshots(snapshots: Array) -> void: +func receive_movement_snapshots(encoded_snapshots: Array) -> void: if state != State.JOINED_CLIENT: return var local_peer_id: int = multiplayer.get_unique_id() - for value: Variant in snapshots: - if typeof(value) != TYPE_DICTIONARY: + for value: Variant in encoded_snapshots: + var snapshot: Dictionary = _decode_movement_snapshot(value) + if snapshot.is_empty(): continue - var snapshot: Dictionary = value if typeof(snapshot.get("peer_id")) != TYPE_INT: continue var peer_id: int = snapshot["peer_id"] @@ -1800,7 +1900,11 @@ func receive_movement_snapshots(snapshots: Array) -> void: if avatar == null: continue if peer_id == local_peer_id: - avatar.apply_local_prediction_correction(snapshot) + avatar.apply_local_prediction_correction( + snapshot, + _input_sequence, + INPUT_INTERVAL, + ) else: avatar.push_network_snapshot(snapshot) diff --git a/player/player.gd b/player/player.gd index dfead0e..efc288b 100644 --- a/player/player.gd +++ b/player/player.gd @@ -104,6 +104,11 @@ 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 LOCAL_PREDICTION_EXTRAPOLATION_LIMIT_SECONDS: float = 0.25 +const LOCAL_PREDICTION_CORRECTION_THRESHOLD: float = 0.12 +const LOCAL_PREDICTION_SNAP_DISTANCE: float = 2.0 +const LOCAL_PREDICTION_CORRECTION_WEIGHT: float = 0.18 # The target Android handheld exposes its physical right trigger through # Godot's left-trigger axis. Keep the role named here so the platform mapping # remains isolated from camera behavior. @@ -767,6 +772,13 @@ func _process(delta: float) -> void: if not local_control_enabled: return if _free_camera_active: + if _is_camera_input_enabled(): + _rotate_free_camera( + _scale_controller_camera_input( + _get_controller_camera_stick(), + delta, + ) + ) return if _camera_dragging and not Input.is_action_pressed("camera_drag"): @@ -785,15 +797,8 @@ func _process(delta: float) -> void: + vertical_zoom_input * controller_zoom_speed * delta ) else: - var adjusted_strength: float = ( - (stick.length() - controller_camera_deadzone) - / (1.0 - controller_camera_deadzone) - ) _rotate_camera( - stick.normalized() - * adjusted_strength - * controller_camera_speed - * delta + _scale_controller_camera_input(stick, delta) ) var zoom_weight: float = 1.0 - exp(-zoom_smoothing * delta) @@ -884,6 +889,25 @@ func _get_controller_camera_stick() -> Vector2: ) +func _scale_controller_camera_input( + stick: Vector2, + delta: float, +) -> Vector2: + var strength: float = stick.length() + if strength <= controller_camera_deadzone: + return Vector2.ZERO + var adjusted_strength: float = ( + (strength - controller_camera_deadzone) + / (1.0 - controller_camera_deadzone) + ) + return ( + stick.normalized() + * adjusted_strength + * controller_camera_speed + * delta + ) + + func _get_controller_zoom_strength() -> float: if _controller_mapping_manager != null: return _controller_mapping_manager.get_role_strength( @@ -1540,7 +1564,11 @@ func push_network_snapshot(snapshot: Dictionary) -> void: _network_snapshot_ready = true -func apply_local_prediction_correction(snapshot: Dictionary) -> void: +func apply_local_prediction_correction( + snapshot: Dictionary, + latest_input_sequence: int = 0, + input_interval_seconds: float = 0.0, +) -> void: var parsed: Dictionary = _parse_network_snapshot(snapshot) if parsed.is_empty(): return @@ -1556,13 +1584,32 @@ func apply_local_prediction_correction(snapshot: Dictionary) -> void: 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, + ) + authoritative_position += ( + (parsed["velocity"] as Vector3) * transit_seconds + ) var error_distance: float = global_position.distance_to( authoritative_position ) - if error_distance > 2.0: + if error_distance > LOCAL_PREDICTION_SNAP_DISTANCE: global_position = authoritative_position - elif error_distance > 0.05: - global_position = global_position.lerp(authoritative_position, 0.18) + elif error_distance > LOCAL_PREDICTION_CORRECTION_THRESHOLD: + global_position = global_position.lerp( + authoritative_position, + LOCAL_PREDICTION_CORRECTION_WEIGHT, + ) func apply_network_teleport(snapshot: Dictionary) -> void: @@ -1760,7 +1807,10 @@ func _apply_network_casting(casting: bool) -> void: func _update_network_interpolation(delta: float) -> void: if not _network_snapshot_ready: return - _network_snapshot_age = minf(_network_snapshot_age + delta, 0.1) + _network_snapshot_age = minf( + _network_snapshot_age + delta, + NETWORK_EXTRAPOLATION_LIMIT_SECONDS, + ) var predicted_position: Vector3 = ( _network_target_position + _network_target_velocity * _network_snapshot_age diff --git a/tests/art_tools_validation.gd b/tests/art_tools_validation.gd index 14e19e7..407711d 100644 --- a/tests/art_tools_validation.gd +++ b/tests/art_tools_validation.gd @@ -220,7 +220,27 @@ func _run() -> void: assert(QuickRadialMenu.ACTIONS.has(&"hud")) game_ui.call("_on_quick_action_selected", &"freecam") assert(player.is_free_camera_active()) - assert(player.get_active_gameplay_camera().name == &"FreeCamera") + var free_camera := player.get_active_gameplay_camera() + assert(free_camera.name == &"FreeCamera") + var free_camera_yaw_before: float = free_camera.global_rotation.y + var mapping_manager := main.get( + "_controller_mapping_manager" + ) as ControllerMappingManager + var right_stick_motion := InputEventJoypadMotion.new() + right_stick_motion.device = mapping_manager.get_active_device_id() + right_stick_motion.axis = JOY_AXIS_RIGHT_X + right_stick_motion.axis_value = 0.9 + Input.parse_input_event(right_stick_motion) + await create_timer(0.1).timeout + assert( + absf(angle_difference( + free_camera.global_rotation.y, + free_camera_yaw_before, + )) > 0.02 + ) + right_stick_motion.axis_value = 0.0 + Input.parse_input_event(right_stick_motion) + await process_frame game_ui.call("_on_quick_action_selected", &"freecam") assert(not player.is_free_camera_active()) game_ui.call("_on_quick_action_selected", &"hud") diff --git a/tests/movement_multiplayer_validation.gd b/tests/movement_multiplayer_validation.gd index cb1d4eb..6926bf0 100644 --- a/tests/movement_multiplayer_validation.gd +++ b/tests/movement_multiplayer_validation.gd @@ -1,6 +1,7 @@ extends SceneTree const MainScene: PackedScene = preload("res://main/main.tscn") +const PlayerScene: PackedScene = preload("res://player/player.tscn") const TEST_PORT: int = 18141 @@ -9,7 +10,12 @@ func _initialize() -> void: func _run() -> void: + await _validate_latency_smoothing() var arguments: PackedStringArray = OS.get_cmdline_user_args() + if arguments.has("unit"): + print("Movement latency smoothing validation: PASS") + quit() + return if arguments.has("host"): await _run_host() return @@ -20,6 +26,67 @@ func _run() -> void: quit(1) +func _validate_latency_smoothing() -> void: + var avatar := PlayerScene.instantiate() as Player + root.add_child(avatar) + await process_frame + avatar.set_process(false) + avatar.set_physics_process(false) + avatar.configure_network_remote(false) + var moving_snapshot: Dictionary = _network_snapshot( + Vector3.ZERO, + Vector3(4.5, 0.0, 0.0), + 1, + ) + assert(NetworkSession.MOVEMENT_SNAPSHOT_BATCH_SIZE == 8) + var encoded_snapshots: Array = [] + for _peer: int in NetworkSession.MOVEMENT_SNAPSHOT_BATCH_SIZE: + encoded_snapshots.append( + NetworkSession._encode_movement_snapshot(moving_snapshot) + ) + assert(var_to_bytes(encoded_snapshots).size() < 1200) + assert( + NetworkSession._decode_movement_snapshot( + 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) + + avatar.configure_network_remote(false) + avatar.global_position = Vector3(1.35, 0.0, 0.0) + avatar.apply_local_prediction_correction( + moving_snapshot, + 10, + 1.0 / 30.0, + ) + assert(avatar.global_position.x > 1.25) + avatar.queue_free() + await process_frame + + +func _network_snapshot( + position: Vector3, + velocity: Vector3, + acknowledged_input: int, +) -> Dictionary: + return { + "peer_id": 2, + "acknowledged_input": acknowledged_input, + "position": [position.x, position.y, position.z], + "velocity": [velocity.x, velocity.y, velocity.z], + "visual_yaw": 0.0, + "animation_state": NetworkPlayerAnimationProtocol.make_state( + NetworkPlayerAnimationProtocol.LOCOMOTION_RUNNING, + true, + ), + "sitting": false, + "casting": false, + } + + func _run_host() -> void: var main: Node = await _create_initialized_main() var session := main.get_node("%NetworkSession") as NetworkSession