Add transactional RV decor synchronization
This commit is contained in:
parent
ee992d9f0c
commit
6578d3e870
7 changed files with 663 additions and 48 deletions
|
|
@ -47,6 +47,12 @@ func setup(
|
|||
_on_local_space_changed
|
||||
):
|
||||
_network_home.local_space_changed.connect(_on_local_space_changed)
|
||||
if not _network_home.local_manifest_rejected.is_connected(
|
||||
_on_local_manifest_rejected
|
||||
):
|
||||
_network_home.local_manifest_rejected.connect(
|
||||
_on_local_manifest_rejected
|
||||
)
|
||||
if not _home_state.changed.is_connected(_on_home_changed):
|
||||
_home_state.changed.connect(_on_home_changed)
|
||||
if not _toolbar.placement_mode_requested.is_connected(
|
||||
|
|
@ -84,7 +90,13 @@ func _input(event: InputEvent) -> void:
|
|||
and pointer_button.button_index == MOUSE_BUTTON_LEFT
|
||||
and not pointer_button.pressed
|
||||
):
|
||||
_finish_drag()
|
||||
# A gameplay menu can take ownership between the press and release. Do
|
||||
# not let that release finalize decor behind the menu; preserve the
|
||||
# original transform instead.
|
||||
if _can_decorate():
|
||||
_finish_drag()
|
||||
else:
|
||||
_cancel_drag()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
|
|
@ -120,14 +132,17 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||
func _process(delta: float) -> void:
|
||||
if _drag_instance_id.is_empty():
|
||||
return
|
||||
# Menus and other gameplay input handoffs must cancel an active drag before
|
||||
# evaluating a held/released pointer. This prevents a same-frame TAB press
|
||||
# followed by left-button release from committing a move behind the UI.
|
||||
if not _can_decorate():
|
||||
_cancel_drag()
|
||||
return
|
||||
if not Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT):
|
||||
_finish_drag()
|
||||
return
|
||||
if not _drag_target_valid:
|
||||
return
|
||||
if not _can_decorate():
|
||||
_cancel_drag()
|
||||
return
|
||||
var current: Dictionary = _home_world.get_decor_preview_transform(
|
||||
_drag_owner_fingerprint, _drag_instance_id
|
||||
)
|
||||
|
|
@ -146,6 +161,9 @@ func _process(delta: float) -> void:
|
|||
|
||||
|
||||
func _begin_drag(screen_position: Vector2) -> bool:
|
||||
if _network_home.has_pending_local_decor_mutation():
|
||||
_toolbar.report_status("waiting for the previous decor update")
|
||||
return false
|
||||
var owner_fingerprint: String = (
|
||||
_network_home.get_local_home_owner_fingerprint()
|
||||
)
|
||||
|
|
@ -331,22 +349,34 @@ static func _placement_position(placement: Dictionary) -> Vector3:
|
|||
|
||||
|
||||
func _place_carried_decor(decor_id: StringName) -> void:
|
||||
if _network_home.has_pending_local_decor_mutation():
|
||||
_toolbar.report_status("waiting for the previous decor update")
|
||||
return
|
||||
var owner_fingerprint: String = (
|
||||
_network_home.get_local_home_owner_fingerprint()
|
||||
)
|
||||
var forward: Vector3 = -_player.global_basis.z
|
||||
forward.y = 0.0
|
||||
forward = forward.normalized()
|
||||
# RV transitions keep the gameplay root axis-aligned so its transform can be
|
||||
# synchronized independently from the presentation. Use the visual facing
|
||||
# direction instead; otherwise carried decor is always placed along world -Z
|
||||
# instead of in front of the character the player sees.
|
||||
var forward: Vector3 = _player.get_facing_direction()
|
||||
var global_position: Vector3 = _player.global_position + forward * 1.45
|
||||
var local_position: Vector3 = _home_world.interior_global_to_local(
|
||||
owner_fingerprint, global_position
|
||||
)
|
||||
var placement_yaw: float = atan2(-forward.x, -forward.z)
|
||||
var instance_id: String = _network_home.place_local_decor(
|
||||
decor_id, local_position, _player.global_rotation.y, _mode
|
||||
decor_id, local_position, placement_yaw, _mode
|
||||
)
|
||||
if instance_id.is_empty():
|
||||
_toolbar.report_status("make room in front of you to place this decor")
|
||||
return
|
||||
# A local host can reject its own optimistic manifest synchronously. Do not
|
||||
# claim success after that rollback or leave the player looking for decor that
|
||||
# was never accepted into the room.
|
||||
if _home_state.get_placement(instance_id).is_empty():
|
||||
_toolbar.report_status("decor placement was not accepted")
|
||||
return
|
||||
_select_instance(instance_id)
|
||||
_toolbar.report_status("decor placed")
|
||||
|
||||
|
|
@ -366,6 +396,9 @@ func _select_instance(instance_id: String) -> void:
|
|||
func _rotate_selected() -> void:
|
||||
if not _can_decorate() or _selected_instance_id.is_empty():
|
||||
return
|
||||
if _network_home.has_pending_local_decor_mutation():
|
||||
_toolbar.report_status("waiting for the previous decor update")
|
||||
return
|
||||
var placement: Dictionary = _home_state.get_placement(
|
||||
_selected_instance_id
|
||||
)
|
||||
|
|
@ -392,6 +425,9 @@ func _rotate_selected() -> void:
|
|||
func _store_selected() -> void:
|
||||
if _selected_instance_id.is_empty():
|
||||
return
|
||||
if _network_home.has_pending_local_decor_mutation():
|
||||
_toolbar.report_status("waiting for the previous decor update")
|
||||
return
|
||||
if _network_home.remove_local_decor(_selected_instance_id):
|
||||
_clear_selection()
|
||||
_toolbar.report_status("decor packed into storage")
|
||||
|
|
@ -418,6 +454,12 @@ func _on_local_space_changed(
|
|||
_refresh_toolbar()
|
||||
|
||||
|
||||
func _on_local_manifest_rejected(message: String) -> void:
|
||||
_clear_selection()
|
||||
if _toolbar != null and _toolbar.visible:
|
||||
_toolbar.report_status(message)
|
||||
|
||||
|
||||
func _on_home_changed() -> void:
|
||||
if (
|
||||
not _selected_instance_id.is_empty()
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const EXTERIOR_INTERACTION_DISTANCE: float = 2.7
|
|||
const INTERIOR_EXIT_DISTANCE: float = 2.1
|
||||
const INTERIOR_ORIGIN := Vector3(1000.0, 300.0, 1000.0)
|
||||
const INTERIOR_SPACING: float = 14.0
|
||||
const INTERIOR_MAX_SPAWN_SLOTS: int = 64
|
||||
const INTERIOR_FLOOR_SIZE := Vector3(8.0, 0.18, 4.5)
|
||||
const INTERIOR_WALL_HEIGHT: float = 2.7
|
||||
const MIN_EXTERIOR_TRIANGLE_AREA: float = 1.0
|
||||
|
|
@ -461,13 +462,17 @@ func get_interior_spawn_transform(
|
|||
visitor_offset: int = 0,
|
||||
) -> Transform3D:
|
||||
var anchor: Vector3 = get_interior_anchor(owner_fingerprint)
|
||||
var result := Transform3D(Basis.IDENTITY, anchor)
|
||||
result.origin += Vector3(
|
||||
-1.0 + float(posmod(visitor_offset, 4)) * 0.65,
|
||||
# `visitor_offset` is allocated by the host as a compact room-local slot.
|
||||
# Keep it bounded as a last line of defense if a future caller accidentally
|
||||
# supplies an engine peer ID instead of a slot.
|
||||
var visitor_slot: int = posmod(visitor_offset, INTERIOR_MAX_SPAWN_SLOTS)
|
||||
# Build the origin as a value rather than mutating Transform3D.origin.
|
||||
var spawn_origin: Vector3 = anchor + Vector3(
|
||||
-1.0 + float(posmod(visitor_slot, 4)) * 0.65,
|
||||
0.25,
|
||||
0.4 + float(floori(float(visitor_offset) / 4.0)) * 0.55,
|
||||
0.4 + float(floori(float(visitor_slot) / 4.0)) * 0.55,
|
||||
)
|
||||
return result
|
||||
return Transform3D(Basis.IDENTITY, spawn_origin)
|
||||
|
||||
|
||||
func get_interior_anchor(owner_fingerprint: String) -> Vector3:
|
||||
|
|
|
|||
31
main/main.gd
31
main/main.gd
|
|
@ -1731,12 +1731,14 @@ func _process(delta: float) -> void:
|
|||
func _apply_runtime_settings(settings: PlayerSettingsType) -> void:
|
||||
if settings == null:
|
||||
return
|
||||
var requested_window_mode: DisplayServer.WindowMode = (
|
||||
DisplayServer.WINDOW_MODE_FULLSCREEN
|
||||
if settings.fullscreen_enabled
|
||||
else DisplayServer.WINDOW_MODE_WINDOWED
|
||||
var current_window_mode: DisplayServer.WindowMode = (
|
||||
DisplayServer.window_get_mode()
|
||||
)
|
||||
if DisplayServer.window_get_mode() != requested_window_mode:
|
||||
var requested_window_mode: DisplayServer.WindowMode = resolve_window_mode(
|
||||
settings.fullscreen_enabled,
|
||||
current_window_mode,
|
||||
)
|
||||
if current_window_mode != requested_window_mode:
|
||||
DisplayServer.window_set_mode(requested_window_mode)
|
||||
_apply_world_pixelation(settings.world_pixel_size)
|
||||
_ui_pixelation.set_pixel_size(settings.ui_pixel_size)
|
||||
|
|
@ -1763,6 +1765,25 @@ func _apply_runtime_settings(settings: PlayerSettingsType) -> void:
|
|||
)
|
||||
|
||||
|
||||
static func resolve_window_mode(
|
||||
fullscreen_enabled: bool,
|
||||
current_mode: DisplayServer.WindowMode,
|
||||
) -> DisplayServer.WindowMode:
|
||||
var is_fullscreen: bool = current_mode in [
|
||||
DisplayServer.WINDOW_MODE_FULLSCREEN,
|
||||
DisplayServer.WINDOW_MODE_EXCLUSIVE_FULLSCREEN,
|
||||
]
|
||||
if fullscreen_enabled:
|
||||
return (
|
||||
current_mode
|
||||
if is_fullscreen
|
||||
else DisplayServer.WINDOW_MODE_FULLSCREEN
|
||||
)
|
||||
if is_fullscreen:
|
||||
return DisplayServer.WINDOW_MODE_WINDOWED
|
||||
return current_mode
|
||||
|
||||
|
||||
func _apply_world_pixelation(pixel_size: int) -> void:
|
||||
var root_viewport: Viewport = get_viewport()
|
||||
root_viewport.scaling_3d_mode = Viewport.SCALING_3D_MODE_NEAREST
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const CAPABILITY: StringName = &"portable_home_v1"
|
|||
const RELIABLE_CHANNEL: int = NetworkProtocol.HOME_RELIABLE_CHANNEL
|
||||
const MAX_ID_LENGTH: int = 96
|
||||
const MAX_MESSAGE_LENGTH: int = 160
|
||||
const MAX_TRANSITION_COORDINATE: float = 10000.0
|
||||
const WORLD_SPACE: StringName = &"world"
|
||||
|
||||
enum TransitionAction {
|
||||
|
|
@ -143,7 +144,7 @@ static func transform_to_array(value: Transform3D) -> Array[float]:
|
|||
|
||||
|
||||
static func array_to_transform(value: Variant) -> Transform3D:
|
||||
if not _valid_transform(value):
|
||||
if not validate_transition_transform(value):
|
||||
return Transform3D()
|
||||
var fields: Array = value
|
||||
return Transform3D(
|
||||
|
|
@ -156,6 +157,17 @@ static func array_to_transform(value: Variant) -> Transform3D:
|
|||
)
|
||||
|
||||
|
||||
static func validate_transition_transform(value: Variant) -> bool:
|
||||
if not _valid_transform(value):
|
||||
return false
|
||||
var fields: Array = value
|
||||
return (
|
||||
absf(float(fields[9])) <= MAX_TRANSITION_COORDINATE
|
||||
and absf(float(fields[10])) <= MAX_TRANSITION_COORDINATE
|
||||
and absf(float(fields[11])) <= MAX_TRANSITION_COORDINATE
|
||||
)
|
||||
|
||||
|
||||
static func _valid_transform(value: Variant) -> bool:
|
||||
if typeof(value) != TYPE_ARRAY or (value as Array).size() != 12:
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -5,12 +5,15 @@ const NetworkHomeProtocolType = preload(
|
|||
"res://network/network_home_protocol.gd"
|
||||
)
|
||||
const PlayerHomeStateType = preload("res://homes/player_home_state.gd")
|
||||
const DecorCatalogType = preload("res://homes/decor_catalog.gd")
|
||||
const OwnedItemType = preload("res://items/owned_item.gd")
|
||||
|
||||
signal local_transition_finished(accepted: bool, message: String)
|
||||
signal local_space_changed(space_id: StringName, owner_fingerprint: String)
|
||||
signal local_manifest_rejected(message: String)
|
||||
|
||||
const MAX_MANIFESTS: int = 128
|
||||
const MAX_PENDING_LOCAL_DECOR_TRANSACTIONS: int = 16
|
||||
|
||||
var _session: NetworkSession
|
||||
var _spawn_service: PlayerSpawnService
|
||||
|
|
@ -24,9 +27,12 @@ var _manifests_by_fingerprint: Dictionary[String, Dictionary] = {}
|
|||
var _fingerprint_by_peer: Dictionary[int, String] = {}
|
||||
var _authorized_owned_counts: Dictionary[int, Dictionary] = {}
|
||||
var _authorization_result_owner: Dictionary[String, int] = {}
|
||||
var _interior_slots_by_space: Dictionary[StringName, Dictionary] = {}
|
||||
var _next_interior_index: int = 0
|
||||
var _suppress_local_submission: bool = false
|
||||
var _publish_queued: bool = false
|
||||
var _last_submitted_local_home_revision: int = -1
|
||||
var _pending_local_decor_transactions: Dictionary[int, Dictionary] = {}
|
||||
|
||||
|
||||
func setup(
|
||||
|
|
@ -77,6 +83,10 @@ func can_local_decorate() -> bool:
|
|||
)
|
||||
|
||||
|
||||
func has_pending_local_decor_mutation() -> bool:
|
||||
return not _pending_local_decor_transactions.is_empty()
|
||||
|
||||
|
||||
func get_authoritative_owned_count(
|
||||
peer_id: int,
|
||||
decor_id: StringName,
|
||||
|
|
@ -101,6 +111,7 @@ func place_local_decor(
|
|||
) -> String:
|
||||
if (
|
||||
not can_local_decorate()
|
||||
or has_pending_local_decor_mutation()
|
||||
or _bag == null
|
||||
or _bag.get_quantity(decor_id) <= 0
|
||||
or _world_service == null
|
||||
|
|
@ -123,6 +134,10 @@ func place_local_decor(
|
|||
)
|
||||
if normalized.is_empty():
|
||||
return ""
|
||||
var transaction: Dictionary = _capture_local_decor_transaction()
|
||||
if transaction.is_empty():
|
||||
return ""
|
||||
_suppress_local_submission = true
|
||||
var instance_id: String = _local_home.place_decor(
|
||||
decor_id,
|
||||
normalized["position"],
|
||||
|
|
@ -130,17 +145,22 @@ func place_local_decor(
|
|||
mode,
|
||||
)
|
||||
if instance_id.is_empty():
|
||||
_suppress_local_submission = false
|
||||
return ""
|
||||
if not _bag.remove_item(decor_id, 1):
|
||||
_local_home.remove_placement(instance_id)
|
||||
_suppress_local_submission = false
|
||||
return ""
|
||||
_suppress_local_submission = false
|
||||
if not _commit_local_decor_transaction(transaction):
|
||||
return ""
|
||||
_save_manager.save_if_dirty()
|
||||
return instance_id
|
||||
|
||||
|
||||
func remove_local_decor(instance_id: String) -> bool:
|
||||
if (
|
||||
not can_local_decorate()
|
||||
or has_pending_local_decor_mutation()
|
||||
or _bag == null
|
||||
or _inventory_layout == null
|
||||
or _hotbar == null
|
||||
|
|
@ -149,6 +169,9 @@ func remove_local_decor(instance_id: String) -> bool:
|
|||
var placement: Dictionary = _local_home.get_placement(instance_id)
|
||||
if placement.is_empty():
|
||||
return false
|
||||
var transaction: Dictionary = _capture_local_decor_transaction()
|
||||
if transaction.is_empty():
|
||||
return false
|
||||
var decor_id := StringName(str(placement["decor_id"]))
|
||||
var target_hotbar_slot: int = _hotbar.find_item_slot(decor_id)
|
||||
if target_hotbar_slot < 0:
|
||||
|
|
@ -174,14 +197,20 @@ func remove_local_decor(instance_id: String) -> bool:
|
|||
_inventory_layout.cancel_prepared_item_placement(decor_id)
|
||||
returned_to_inventory = _bag.add_item_to_storage(decor_id, 1)
|
||||
if not returned_to_inventory:
|
||||
if prepared_hotbar_placement:
|
||||
_inventory_layout.cancel_prepared_item_placement(decor_id)
|
||||
return false
|
||||
if target_hotbar_slot >= 0:
|
||||
_hotbar.assign_item(target_hotbar_slot, decor_id)
|
||||
_hotbar.select_slot(target_hotbar_slot)
|
||||
_suppress_local_submission = true
|
||||
if not _local_home.remove_placement(instance_id):
|
||||
_bag.remove_item(decor_id, 1)
|
||||
_suppress_local_submission = false
|
||||
_restore_local_decor_transaction(transaction)
|
||||
return false
|
||||
_suppress_local_submission = false
|
||||
if not _commit_local_decor_transaction(transaction):
|
||||
return false
|
||||
_save_manager.save_if_dirty()
|
||||
return true
|
||||
|
||||
|
||||
|
|
@ -191,19 +220,28 @@ func update_local_decor(
|
|||
yaw_radians: float,
|
||||
mode: PlayerHomeStateType.PlacementMode,
|
||||
) -> bool:
|
||||
if has_pending_local_decor_mutation():
|
||||
return false
|
||||
var normalized: Dictionary = get_valid_local_decor_transform(
|
||||
instance_id, position, yaw_radians, mode
|
||||
)
|
||||
if normalized.is_empty():
|
||||
return false
|
||||
var transaction: Dictionary = _capture_local_decor_transaction()
|
||||
if transaction.is_empty():
|
||||
return false
|
||||
_suppress_local_submission = true
|
||||
if not _local_home.update_placement(
|
||||
instance_id,
|
||||
normalized["position"],
|
||||
float(normalized["yaw"]),
|
||||
mode,
|
||||
):
|
||||
_suppress_local_submission = false
|
||||
return false
|
||||
_suppress_local_submission = false
|
||||
if not _commit_local_decor_transaction(transaction):
|
||||
return false
|
||||
_save_manager.save_if_dirty()
|
||||
return true
|
||||
|
||||
|
||||
|
|
@ -337,7 +375,9 @@ func refresh_world_presentations() -> void:
|
|||
)
|
||||
|
||||
|
||||
func publish_local_home() -> bool:
|
||||
func publish_local_home(
|
||||
allow_pending_decor_transaction: bool = false,
|
||||
) -> bool:
|
||||
if (
|
||||
_session == null
|
||||
or not _session.is_gameplay_session_active()
|
||||
|
|
@ -345,11 +385,24 @@ func publish_local_home() -> bool:
|
|||
or _local_home == null
|
||||
):
|
||||
return false
|
||||
# A purchase can update the owned decor count while an optimistic placement
|
||||
# is waiting on the host. Hold that follow-up manifest until the placement is
|
||||
# accepted or rolled back; otherwise it can carry the still-unverified
|
||||
# placement and turn one rejection into a second stale rejection.
|
||||
if (
|
||||
not allow_pending_decor_transaction
|
||||
and has_pending_local_decor_mutation()
|
||||
):
|
||||
_publish_queued = true
|
||||
return true
|
||||
if _session.is_host() and (
|
||||
_world_service == null or not _world_service.is_world_ready()
|
||||
):
|
||||
return false
|
||||
var home_data: Dictionary = _local_home.to_save_data()
|
||||
var revision: int = int(home_data.get("revision", -1))
|
||||
if revision >= 0 and revision == _last_submitted_local_home_revision:
|
||||
return true
|
||||
var request: Dictionary = {
|
||||
"request_id": _new_id("home"),
|
||||
"session_id": _session.get_session_id(),
|
||||
|
|
@ -361,6 +414,7 @@ func publish_local_home() -> bool:
|
|||
"portable_home_manifest",
|
||||
NetworkHomeProtocolType.manifest_signature_fields(request),
|
||||
)
|
||||
_last_submitted_local_home_revision = revision
|
||||
if _session.is_host():
|
||||
_process_manifest_request(_session.get_local_peer_id(), request)
|
||||
else:
|
||||
|
|
@ -445,6 +499,15 @@ func _process_manifest_request(peer_id: int, data: Dictionary) -> void:
|
|||
_manifests_by_fingerprint[fingerprint] = manifest
|
||||
_fingerprint_by_peer[peer_id] = fingerprint
|
||||
_consume_owned_authorizations(peer_id, home_data)
|
||||
if peer_id == _session.get_local_peer_id():
|
||||
_discard_local_decor_transactions_through(
|
||||
int(home_data.get("revision", -1))
|
||||
)
|
||||
if _last_submitted_local_home_revision == int(
|
||||
home_data.get("revision", -1)
|
||||
):
|
||||
_last_submitted_local_home_revision = -1
|
||||
_flush_queued_local_home_publish()
|
||||
_broadcast_manifest(manifest)
|
||||
if is_first_manifest:
|
||||
_spawn_peer_at_exterior(peer_id, fingerprint)
|
||||
|
|
@ -588,6 +651,16 @@ func receive_home_manifest(data: Dictionary) -> void:
|
|||
var fingerprint: String = str(data["owner_fingerprint"])
|
||||
_manifests_by_fingerprint[fingerprint] = data.duplicate(true)
|
||||
_fingerprint_by_peer[int(data["owner_peer_id"])] = fingerprint
|
||||
if fingerprint == _session.get_local_identity_fingerprint():
|
||||
var home: Dictionary = data.get("home", {})
|
||||
_discard_local_decor_transactions_through(
|
||||
int(home.get("revision", -1))
|
||||
)
|
||||
if _last_submitted_local_home_revision == int(
|
||||
home.get("revision", -1)
|
||||
):
|
||||
_last_submitted_local_home_revision = -1
|
||||
_flush_queued_local_home_publish()
|
||||
if _world_service != null and _world_service.is_world_ready():
|
||||
_world_service.apply_manifest(data)
|
||||
|
||||
|
|
@ -613,20 +686,304 @@ func receive_home_manifest_rejection(
|
|||
|
||||
|
||||
func _apply_manifest_rejection(message: String, canonical: Dictionary) -> void:
|
||||
if (
|
||||
canonical.is_empty()
|
||||
or not NetworkHomeProtocolType.validate_manifest_broadcast(canonical)
|
||||
):
|
||||
_last_submitted_local_home_revision = -1
|
||||
var canonical_home: Dictionary = canonical.get("home", {})
|
||||
var canonical_revision: int = int(canonical_home.get("revision", -1))
|
||||
var transaction: Dictionary = _get_local_decor_rejection_transaction(
|
||||
canonical_revision
|
||||
)
|
||||
if canonical.is_empty() or not NetworkHomeProtocolType.validate_manifest_broadcast(canonical):
|
||||
if not transaction.is_empty():
|
||||
_restore_local_decor_transaction(transaction)
|
||||
_pending_local_decor_transactions.clear()
|
||||
if _save_manager != null and not _save_manager.save_if_dirty():
|
||||
push_warning("Unable to save the restored RV decor state.")
|
||||
_flush_queued_local_home_publish()
|
||||
local_manifest_rejected.emit(message)
|
||||
return
|
||||
# A shop result can be accepted locally while its own home manifest is still
|
||||
# in flight. A delayed rejection for an earlier placement must not erase that
|
||||
# accepted purchase just because the host's canonical manifest predates it.
|
||||
var locally_owned_decor: Dictionary[StringName, int] = (
|
||||
_capture_local_owned_decor_counts()
|
||||
)
|
||||
# Only carry ownership forward across an actual pending decor-action
|
||||
# rejection. A standalone rejected manifest must remain authoritative rather
|
||||
# than repeatedly re-submitting an unverified owned-count increase.
|
||||
var has_owned_decor_increase: bool = (
|
||||
not transaction.is_empty()
|
||||
and _has_local_owned_decor_increase(
|
||||
locally_owned_decor,
|
||||
canonical_home,
|
||||
)
|
||||
)
|
||||
_suppress_local_submission = true
|
||||
_local_home.restore_from_save_data(canonical["home"])
|
||||
var restored: bool = _local_home.restore_from_save_data(canonical_home)
|
||||
var owned_decor_restored: bool = (
|
||||
restored
|
||||
and (
|
||||
not has_owned_decor_increase
|
||||
or _restore_local_owned_decor_increases(locally_owned_decor)
|
||||
)
|
||||
)
|
||||
var reconciled: bool = (
|
||||
restored
|
||||
and owned_decor_restored
|
||||
and _reconcile_local_decor_inventory(transaction)
|
||||
)
|
||||
_suppress_local_submission = false
|
||||
_save_manager.save_if_dirty()
|
||||
_discard_local_decor_transactions_after(canonical_revision)
|
||||
if not reconciled:
|
||||
push_error("Unable to reconcile RV decor after a host rejection.")
|
||||
elif _save_manager != null and not _save_manager.save_if_dirty():
|
||||
push_warning("Unable to save the restored RV decor state.")
|
||||
receive_home_manifest(canonical)
|
||||
if reconciled and has_owned_decor_increase:
|
||||
# Submit the preserved purchase as a fresh revision after applying the
|
||||
# canonical placement rollback. This is deliberately separate from the
|
||||
# rejected action, so the host can validate the already-authorized count.
|
||||
if not publish_local_home():
|
||||
push_warning("Unable to publish the restored RV decor ownership.")
|
||||
local_manifest_rejected.emit(message)
|
||||
|
||||
|
||||
func _capture_local_decor_transaction() -> Dictionary:
|
||||
if (
|
||||
_local_home == null
|
||||
or _bag == null
|
||||
or _inventory_layout == null
|
||||
or _hotbar == null
|
||||
):
|
||||
return {}
|
||||
var saved_items: Array[OwnedItemType] = []
|
||||
for item: OwnedItemType in _bag.get_all_items():
|
||||
saved_items.append(item.duplicate_record())
|
||||
return {
|
||||
"home": _local_home.to_save_data(),
|
||||
"bag_items": saved_items,
|
||||
"layout": _inventory_layout.to_save_data(),
|
||||
"hotbar_slots": _hotbar.get_slots(),
|
||||
"hotbar_fish_slots": _hotbar.get_fish_slots(),
|
||||
"selected_hotbar_slot": _hotbar.get_selected_slot(),
|
||||
}
|
||||
|
||||
|
||||
func _capture_local_owned_decor_counts() -> Dictionary[StringName, int]:
|
||||
var counts: Dictionary[StringName, int] = {}
|
||||
if _local_home == null:
|
||||
return counts
|
||||
for decor_id: StringName in DecorCatalogType.get_product_ids():
|
||||
counts[decor_id] = _local_home.get_owned_count(decor_id)
|
||||
return counts
|
||||
|
||||
|
||||
func _has_local_owned_decor_increase(
|
||||
local_counts: Dictionary[StringName, int],
|
||||
canonical_home: Dictionary,
|
||||
) -> bool:
|
||||
var canonical_owned: Dictionary = canonical_home.get("owned_decor", {})
|
||||
for decor_id: StringName in DecorCatalogType.get_product_ids():
|
||||
if int(local_counts.get(decor_id, 0)) > int(
|
||||
canonical_owned.get(String(decor_id), 0)
|
||||
):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _restore_local_owned_decor_increases(
|
||||
local_counts: Dictionary[StringName, int],
|
||||
) -> bool:
|
||||
if _local_home == null:
|
||||
return false
|
||||
for decor_id: StringName in DecorCatalogType.get_product_ids():
|
||||
var local_count: int = int(local_counts.get(decor_id, 0))
|
||||
var canonical_count: int = _local_home.get_owned_count(decor_id)
|
||||
while canonical_count < local_count:
|
||||
if not _local_home.add_owned_decor(decor_id, 1):
|
||||
return false
|
||||
canonical_count += 1
|
||||
return true
|
||||
|
||||
|
||||
func _commit_local_decor_transaction(transaction: Dictionary) -> bool:
|
||||
if _save_manager == null or not _save_manager.save_if_dirty():
|
||||
_restore_local_decor_transaction(transaction)
|
||||
return false
|
||||
var revision: int = _local_home.get_revision()
|
||||
_pending_local_decor_transactions[revision] = transaction.duplicate(true)
|
||||
_trim_pending_local_decor_transactions()
|
||||
if publish_local_home(true):
|
||||
return true
|
||||
_pending_local_decor_transactions.erase(revision)
|
||||
_restore_local_decor_transaction(transaction)
|
||||
_flush_queued_local_home_publish()
|
||||
# The optimistic mutation was already written before we attempted to publish
|
||||
# it. If the session ended or could not accept the manifest, immediately
|
||||
# persist the restored state so a restart cannot resurrect the rejected
|
||||
# placement or consume its decor item.
|
||||
if _save_manager != null and not _save_manager.save_if_dirty():
|
||||
push_warning("Unable to save the restored RV decor state.")
|
||||
return false
|
||||
|
||||
|
||||
func _restore_local_decor_transaction(transaction: Dictionary) -> bool:
|
||||
if (
|
||||
transaction.is_empty()
|
||||
or _local_home == null
|
||||
or _bag == null
|
||||
or _inventory_layout == null
|
||||
or _hotbar == null
|
||||
):
|
||||
return false
|
||||
var home_data: Variant = transaction.get("home", {})
|
||||
var layout_data: Variant = transaction.get("layout", {})
|
||||
var stored_items: Variant = transaction.get("bag_items", [])
|
||||
var stored_slots: Variant = transaction.get("hotbar_slots", [])
|
||||
var stored_fish_slots: Variant = transaction.get("hotbar_fish_slots", [])
|
||||
if (
|
||||
typeof(home_data) != TYPE_DICTIONARY
|
||||
or typeof(layout_data) != TYPE_DICTIONARY
|
||||
or typeof(stored_items) != TYPE_ARRAY
|
||||
or typeof(stored_slots) != TYPE_ARRAY
|
||||
or typeof(stored_fish_slots) != TYPE_ARRAY
|
||||
):
|
||||
return false
|
||||
var items: Array[OwnedItemType] = []
|
||||
for value: Variant in stored_items as Array:
|
||||
if not value is OwnedItemType:
|
||||
return false
|
||||
items.append((value as OwnedItemType).duplicate_record())
|
||||
var slots: Array[StringName] = []
|
||||
for value: Variant in stored_slots as Array:
|
||||
slots.append(StringName(str(value)))
|
||||
var fish_slots: Array[StringName] = []
|
||||
for value: Variant in stored_fish_slots as Array:
|
||||
fish_slots.append(StringName(str(value)))
|
||||
var was_suppressed: bool = _suppress_local_submission
|
||||
_suppress_local_submission = true
|
||||
var home_restored: bool = _local_home.restore_from_save_data(
|
||||
home_data as Dictionary
|
||||
)
|
||||
var bag_restored: bool = _bag.replace_all_items(items)
|
||||
var layout_restored: bool = _inventory_layout.restore_from_save_data(
|
||||
layout_data as Dictionary
|
||||
)
|
||||
var hotbar_restored: bool = _hotbar.replace_state(
|
||||
slots,
|
||||
int(transaction.get("selected_hotbar_slot", 0)),
|
||||
fish_slots,
|
||||
false,
|
||||
)
|
||||
_suppress_local_submission = was_suppressed
|
||||
return home_restored and bag_restored and layout_restored and hotbar_restored
|
||||
|
||||
|
||||
func _reconcile_local_decor_inventory(transaction: Dictionary) -> bool:
|
||||
if _bag == null or _local_home == null:
|
||||
return false
|
||||
var preferred_slots: Dictionary[StringName, int] = (
|
||||
_decor_hotbar_slots_from_transaction(transaction)
|
||||
)
|
||||
for decor_id: StringName in DecorCatalogType.get_product_ids():
|
||||
var target_quantity: int = _local_home.get_available_count(decor_id)
|
||||
var current_quantity: int = _bag.get_quantity(decor_id)
|
||||
if current_quantity > target_quantity:
|
||||
if not _bag.remove_item(
|
||||
decor_id, current_quantity - target_quantity
|
||||
):
|
||||
return false
|
||||
continue
|
||||
while current_quantity < target_quantity:
|
||||
var preferred_slot: int = int(
|
||||
preferred_slots.get(decor_id, -1)
|
||||
)
|
||||
if not _restore_decor_item_to_bag(decor_id, preferred_slot):
|
||||
return false
|
||||
current_quantity += 1
|
||||
return true
|
||||
|
||||
|
||||
func _decor_hotbar_slots_from_transaction(
|
||||
transaction: Dictionary,
|
||||
) -> Dictionary[StringName, int]:
|
||||
var result: Dictionary[StringName, int] = {}
|
||||
var stored_slots: Variant = transaction.get("hotbar_slots", [])
|
||||
if typeof(stored_slots) != TYPE_ARRAY:
|
||||
return result
|
||||
for slot_index: int in range((stored_slots as Array).size()):
|
||||
var decor_id := StringName(str((stored_slots as Array)[slot_index]))
|
||||
if DecorCatalogType.has_product(decor_id):
|
||||
result[decor_id] = slot_index
|
||||
return result
|
||||
|
||||
|
||||
func _restore_decor_item_to_bag(
|
||||
decor_id: StringName,
|
||||
preferred_hotbar_slot: int,
|
||||
) -> bool:
|
||||
if _bag == null:
|
||||
return false
|
||||
var prepared_slot: int = -1
|
||||
if (
|
||||
_inventory_layout != null
|
||||
and _hotbar != null
|
||||
and _inventory_layout.get_container(
|
||||
PlayerInventoryLayout.EntryKind.ITEM, decor_id
|
||||
) < 0
|
||||
):
|
||||
var candidate_slot: int = preferred_hotbar_slot
|
||||
if candidate_slot < 0:
|
||||
candidate_slot = _hotbar.find_first_empty_slot()
|
||||
if candidate_slot >= 0 and _inventory_layout.prepare_new_item_placement(
|
||||
decor_id,
|
||||
PlayerInventoryLayout.InventoryContainer.HOTBAR,
|
||||
candidate_slot,
|
||||
):
|
||||
prepared_slot = candidate_slot
|
||||
var restored: bool = _bag.add_item(decor_id, 1)
|
||||
if not restored:
|
||||
if prepared_slot >= 0:
|
||||
_inventory_layout.cancel_prepared_item_placement(decor_id)
|
||||
if _bag.get_owned_item(decor_id) == null:
|
||||
restored = _bag.add_item_to_storage(decor_id, 1)
|
||||
if restored and prepared_slot >= 0:
|
||||
_hotbar.assign_item(prepared_slot, decor_id)
|
||||
_hotbar.select_slot(prepared_slot)
|
||||
return restored
|
||||
|
||||
|
||||
func _get_local_decor_rejection_transaction(
|
||||
canonical_revision: int,
|
||||
) -> Dictionary:
|
||||
var revisions: Array[int] = []
|
||||
revisions.assign(_pending_local_decor_transactions.keys())
|
||||
revisions.sort()
|
||||
for revision: int in revisions:
|
||||
if canonical_revision < 0 or revision > canonical_revision:
|
||||
return _pending_local_decor_transactions[revision].duplicate(true)
|
||||
return {}
|
||||
|
||||
|
||||
func _discard_local_decor_transactions_through(revision: int) -> void:
|
||||
for pending_revision: int in _pending_local_decor_transactions.keys():
|
||||
if pending_revision <= revision:
|
||||
_pending_local_decor_transactions.erase(pending_revision)
|
||||
|
||||
|
||||
func _discard_local_decor_transactions_after(revision: int) -> void:
|
||||
for pending_revision: int in _pending_local_decor_transactions.keys():
|
||||
if pending_revision > revision:
|
||||
_pending_local_decor_transactions.erase(pending_revision)
|
||||
|
||||
|
||||
func _trim_pending_local_decor_transactions() -> void:
|
||||
var revisions: Array[int] = []
|
||||
revisions.assign(_pending_local_decor_transactions.keys())
|
||||
revisions.sort()
|
||||
while revisions.size() > MAX_PENDING_LOCAL_DECOR_TRANSACTIONS:
|
||||
_pending_local_decor_transactions.erase(revisions.pop_front())
|
||||
|
||||
|
||||
func _submit_transition(action: int, owner_fingerprint: String) -> bool:
|
||||
var request: Dictionary = {
|
||||
"request_id": _new_id("home_transition"),
|
||||
|
|
@ -697,12 +1054,18 @@ func _process_transition_request(peer_id: int, data: Dictionary) -> void:
|
|||
):
|
||||
_send_transition_rejection(peer_id, "You cannot enter that RV.")
|
||||
return
|
||||
var interior_space: StringName = (
|
||||
NetworkHomeProtocolType.make_space_id(owner_fingerprint)
|
||||
)
|
||||
var visitor_slot: int = _reserve_interior_slot(
|
||||
interior_space, peer_id
|
||||
)
|
||||
_apply_host_transition(
|
||||
peer_id,
|
||||
NetworkHomeProtocolType.make_space_id(owner_fingerprint),
|
||||
interior_space,
|
||||
owner_fingerprint,
|
||||
_world_service.get_interior_spawn_transform(
|
||||
owner_fingerprint, peer_id
|
||||
owner_fingerprint, visitor_slot
|
||||
),
|
||||
)
|
||||
return
|
||||
|
|
@ -748,18 +1111,40 @@ func _apply_host_transition(
|
|||
owner_fingerprint: String,
|
||||
transform: Transform3D,
|
||||
) -> void:
|
||||
var avatar: Player = _spawn_service.get_avatar(peer_id)
|
||||
var movement_sequence: int = 0
|
||||
if avatar != null:
|
||||
movement_sequence = int(
|
||||
avatar.make_network_snapshot(peer_id).get(
|
||||
"acknowledged_input", 0
|
||||
)
|
||||
)
|
||||
var transform_data: Array[float] = (
|
||||
NetworkHomeProtocolType.transform_to_array(transform)
|
||||
)
|
||||
if not NetworkHomeProtocolType.validate_transition_transform(
|
||||
transform_data
|
||||
):
|
||||
push_error("Refused an out-of-bounds RV transition transform.")
|
||||
return
|
||||
var previous_space: StringName = _session.get_peer_space(peer_id)
|
||||
if (
|
||||
String(previous_space).begins_with("rv:")
|
||||
and previous_space != space_id
|
||||
):
|
||||
_release_interior_slot(previous_space, peer_id)
|
||||
var result: Dictionary = {
|
||||
"peer_id": peer_id,
|
||||
"space_id": String(space_id),
|
||||
"owner_fingerprint": owner_fingerprint,
|
||||
"transform": NetworkHomeProtocolType.transform_to_array(transform),
|
||||
"transform": transform_data,
|
||||
"movement_sequence": movement_sequence,
|
||||
}
|
||||
_apply_transition_result(result)
|
||||
for recipient_id: int in _session.get_authenticated_peer_ids():
|
||||
if recipient_id == 1:
|
||||
continue
|
||||
receive_home_transition.rpc_id(recipient_id, result)
|
||||
_session.publish_authoritative_teleport(peer_id)
|
||||
|
||||
|
||||
@rpc(
|
||||
|
|
@ -770,35 +1155,40 @@ func receive_home_transition(data: Dictionary) -> void:
|
|||
if (
|
||||
typeof(data.get("peer_id")) != TYPE_INT
|
||||
or typeof(data.get("space_id")) not in [TYPE_STRING, TYPE_STRING_NAME]
|
||||
or typeof(data.get("movement_sequence", 0)) != TYPE_INT
|
||||
or int(data.get("movement_sequence", 0)) < 0
|
||||
or not NetworkIdentityCrypto.valid_fingerprint(
|
||||
data.get("owner_fingerprint")
|
||||
)
|
||||
or not NetworkHomeProtocolType.validate_transition_transform(
|
||||
data.get("transform")
|
||||
)
|
||||
):
|
||||
return
|
||||
var transform: Transform3D = NetworkHomeProtocolType.array_to_transform(
|
||||
data.get("transform")
|
||||
)
|
||||
if transform == Transform3D():
|
||||
return
|
||||
_apply_transition_result(data)
|
||||
|
||||
|
||||
func _apply_transition_result(data: Dictionary) -> void:
|
||||
var peer_id: int = int(data["peer_id"])
|
||||
var space_id := StringName(str(data["space_id"]))
|
||||
if not NetworkHomeProtocolType.validate_transition_transform(
|
||||
data.get("transform")
|
||||
):
|
||||
return
|
||||
var target_transform: Transform3D = (
|
||||
NetworkHomeProtocolType.array_to_transform(data["transform"])
|
||||
)
|
||||
if not _session.set_peer_space(peer_id, space_id):
|
||||
return
|
||||
var avatar: Player = _spawn_service.get_avatar(peer_id)
|
||||
if avatar != null:
|
||||
avatar.global_transform = NetworkHomeProtocolType.array_to_transform(
|
||||
data["transform"]
|
||||
)
|
||||
avatar.velocity = Vector3.ZERO
|
||||
if not _session.apply_authoritative_space_transition(
|
||||
peer_id,
|
||||
target_transform,
|
||||
int(data.get("movement_sequence", 0)),
|
||||
):
|
||||
return
|
||||
if peer_id == _session.get_local_peer_id():
|
||||
_world_service.set_local_space(space_id)
|
||||
local_space_changed.emit(
|
||||
space_id, str(data["owner_fingerprint"])
|
||||
)
|
||||
local_space_changed.emit(space_id, str(data["owner_fingerprint"]))
|
||||
local_transition_finished.emit(true, "")
|
||||
_refresh_peer_visibility()
|
||||
|
||||
|
|
@ -840,12 +1230,20 @@ func _on_local_home_changed() -> void:
|
|||
|
||||
|
||||
func _publish_queued_home() -> void:
|
||||
if has_pending_local_decor_mutation():
|
||||
return
|
||||
_publish_queued = false
|
||||
if _save_manager != null:
|
||||
_save_manager.save_if_dirty()
|
||||
publish_local_home()
|
||||
|
||||
|
||||
func _flush_queued_local_home_publish() -> void:
|
||||
if not _publish_queued or has_pending_local_decor_mutation():
|
||||
return
|
||||
call_deferred("_publish_queued_home")
|
||||
|
||||
|
||||
func _on_join_authenticated() -> void:
|
||||
publish_local_home()
|
||||
|
||||
|
|
@ -880,6 +1278,7 @@ func _on_peer_authenticated(peer_id: int, _display_name: String) -> void:
|
|||
|
||||
|
||||
func _on_peer_removed(peer_id: int) -> void:
|
||||
_release_peer_interior_slots(peer_id)
|
||||
var fingerprint: String = _fingerprint_by_peer.get(peer_id, "")
|
||||
_fingerprint_by_peer.erase(peer_id)
|
||||
_authorized_owned_counts.erase(peer_id)
|
||||
|
|
@ -937,6 +1336,10 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
|
|||
_fingerprint_by_peer.clear()
|
||||
_authorized_owned_counts.clear()
|
||||
_authorization_result_owner.clear()
|
||||
_publish_queued = false
|
||||
_last_submitted_local_home_revision = -1
|
||||
_pending_local_decor_transactions.clear()
|
||||
_interior_slots_by_space.clear()
|
||||
_next_interior_index = 0
|
||||
_world_service.clear_presentations()
|
||||
_world_service.set_local_space(NetworkHomeProtocolType.WORLD_SPACE)
|
||||
|
|
@ -950,6 +1353,37 @@ func _fingerprint_for_peer(peer_id: int) -> String:
|
|||
return record.identity_fingerprint if record != null else ""
|
||||
|
||||
|
||||
func _reserve_interior_slot(space_id: StringName, peer_id: int) -> int:
|
||||
var slots: Dictionary = _interior_slots_by_space.get(space_id, {})
|
||||
if slots.has(peer_id):
|
||||
return int(slots[peer_id])
|
||||
var used_slots: Dictionary[int, bool] = {}
|
||||
for value: Variant in slots.values():
|
||||
used_slots[int(value)] = true
|
||||
var slot: int = 0
|
||||
while used_slots.has(slot):
|
||||
slot += 1
|
||||
slots[peer_id] = slot
|
||||
_interior_slots_by_space[space_id] = slots
|
||||
return slot
|
||||
|
||||
|
||||
func _release_interior_slot(space_id: StringName, peer_id: int) -> void:
|
||||
var slots: Dictionary = _interior_slots_by_space.get(space_id, {})
|
||||
if slots.is_empty():
|
||||
return
|
||||
slots.erase(peer_id)
|
||||
if slots.is_empty():
|
||||
_interior_slots_by_space.erase(space_id)
|
||||
else:
|
||||
_interior_slots_by_space[space_id] = slots
|
||||
|
||||
|
||||
func _release_peer_interior_slots(peer_id: int) -> void:
|
||||
for space_id: StringName in _interior_slots_by_space.keys():
|
||||
_release_interior_slot(space_id, peer_id)
|
||||
|
||||
|
||||
func _new_id(prefix: String) -> String:
|
||||
return "%s:%s" % [
|
||||
prefix, Crypto.new().generate_random_bytes(16).hex_encode(),
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const MAX_PENDING_MOVEMENT_INPUTS: int = 96
|
|||
const ANIMATION_REFRESH_INTERVAL: float = 1.0
|
||||
const MAX_MOVEMENT_INPUT_SEQUENCE: int = 2147483647
|
||||
const MAX_MOVEMENT_ONE_WAY_TRANSIT_SECONDS: float = 0.25
|
||||
const SPACE_TRANSITION_SNAPSHOT_GUARD_DISTANCE: float = 12.0
|
||||
|
||||
const MOVEMENT_FLAG_JUMP: int = 1 << 0
|
||||
const MOVEMENT_FLAG_SPRINT: int = 1 << 1
|
||||
|
|
@ -124,6 +125,9 @@ var _pending_movement_inputs: Array[Dictionary] = []
|
|||
var _snapshot_accumulator: float = 0.0
|
||||
var _movement_snapshot_tick: int = 0
|
||||
var _last_local_snapshot_received_msec: int = 0
|
||||
var _local_space_transition_guard_active: bool = false
|
||||
var _local_space_transition_guard_position: Vector3 = Vector3.ZERO
|
||||
var _local_space_transition_minimum_ack: int = 0
|
||||
var _animation_refresh_accumulator: float = 0.0
|
||||
var _last_animation_state_by_peer: Dictionary[int, Dictionary] = {}
|
||||
var _pending_animation_state_by_peer: Dictionary[int, Dictionary] = {}
|
||||
|
|
@ -703,6 +707,40 @@ func peers_share_space(first_peer_id: int, second_peer_id: int) -> bool:
|
|||
return get_peer_space(first_peer_id) == get_peer_space(second_peer_id)
|
||||
|
||||
|
||||
func apply_authoritative_space_transition(
|
||||
peer_id: int,
|
||||
target_transform: Transform3D,
|
||||
acknowledged_input: int,
|
||||
) -> bool:
|
||||
if not target_transform.origin.is_finite():
|
||||
return false
|
||||
var local_peer_id: int = get_local_peer_id()
|
||||
var local_transition: bool = peer_id == local_peer_id
|
||||
var avatar: Player = _spawn_service.get_avatar(peer_id)
|
||||
# Remote home transitions can arrive before their player-spawn message. Keep
|
||||
# the new space assignment so the later spawn inherits correct visibility;
|
||||
# only a missing local avatar is a transition failure.
|
||||
if avatar == null:
|
||||
return not local_transition
|
||||
avatar.apply_network_space_transition(target_transform, local_transition)
|
||||
if (
|
||||
local_transition
|
||||
and state == State.JOINED_CLIENT
|
||||
):
|
||||
# Movement snapshots use another ENet channel and a pre-transition audit
|
||||
# can therefore arrive after the reliable home transition. Clear local
|
||||
# prediction and reject those old-world snapshots until the host publishes
|
||||
# its first audit from the new space.
|
||||
_pending_movement_inputs.clear()
|
||||
_last_local_snapshot_received_msec = 0
|
||||
_last_input_state_hash = 0
|
||||
_idle_input_accumulator = IDLE_INPUT_INTERVAL
|
||||
_local_space_transition_guard_active = true
|
||||
_local_space_transition_guard_position = target_transform.origin
|
||||
_local_space_transition_minimum_ack = maxi(acknowledged_input, 0)
|
||||
return true
|
||||
|
||||
|
||||
func get_peer_rtt_ms(peer_id: int) -> int:
|
||||
if peer_id == 1:
|
||||
return 0
|
||||
|
|
@ -2569,6 +2607,8 @@ func receive_movement_snapshots(encoded_snapshots: Array) -> void:
|
|||
if avatar == null:
|
||||
continue
|
||||
if peer_id == local_peer_id:
|
||||
if _reject_stale_space_transition_snapshot(snapshot):
|
||||
continue
|
||||
_discard_acknowledged_movement_inputs(
|
||||
int(snapshot.get("acknowledged_input", 0))
|
||||
)
|
||||
|
|
@ -2586,6 +2626,33 @@ func receive_movement_snapshots(encoded_snapshots: Array) -> void:
|
|||
)
|
||||
|
||||
|
||||
func _reject_stale_space_transition_snapshot(snapshot: Dictionary) -> bool:
|
||||
if not _local_space_transition_guard_active:
|
||||
return false
|
||||
var position_values: Array = snapshot.get("position", [])
|
||||
if position_values.size() != 3:
|
||||
return true
|
||||
var position := Vector3(
|
||||
float(position_values[0]),
|
||||
float(position_values[1]),
|
||||
float(position_values[2]),
|
||||
)
|
||||
var acknowledged_input: int = int(
|
||||
snapshot.get("acknowledged_input", 0)
|
||||
)
|
||||
if acknowledged_input < _local_space_transition_minimum_ack:
|
||||
return true
|
||||
if acknowledged_input > _local_space_transition_minimum_ack:
|
||||
_local_space_transition_guard_active = false
|
||||
return false
|
||||
if position.distance_to(_local_space_transition_guard_position) > (
|
||||
SPACE_TRANSITION_SNAPSHOT_GUARD_DISTANCE
|
||||
):
|
||||
return true
|
||||
_local_space_transition_guard_active = false
|
||||
return false
|
||||
|
||||
|
||||
func _discard_acknowledged_movement_inputs(acknowledged_sequence: int) -> void:
|
||||
while (
|
||||
not _pending_movement_inputs.is_empty()
|
||||
|
|
@ -2914,6 +2981,9 @@ func _teardown_peer() -> void:
|
|||
_snapshot_accumulator = 0.0
|
||||
_movement_snapshot_tick = 0
|
||||
_last_local_snapshot_received_msec = 0
|
||||
_local_space_transition_guard_active = false
|
||||
_local_space_transition_guard_position = Vector3.ZERO
|
||||
_local_space_transition_minimum_ack = 0
|
||||
_animation_refresh_accumulator = 0.0
|
||||
_last_animation_state_by_peer.clear()
|
||||
_pending_animation_state_by_peer.clear()
|
||||
|
|
|
|||
|
|
@ -2093,6 +2093,37 @@ func reset_network_movement_state() -> void:
|
|||
_local_prediction_largest_error = 0.0
|
||||
|
||||
|
||||
func apply_network_space_transition(
|
||||
target_transform: Transform3D,
|
||||
align_local_camera: bool,
|
||||
) -> void:
|
||||
if not target_transform.origin.is_finite():
|
||||
return
|
||||
# A room transition is a discontinuity, not ordinary movement. Preserve the
|
||||
# host's input acknowledgement while clearing every motion, animation, and
|
||||
# reconciliation state that could pull the avatar toward the previous space.
|
||||
var acknowledged_input: int = _last_network_input_sequence
|
||||
reset_network_movement_state()
|
||||
_last_network_input_sequence = acknowledged_input
|
||||
_apply_network_casting(false)
|
||||
velocity = Vector3.ZERO
|
||||
global_transform = Transform3D(Basis.IDENTITY, target_transform.origin)
|
||||
var target_yaw: float = target_transform.basis.get_euler().y
|
||||
_visuals.rotation.y = target_yaw
|
||||
_network_camera_yaw = target_yaw
|
||||
_network_target_position = global_position
|
||||
_network_target_velocity = Vector3.ZERO
|
||||
_network_target_visual_yaw = target_yaw
|
||||
_network_snapshot_age = 0.0
|
||||
_network_snapshot_ready = true
|
||||
_authoritative_movement_history.clear()
|
||||
if align_local_camera:
|
||||
_set_camera_dragging(false)
|
||||
if _free_camera_active:
|
||||
_set_free_camera_active(false)
|
||||
_camera_yaw.rotation.y = target_yaw
|
||||
|
||||
|
||||
func capture_network_input(sequence: int) -> Dictionary:
|
||||
if _sitting_intent_pending and _sitting_intent_sequence < 0:
|
||||
_sitting_intent_sequence = sequence
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue