stabilize movement reconciliation states
This commit is contained in:
parent
1ab2a291d2
commit
135fd9110f
2 changed files with 156 additions and 9 deletions
|
|
@ -1036,6 +1036,8 @@ func _simulate_movement_physics(delta: float) -> void:
|
||||||
or (_network_authoritative_simulation and _network_jump_pending)
|
or (_network_authoritative_simulation and _network_jump_pending)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if jump_requested:
|
||||||
|
_cancel_sitting_for_jump()
|
||||||
# Sitting deliberately stops movement processing, so never allow a stale
|
# Sitting deliberately stops movement processing, so never allow a stale
|
||||||
# grounded flag or a reconciled network state to leave an airborne avatar in
|
# grounded flag or a reconciled network state to leave an airborne avatar in
|
||||||
# that early-return path. Preserve the intent and sit once landing is real.
|
# that early-return path. Preserve the intent and sit once landing is real.
|
||||||
|
|
@ -1635,6 +1637,7 @@ func _complete_retract_animation() -> void:
|
||||||
func toggle_sitting() -> void:
|
func toggle_sitting() -> void:
|
||||||
if _sit_after_landing:
|
if _sit_after_landing:
|
||||||
_sit_after_landing = false
|
_sit_after_landing = false
|
||||||
|
_set_sitting(false, local_control_enabled)
|
||||||
return
|
return
|
||||||
var should_sit: bool = not _sitting
|
var should_sit: bool = not _sitting
|
||||||
if should_sit:
|
if should_sit:
|
||||||
|
|
@ -1667,12 +1670,12 @@ func _set_sitting(
|
||||||
should_sit: bool,
|
should_sit: bool,
|
||||||
is_local_intent: bool = false,
|
is_local_intent: bool = false,
|
||||||
) -> void:
|
) -> void:
|
||||||
if _sitting == should_sit:
|
|
||||||
return
|
|
||||||
_sitting = should_sit
|
|
||||||
if is_local_intent:
|
if is_local_intent:
|
||||||
_sitting_intent_pending = true
|
_sitting_intent_pending = true
|
||||||
_sitting_intent_sequence = -1
|
_sitting_intent_sequence = -1
|
||||||
|
if _sitting == should_sit:
|
||||||
|
return
|
||||||
|
_sitting = should_sit
|
||||||
if _sitting:
|
if _sitting:
|
||||||
velocity = Vector3.ZERO
|
velocity = Vector3.ZERO
|
||||||
_character_animation_name = &""
|
_character_animation_name = &""
|
||||||
|
|
@ -1687,11 +1690,23 @@ func _can_begin_sitting() -> bool:
|
||||||
return is_on_floor() and velocity.y <= 0.0
|
return is_on_floor() and velocity.y <= 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func _cancel_sitting_for_jump() -> void:
|
||||||
|
if not _sitting and not _sit_after_landing:
|
||||||
|
return
|
||||||
|
_sit_after_landing = false
|
||||||
|
_set_sitting(false, local_control_enabled)
|
||||||
|
|
||||||
|
|
||||||
func get_network_sitting_state() -> bool:
|
func get_network_sitting_state() -> bool:
|
||||||
return _sitting
|
return _sitting
|
||||||
|
|
||||||
|
|
||||||
func get_network_sitting_intent() -> bool:
|
func get_network_sitting_intent() -> bool:
|
||||||
|
# Jumping is an explicit cancellation of both a current sit and a deferred
|
||||||
|
# sit. Input capture can run before the matching physics frame, so suppress
|
||||||
|
# the stale seated bit as soon as the reliable jump intent is queued.
|
||||||
|
if _local_network_jump_intent_pending:
|
||||||
|
return false
|
||||||
return _sitting or _sit_after_landing
|
return _sitting or _sit_after_landing
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2352,6 +2367,21 @@ func apply_local_prediction_correction(
|
||||||
_local_prediction_hard_corrections += 1
|
_local_prediction_hard_corrections += 1
|
||||||
_reset_local_prediction_error()
|
_reset_local_prediction_error()
|
||||||
return
|
return
|
||||||
|
if _should_defer_local_prediction_correction(parsed):
|
||||||
|
# A host and its client do not reach the apex or landing on the same
|
||||||
|
# frame. Correcting that expected vertical disagreement while either
|
||||||
|
# side is airborne can pull the local body off narrow platforms.
|
||||||
|
_reset_local_prediction_error()
|
||||||
|
return
|
||||||
|
# Both simulations agree the player is standing. Let ordinary floor motion
|
||||||
|
# and gravity resolve the vertical component; a stale height sample must not
|
||||||
|
# press a locally controlled player down or turn a horizontal correction
|
||||||
|
# into an early floor collision.
|
||||||
|
error_offset.y = 0.0
|
||||||
|
error_distance = error_offset.length()
|
||||||
|
if error_distance <= LOCAL_PREDICTION_CORRECTION_THRESHOLD:
|
||||||
|
_reset_local_prediction_error()
|
||||||
|
return
|
||||||
var error_direction: Vector3 = error_offset.normalized()
|
var error_direction: Vector3 = error_offset.normalized()
|
||||||
if (
|
if (
|
||||||
not _local_prediction_error_direction.is_zero_approx()
|
not _local_prediction_error_direction.is_zero_approx()
|
||||||
|
|
@ -2386,22 +2416,34 @@ func apply_local_prediction_correction(
|
||||||
correction.normalized()
|
correction.normalized()
|
||||||
* LOCAL_PREDICTION_MAX_SOFT_CORRECTION_STEP
|
* LOCAL_PREDICTION_MAX_SOFT_CORRECTION_STEP
|
||||||
)
|
)
|
||||||
_apply_camera_safe_local_correction(correction)
|
if _apply_camera_safe_local_correction(correction):
|
||||||
_local_prediction_soft_corrections += 1
|
_local_prediction_soft_corrections += 1
|
||||||
|
|
||||||
|
|
||||||
func _apply_camera_safe_local_correction(correction: Vector3) -> void:
|
func _should_defer_local_prediction_correction(parsed: Dictionary) -> bool:
|
||||||
|
if not bool(parsed.get("grounded", true)):
|
||||||
|
return true
|
||||||
|
return _local_network_jump_intent_pending or not is_on_floor()
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_camera_safe_local_correction(correction: Vector3) -> bool:
|
||||||
if correction.is_zero_approx():
|
if correction.is_zero_approx():
|
||||||
return
|
return false
|
||||||
var previous_visual_position: Vector3 = _visuals.global_position
|
var previous_visual_position: Vector3 = _visuals.global_position
|
||||||
var previous_camera_position: Vector3 = _camera_yaw.global_position
|
var previous_camera_position: Vector3 = _camera_yaw.global_position
|
||||||
|
var previous_body_position: Vector3 = global_position
|
||||||
var base_visual_local_position: Vector3 = (
|
var base_visual_local_position: Vector3 = (
|
||||||
_visuals.position - _local_reconciliation_visual_offset
|
_visuals.position - _local_reconciliation_visual_offset
|
||||||
)
|
)
|
||||||
var base_camera_local_position: Vector3 = (
|
var base_camera_local_position: Vector3 = (
|
||||||
_camera_yaw.position - _local_reconciliation_camera_offset
|
_camera_yaw.position - _local_reconciliation_camera_offset
|
||||||
)
|
)
|
||||||
global_position += correction
|
# Reconciliation is still physical movement. Going through the physics
|
||||||
|
# server prevents a delayed host audit from placing the capsule through a
|
||||||
|
# tree, cliff, or stepping stone on the way to the authoritative position.
|
||||||
|
move_and_collide(correction)
|
||||||
|
if global_position.is_equal_approx(previous_body_position):
|
||||||
|
return false
|
||||||
_visuals.global_position = previous_visual_position
|
_visuals.global_position = previous_visual_position
|
||||||
_camera_yaw.global_position = previous_camera_position
|
_camera_yaw.global_position = previous_camera_position
|
||||||
_local_reconciliation_visual_offset = (
|
_local_reconciliation_visual_offset = (
|
||||||
|
|
@ -2410,6 +2452,7 @@ func _apply_camera_safe_local_correction(correction: Vector3) -> void:
|
||||||
_local_reconciliation_camera_offset = (
|
_local_reconciliation_camera_offset = (
|
||||||
_camera_yaw.position - base_camera_local_position
|
_camera_yaw.position - base_camera_local_position
|
||||||
)
|
)
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
func _reset_local_prediction_error() -> void:
|
func _reset_local_prediction_error() -> void:
|
||||||
|
|
@ -2625,6 +2668,9 @@ func _parse_network_snapshot(snapshot: Dictionary) -> Dictionary:
|
||||||
if snapshot.has("casting") and typeof(snapshot.get("casting")) != TYPE_BOOL:
|
if snapshot.has("casting") and typeof(snapshot.get("casting")) != TYPE_BOOL:
|
||||||
return {}
|
return {}
|
||||||
var casting: bool = bool(snapshot.get("casting", false))
|
var casting: bool = bool(snapshot.get("casting", false))
|
||||||
|
if snapshot.has("grounded") and typeof(snapshot.get("grounded")) != TYPE_BOOL:
|
||||||
|
return {}
|
||||||
|
var grounded: bool = bool(snapshot.get("grounded", true))
|
||||||
if (
|
if (
|
||||||
not parsed_position.is_finite()
|
not parsed_position.is_finite()
|
||||||
or not parsed_velocity.is_finite()
|
or not parsed_velocity.is_finite()
|
||||||
|
|
@ -2636,6 +2682,7 @@ func _parse_network_snapshot(snapshot: Dictionary) -> Dictionary:
|
||||||
"velocity": parsed_velocity,
|
"velocity": parsed_velocity,
|
||||||
"visual_yaw": visual_yaw,
|
"visual_yaw": visual_yaw,
|
||||||
"animation_state": animation_state,
|
"animation_state": animation_state,
|
||||||
|
"grounded": grounded,
|
||||||
"acknowledged_input": acknowledged_input,
|
"acknowledged_input": acknowledged_input,
|
||||||
"sitting": sitting,
|
"sitting": sitting,
|
||||||
"casting": casting,
|
"casting": casting,
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ func _validate_latency_smoothing() -> void:
|
||||||
_validate_compact_animation_encoding()
|
_validate_compact_animation_encoding()
|
||||||
_validate_animation_action_ordering(avatar)
|
_validate_animation_action_ordering(avatar)
|
||||||
_validate_transit_estimation()
|
_validate_transit_estimation()
|
||||||
_validate_remote_snapshot_smoothing(avatar)
|
await _validate_remote_snapshot_smoothing(avatar)
|
||||||
_validate_remote_locomotion_playback_recovery(avatar)
|
_validate_remote_locomotion_playback_recovery(avatar)
|
||||||
_validate_reliable_jump_intent(avatar)
|
_validate_reliable_jump_intent(avatar)
|
||||||
_validate_airborne_sitting(avatar)
|
_validate_airborne_sitting(avatar)
|
||||||
|
|
@ -145,11 +145,20 @@ func _validate_compact_animation_encoding() -> void:
|
||||||
|
|
||||||
func _validate_airborne_sitting(avatar: Player) -> void:
|
func _validate_airborne_sitting(avatar: Player) -> void:
|
||||||
avatar.reset_network_movement_state()
|
avatar.reset_network_movement_state()
|
||||||
|
avatar.set_local_control(true)
|
||||||
avatar.set("_sit_after_landing", true)
|
avatar.set("_sit_after_landing", true)
|
||||||
assert(avatar.get_network_sitting_intent())
|
assert(avatar.get_network_sitting_intent())
|
||||||
assert(not avatar.get_network_sitting_state())
|
assert(not avatar.get_network_sitting_state())
|
||||||
assert(not bool(avatar.make_network_snapshot(2)["sitting"]))
|
assert(not bool(avatar.make_network_snapshot(2)["sitting"]))
|
||||||
assert(bool(avatar.capture_network_input(1)["sitting"]))
|
assert(bool(avatar.capture_network_input(1)["sitting"]))
|
||||||
|
avatar.call("_queue_local_network_jump_intent")
|
||||||
|
var jumping_input: Dictionary = avatar.capture_network_input(2)
|
||||||
|
assert(bool(jumping_input["jump"]))
|
||||||
|
assert(not bool(jumping_input["sitting"]))
|
||||||
|
avatar.call("_cancel_sitting_for_jump")
|
||||||
|
assert(not bool(avatar.get("_sit_after_landing")))
|
||||||
|
assert(bool(avatar.get("_sitting_intent_pending")))
|
||||||
|
avatar.reset_network_movement_state()
|
||||||
|
|
||||||
# Even a malformed or stale reconciliation that marks an airborne avatar as
|
# Even a malformed or stale reconciliation that marks an airborne avatar as
|
||||||
# seated must not bypass gravity and freeze it in place.
|
# seated must not bypass gravity and freeze it in place.
|
||||||
|
|
@ -304,8 +313,24 @@ func _validate_remote_snapshot_smoothing(avatar: Player) -> void:
|
||||||
)
|
)
|
||||||
assert(avatar.global_position.x <= maximum_extrapolated_x + 0.1)
|
assert(avatar.global_position.x <= maximum_extrapolated_x + 0.1)
|
||||||
|
|
||||||
|
# Reconciliation only applies after both simulations report a grounded body.
|
||||||
|
# Give the unit avatar a real floor so this exercises the production path.
|
||||||
|
var floor := StaticBody3D.new()
|
||||||
|
floor.collision_layer = 1
|
||||||
|
floor.collision_mask = 0
|
||||||
|
floor.position = Vector3(0.0, -0.1, 0.0)
|
||||||
|
var floor_shape := CollisionShape3D.new()
|
||||||
|
var floor_box := BoxShape3D.new()
|
||||||
|
floor_box.size = Vector3(100.0, 0.2, 100.0)
|
||||||
|
floor_shape.shape = floor_box
|
||||||
|
floor.add_child(floor_shape)
|
||||||
|
root.add_child(floor)
|
||||||
|
await physics_frame
|
||||||
avatar.set_local_control(true)
|
avatar.set_local_control(true)
|
||||||
avatar.global_position = Vector3(0.8, 0.0, 0.0)
|
avatar.global_position = Vector3(0.8, 0.0, 0.0)
|
||||||
|
avatar.velocity = Vector3.DOWN
|
||||||
|
avatar.move_and_slide()
|
||||||
|
assert(avatar.is_on_floor())
|
||||||
avatar.apply_local_prediction_correction(
|
avatar.apply_local_prediction_correction(
|
||||||
moving_snapshot,
|
moving_snapshot,
|
||||||
2,
|
2,
|
||||||
|
|
@ -358,6 +383,42 @@ func _validate_remote_snapshot_smoothing(avatar: Player) -> void:
|
||||||
int(avatar.get("_local_prediction_soft_corrections")) == 1
|
int(avatar.get("_local_prediction_soft_corrections")) == 1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Host and client jump timing naturally differs. An airborne authoritative
|
||||||
|
# snapshot must not drag the local capsule sideways or down before landing.
|
||||||
|
avatar.global_position = Vector3(3.0, 0.0, 0.0)
|
||||||
|
avatar.velocity = Vector3(0.0, 1.0, 0.0)
|
||||||
|
var airborne_snapshot: Dictionary = drift_snapshot.duplicate(true)
|
||||||
|
airborne_snapshot["grounded"] = false
|
||||||
|
for _audit: int in 6:
|
||||||
|
avatar.apply_local_prediction_correction(
|
||||||
|
airborne_snapshot,
|
||||||
|
20,
|
||||||
|
1.0 / 30.0,
|
||||||
|
0.0,
|
||||||
|
0.1,
|
||||||
|
)
|
||||||
|
assert(avatar.global_position.is_equal_approx(Vector3(3.0, 0.0, 0.0)))
|
||||||
|
assert(int(avatar.get("_local_prediction_soft_corrections")) == 1)
|
||||||
|
avatar.velocity = Vector3.ZERO
|
||||||
|
|
||||||
|
# The local body may reach its apex after the host has already landed. The
|
||||||
|
# local airborne state still owns presentation and must not be pulled toward
|
||||||
|
# the host until it has landed too.
|
||||||
|
avatar.global_position = Vector3(3.0, 2.0, 0.0)
|
||||||
|
avatar.velocity = Vector3.ZERO
|
||||||
|
avatar.move_and_slide()
|
||||||
|
assert(not avatar.is_on_floor())
|
||||||
|
for _audit: int in 6:
|
||||||
|
avatar.apply_local_prediction_correction(
|
||||||
|
drift_snapshot,
|
||||||
|
20,
|
||||||
|
1.0 / 30.0,
|
||||||
|
0.0,
|
||||||
|
0.1,
|
||||||
|
)
|
||||||
|
assert(avatar.global_position.is_equal_approx(Vector3(3.0, 2.0, 0.0)))
|
||||||
|
assert(int(avatar.get("_local_prediction_soft_corrections")) == 1)
|
||||||
|
|
||||||
# Genuine divergence still recovers immediately, as do the separate reliable
|
# Genuine divergence still recovers immediately, as do the separate reliable
|
||||||
# teleport and water-recovery paths used by gameplay transitions.
|
# teleport and water-recovery paths used by gameplay transitions.
|
||||||
avatar.global_position = Vector3(10.0, 0.0, 0.0)
|
avatar.global_position = Vector3(10.0, 0.0, 0.0)
|
||||||
|
|
@ -373,6 +434,45 @@ func _validate_remote_snapshot_smoothing(avatar: Player) -> void:
|
||||||
int(avatar.get("_local_prediction_hard_corrections")) == 1
|
int(avatar.get("_local_prediction_hard_corrections")) == 1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Soft reconciliation must respect the same terrain collision as ordinary
|
||||||
|
# player movement instead of phasing the capsule through a solid obstacle.
|
||||||
|
var blocker := StaticBody3D.new()
|
||||||
|
blocker.collision_layer = 1
|
||||||
|
blocker.collision_mask = 0
|
||||||
|
blocker.position = Vector3(1.0, 1.0, 0.0)
|
||||||
|
var blocker_shape := CollisionShape3D.new()
|
||||||
|
var blocker_box := BoxShape3D.new()
|
||||||
|
blocker_box.size = Vector3(0.2, 4.0, 4.0)
|
||||||
|
blocker_shape.shape = blocker_box
|
||||||
|
blocker.add_child(blocker_shape)
|
||||||
|
root.add_child(blocker)
|
||||||
|
await physics_frame
|
||||||
|
avatar.global_position = Vector3.ZERO
|
||||||
|
avatar.velocity = Vector3.ZERO
|
||||||
|
avatar.call("_clear_local_reconciliation_offsets")
|
||||||
|
var blocked_visual_position: Vector3 = (
|
||||||
|
avatar.get_node("Visuals") as Node3D
|
||||||
|
).global_position
|
||||||
|
var blocked_camera_position: Vector3 = (
|
||||||
|
avatar.get_node("CameraYaw") as Node3D
|
||||||
|
).global_position
|
||||||
|
assert(bool(avatar.call(
|
||||||
|
"_apply_camera_safe_local_correction",
|
||||||
|
Vector3(2.0, 0.0, 0.0),
|
||||||
|
)))
|
||||||
|
assert(avatar.global_position.x > 0.0 and avatar.global_position.x < 0.6)
|
||||||
|
assert(
|
||||||
|
(avatar.get_node("Visuals") as Node3D).global_position
|
||||||
|
== blocked_visual_position
|
||||||
|
)
|
||||||
|
assert(
|
||||||
|
(avatar.get_node("CameraYaw") as Node3D).global_position
|
||||||
|
== blocked_camera_position
|
||||||
|
)
|
||||||
|
blocker.queue_free()
|
||||||
|
floor.queue_free()
|
||||||
|
await physics_frame
|
||||||
|
|
||||||
|
|
||||||
func _validate_remote_locomotion_playback_recovery(avatar: Player) -> void:
|
func _validate_remote_locomotion_playback_recovery(avatar: Player) -> void:
|
||||||
avatar.configure_network_remote(false)
|
avatar.configure_network_remote(false)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue