feat: expand progression and multiplayer systems

Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
This commit is contained in:
Alexander Sellite 2026-08-23 20:48:38 -04:00
parent 1db1a5b754
commit 3b84bfe3a0
97 changed files with 7869 additions and 982 deletions

View file

@ -7,13 +7,50 @@ const DEFAULT_SESSION_MAX_PLAYERS: int = 8
const DEFAULT_TRANSPORT_MAX_CLIENTS: int = 31
const CONNECTION_TIMEOUT_SECONDS: float = 10.0
const AUTHENTICATION_TIMEOUT_SECONDS: float = 60.0
const ENET_TIMEOUT_LIMIT: int = 32
const ENET_TIMEOUT_MINIMUM_MS: int = 10000
const ENET_TIMEOUT_MAXIMUM_MS: int = 120000
const INPUT_INTERVAL: float = 1.0 / 30.0
const IDLE_INPUT_INTERVAL: float = 1.0 / 5.0
const SNAPSHOT_INTERVAL: float = 1.0 / 30.0
const NEAR_REMOTE_SNAPSHOT_DIVISOR: int = 2
const FAR_REMOTE_SNAPSHOT_DIVISOR: int = 6
const DISTANT_REMOTE_SNAPSHOT_DIVISOR: int = 8
const NEAR_REMOTE_DISTANCE: float = 48.0
const FAR_REMOTE_DISTANCE: float = 96.0
const MOVEMENT_SNAPSHOT_BATCH_SIZE: int = 8
const MOVEMENT_SNAPSHOT_FIELD_COUNT: int = 13
const MOVEMENT_INPUT_FIELD_COUNT: int = 8
const MOVEMENT_SNAPSHOT_FIELD_COUNT: int = 6
const MOVEMENT_ANIMATION_FIELD_COUNT: int = 7
const MOVEMENT_ANIMATION_ACTION_FIELD_COUNT: int = 5
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 MOVEMENT_FLAG_JUMP: int = 1 << 0
const MOVEMENT_FLAG_SPRINT: int = 1 << 1
const MOVEMENT_FLAG_SNEAK: int = 1 << 2
const MOVEMENT_FLAG_SLOW_WALK: int = 1 << 3
const MOVEMENT_FLAG_SITTING: int = 1 << 4
const MOVEMENT_FLAG_CASTING: int = 1 << 5
const MOVEMENT_ALLOWED_FLAGS: int = (
MOVEMENT_FLAG_JUMP
| MOVEMENT_FLAG_SPRINT
| MOVEMENT_FLAG_SNEAK
| MOVEMENT_FLAG_SLOW_WALK
| MOVEMENT_FLAG_SITTING
| MOVEMENT_FLAG_CASTING
)
const SNAPSHOT_FLAG_GROUNDED: int = 1 << 0
const SNAPSHOT_FLAG_SITTING: int = 1 << 1
const SNAPSHOT_FLAG_CASTING: int = 1 << 2
const SNAPSHOT_ALLOWED_FLAGS: int = (
SNAPSHOT_FLAG_GROUNDED
| SNAPSHOT_FLAG_SITTING
| SNAPSHOT_FLAG_CASTING
)
signal state_changed(state: State)
signal status_message_changed(message: String)
signal connection_error(message: String)
@ -80,7 +117,21 @@ var _client_nonce: String = ""
var _current_route: ConnectionRoute
var _input_sequence: int = 0
var _input_accumulator: float = 0.0
var _idle_input_accumulator: float = 0.0
var _last_input_state_hash: int = 0
var _pending_movement_inputs: Array[Dictionary] = []
var _snapshot_accumulator: float = 0.0
var _movement_snapshot_tick: 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] = {}
var _last_local_animation_action_signature: Array = []
var _movement_inputs_sent: int = 0
var _movement_inputs_received: int = 0
var _movement_snapshot_packets_sent: int = 0
var _movement_snapshot_states_sent: int = 0
var _movement_animation_packets_sent: int = 0
var _movement_animation_states_sent: int = 0
var _last_server_max_players: int = DEFAULT_SESSION_MAX_PLAYERS
var _last_server_player_count: int = 0
var _last_server_display_name: String = ""
@ -278,6 +329,9 @@ func _register_player_host() -> void:
NetworkProtocol.BACKPACK_SHOP_CAPABILITY,
NetworkProtocol.WORLD_GENERATION_CAPABILITY,
NetworkProtocol.WORLD_LAYOUT_CAPABILITY,
NetworkProtocol.FRIENDS_CAPABILITY,
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
]),
)
_registry.update_appearance(1, _local_appearance_snapshot)
@ -602,6 +656,9 @@ func supports_server_capability(capability: StringName) -> bool:
NetworkProtocol.APPEARANCE_PREVIEW_CAPABILITY,
NetworkProtocol.WORLD_GENERATION_CAPABILITY,
NetworkProtocol.WORLD_LAYOUT_CAPABILITY,
NetworkProtocol.FRIENDS_CAPABILITY,
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
"chat_v1",
"mail_v1",
"profile_v1",
@ -649,6 +706,18 @@ func get_peer_rtt_ms(peer_id: int) -> int:
return int(packet_peer.get_statistic(ENetPacketPeer.PEER_ROUND_TRIP_TIME))
func get_movement_metrics() -> Dictionary:
return {
"inputs_sent": _movement_inputs_sent,
"inputs_received": _movement_inputs_received,
"snapshot_packets_sent": _movement_snapshot_packets_sent,
"snapshot_states_sent": _movement_snapshot_states_sent,
"animation_packets_sent": _movement_animation_packets_sent,
"animation_states_sent": _movement_animation_states_sent,
"pending_local_inputs": _pending_movement_inputs.size(),
}
func kick_authenticated_peer(
peer_id: int,
fingerprint: String,
@ -813,6 +882,10 @@ func get_local_peer_id() -> int:
return multiplayer.get_unique_id() if is_gameplay_session_active() else 0
func get_latest_movement_input_sequence() -> int:
return _input_sequence if state == State.JOINED_CLIENT else 0
func _process(delta: float) -> void:
var now: float = Time.get_ticks_msec() / 1000.0
if (
@ -833,9 +906,13 @@ func _process(delta: float) -> void:
return
_input_accumulator += delta
_snapshot_accumulator += delta
_animation_refresh_accumulator += delta
if state == State.JOINED_CLIENT and _input_accumulator >= INPUT_INTERVAL:
var elapsed_input_time: float = _input_accumulator
_input_accumulator = fmod(_input_accumulator, INPUT_INTERVAL)
_send_local_input()
_idle_input_accumulator += elapsed_input_time
_maybe_send_local_animation_action()
_maybe_send_local_input()
if is_host() and _snapshot_accumulator >= SNAPSHOT_INTERVAL:
_snapshot_accumulator = fmod(_snapshot_accumulator, SNAPSHOT_INTERVAL)
_broadcast_movement_snapshots()
@ -873,6 +950,7 @@ func _on_peer_connected(peer_id: int) -> void:
if state != State.OPEN_HOST:
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
return
_configure_enet_peer_timeout(peer_id)
_pending_authentication[peer_id] = (
Time.get_ticks_msec() / 1000.0 + AUTHENTICATION_TIMEOUT_SECONDS
)
@ -881,6 +959,7 @@ func _on_peer_connected(peer_id: int) -> void:
func _on_connected_to_server() -> void:
if state != State.CONNECTING:
return
_configure_enet_peer_timeout(1)
_set_state(State.AUTHENTICATING, "Authenticating...")
_client_nonce = NetworkIdentityCrypto.secure_id(32)
_client_identity_attempt = NetworkProtocol.make_identity_hello(
@ -892,6 +971,24 @@ func _on_connected_to_server() -> void:
submit_identity_hello.rpc_id(1, _client_identity_attempt)
func _configure_enet_peer_timeout(peer_id: int) -> void:
var enet := multiplayer.multiplayer_peer as ENetMultiplayerPeer
if enet == null:
return
var packet_peer: ENetPacketPeer = enet.get_peer(peer_id)
if packet_peer == null:
return
# A deterministic generated world can temporarily occupy the client's main
# thread during the authenticated join transition. Keep that bounded load
# from looking like a dead connection while retaining normal ENet liveness
# checks once the client resumes polling.
packet_peer.set_timeout(
ENET_TIMEOUT_LIMIT,
ENET_TIMEOUT_MINIMUM_MS,
ENET_TIMEOUT_MAXIMUM_MS,
)
func _on_connection_failed() -> void:
if state not in [
State.CONNECTING,
@ -931,6 +1028,8 @@ func _on_peer_disconnected(peer_id: int) -> void:
_pending_identity_challenges.erase(peer_id)
_authenticated_identity_cache.erase(peer_id)
_operator_peer_ids.erase(peer_id)
_last_animation_state_by_peer.erase(peer_id)
_pending_animation_state_by_peer.erase(peer_id)
var recovery_attempt: String = _recovery_attempts.get(peer_id, "")
if not recovery_attempt.is_empty():
_recovery_attempts.erase(peer_id)
@ -1209,6 +1308,16 @@ func submit_client_hello(data: Dictionary) -> void:
NetworkProtocol.RejectionCode.UNSUPPORTED_CLIENT,
)
return
for required_capability: String in [
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
]:
if required_capability not in client_capabilities:
_reject_peer(
sender_id,
NetworkProtocol.RejectionCode.UNSUPPORTED_CLIENT,
)
return
if (
_host_world_layout == WorldLayout.STARTER_ISLAND
and NetworkProtocol.WORLD_LAYOUT_CAPABILITY not in client_capabilities
@ -1432,6 +1541,14 @@ func receive_server_hello(data: Dictionary) -> void:
_teardown_peer()
_fail("This server does not support generated worlds.")
return
for required_capability: String in [
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
]:
if required_capability not in _server_capabilities:
_teardown_peer()
_fail("This server is missing required network capabilities.")
return
if (
received_world_layout == WorldLayout.STARTER_ISLAND
and NetworkProtocol.WORLD_LAYOUT_CAPABILITY not in _server_capabilities
@ -1456,6 +1573,9 @@ func receive_server_hello(data: Dictionary) -> void:
NetworkProtocol.BACKPACK_SHOP_CAPABILITY,
NetworkProtocol.WORLD_GENERATION_CAPABILITY,
NetworkProtocol.WORLD_LAYOUT_CAPABILITY,
NetworkProtocol.FRIENDS_CAPABILITY,
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
]),
)
_registry.update_appearance(local_peer_id, _local_appearance_snapshot)
@ -1501,6 +1621,8 @@ func receive_peer_despawn(peer_id: int) -> void:
if state != State.JOINED_CLIENT:
return
_operator_peer_ids.erase(peer_id)
_last_animation_state_by_peer.erase(peer_id)
_pending_animation_state_by_peer.erase(peer_id)
_registry.remove_peer(peer_id)
_spawn_service.remove_peer(peer_id)
peer_removed.emit(peer_id)
@ -1542,6 +1664,10 @@ func _apply_spawn_entry(entry: Dictionary) -> void:
or typeof(entry.get("display_name")) != TYPE_STRING
or typeof(entry.get("position")) != TYPE_ARRAY
or typeof(entry.get("yaw")) not in [TYPE_FLOAT, TYPE_INT]
or typeof(entry.get("sitting")) != TYPE_BOOL
or not NetworkPlayerAnimationProtocol.validate_state(
entry.get("animation_state")
)
):
return
if not _verify_spawn_identity(entry):
@ -1557,6 +1683,7 @@ func _apply_spawn_entry(entry: Dictionary) -> void:
)
own_snapshot["position"] = own_position
own_snapshot["visual_yaw"] = float(entry["yaw"])
own_snapshot["sitting"] = bool(entry["sitting"])
own_avatar.apply_network_teleport(own_snapshot)
return
var peer_was_added: bool = false
@ -1603,6 +1730,18 @@ func _apply_spawn_entry(entry: Dictionary) -> void:
var record := _registry.get_peer(peer_id)
if avatar != null and record != null:
avatar.apply_appearance_snapshot(record.appearance_snapshot)
# Lifecycle and animation updates use separate reliable ENet channels.
# Include the current authoritative state in the spawn record so a peer
# joining during an action presents it immediately, regardless of which
# channel arrives first.
avatar.apply_network_animation_state(entry["animation_state"])
avatar.apply_network_sitting_state(bool(entry["sitting"]))
var pending_animation: Dictionary = (
_pending_animation_state_by_peer.get(peer_id, {})
)
if not pending_animation.is_empty():
avatar.apply_network_animation_state(pending_animation)
_pending_animation_state_by_peer.erase(peer_id)
if peer_was_added and record != null:
peer_authenticated.emit(peer_id, record.display_name)
@ -1627,6 +1766,15 @@ func _make_spawn_entry(
transform: Transform3D,
) -> Dictionary:
var record: PeerRegistry.PeerRecord = _registry.get_peer(peer_id)
var avatar: Player = _spawn_service.get_avatar(peer_id)
var animation_state: Dictionary = (
avatar.make_network_animation_state()
if avatar != null
else NetworkPlayerAnimationProtocol.make_state(
NetworkPlayerAnimationProtocol.LOCOMOTION_IDLE,
true,
)
)
return {
"peer_id": peer_id,
"profile_id": record.profile_id if record != null else "",
@ -1637,6 +1785,12 @@ func _make_spawn_entry(
transform.origin.z,
],
"yaw": transform.basis.get_euler().y,
"sitting": (
avatar.get_network_sitting_state()
if avatar != null
else false
),
"animation_state": animation_state,
"appearance": (
record.appearance_snapshot.duplicate(true)
if record != null
@ -1862,14 +2016,69 @@ func _fail_identity(message: String) -> void:
_fail(message)
func _maybe_send_local_input() -> void:
var peer_id: int = multiplayer.get_unique_id()
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar == null:
return
var state_hash: int = avatar.get_network_input_state_hash()
var state_changed: bool = state_hash != _last_input_state_hash
if (
avatar.has_active_network_input()
or state_changed
or _idle_input_accumulator >= IDLE_INPUT_INTERVAL
):
_send_local_input()
_last_input_state_hash = state_hash
_idle_input_accumulator = 0.0
func _send_local_input() -> void:
var peer_id: int = multiplayer.get_unique_id()
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar == null:
return
_input_sequence += 1
_input_sequence = (
1
if _input_sequence >= MAX_MOVEMENT_INPUT_SEQUENCE
else _input_sequence + 1
)
var input: Dictionary = avatar.capture_network_input(_input_sequence)
submit_movement_input.rpc_id(1, input)
var encoded: Array = _encode_movement_input(input)
if encoded.is_empty():
return
_pending_movement_inputs.append(input.duplicate(true))
while _pending_movement_inputs.size() > MAX_PENDING_MOVEMENT_INPUTS:
_pending_movement_inputs.pop_front()
_movement_inputs_sent += 1
submit_movement_input.rpc_id(1, encoded)
func _maybe_send_local_animation_action() -> void:
var peer_id: int = multiplayer.get_unique_id()
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar == null:
return
var state: Dictionary = avatar.make_network_animation_state()
var action: Dictionary = state.get("action", {})
if not NetworkPlayerAnimationProtocol.validate_action_state(action):
return
var signature: Array = [
str(action["id"]),
int(action["sequence"]),
bool(action.get("paused", false)),
avatar.get_network_sitting_state(),
]
if signature == _last_local_animation_action_signature:
return
var encoded: Array = _encode_movement_animation_action(
action,
avatar.get_network_sitting_state(),
)
if encoded.is_empty():
return
_last_local_animation_action_signature = signature
submit_movement_animation_action.rpc_id(1, encoded)
func submit_neutral_local_movement() -> void:
@ -1879,17 +2088,112 @@ func submit_neutral_local_movement() -> void:
@rpc("any_peer", "call_remote", "unreliable_ordered", 1)
func submit_movement_input(data: Dictionary) -> void:
func submit_movement_input(encoded: Array) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if not is_host() or not _registry.has_peer(sender_id):
return
var avatar: Player = _spawn_service.get_avatar(sender_id)
if avatar == null or not _is_valid_movement_input(data):
var data: Dictionary = _decode_movement_input(encoded)
if avatar == null or data.is_empty():
return
avatar.apply_authoritative_network_input(data)
_movement_inputs_received += 1
avatar.apply_authoritative_network_input(
data,
Player.resolve_network_input_stale_timeout_seconds(
get_peer_rtt_ms(sender_id)
),
)
func _is_valid_movement_input(data: Dictionary) -> bool:
@rpc(
"any_peer",
"call_remote",
"reliable",
NetworkProtocol.MOVEMENT_ANIMATION_CHANNEL,
)
func submit_movement_animation_action(encoded: Array) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if not is_host() or not _registry.has_peer(sender_id):
return
var avatar: Player = _spawn_service.get_avatar(sender_id)
var state: Dictionary = _decode_movement_animation_action(encoded)
if avatar == null or state.is_empty():
return
avatar.apply_authoritative_network_animation_action(state["action"])
avatar.apply_authoritative_network_sitting_state(bool(state["sitting"]))
static func _encode_movement_input(data: Dictionary) -> Array:
if not _is_valid_movement_input(data):
return []
var axis: Array = data["axis"]
var flags: int = 0
if bool(data["jump"]):
flags |= MOVEMENT_FLAG_JUMP
if bool(data["sprint"]):
flags |= MOVEMENT_FLAG_SPRINT
if bool(data["sneak"]):
flags |= MOVEMENT_FLAG_SNEAK
if bool(data["slow_walk"]):
flags |= MOVEMENT_FLAG_SLOW_WALK
if bool(data["sitting"]):
flags |= MOVEMENT_FLAG_SITTING
if bool(data["casting"]):
flags |= MOVEMENT_FLAG_CASTING
var action: Dictionary = data["animation_action"]
return [
int(data["sequence"]),
Vector2(float(axis[0]), float(axis[1])),
float(data["camera_yaw"]),
flags,
str(action["id"]),
int(action["sequence"]),
float(action["elapsed"]),
bool(action.get("paused", false)),
]
static func _decode_movement_input(value: Variant) -> Dictionary:
if typeof(value) != TYPE_ARRAY:
return {}
var fields: Array = value
if (
fields.size() != MOVEMENT_INPUT_FIELD_COUNT
or typeof(fields[0]) != TYPE_INT
or typeof(fields[1]) != TYPE_VECTOR2
or typeof(fields[2]) not in [TYPE_FLOAT, TYPE_INT]
or typeof(fields[3]) != TYPE_INT
or typeof(fields[4]) not in [TYPE_STRING, TYPE_STRING_NAME]
or typeof(fields[5]) != TYPE_INT
or typeof(fields[6]) not in [TYPE_FLOAT, TYPE_INT]
or typeof(fields[7]) != TYPE_BOOL
):
return {}
var axis: Vector2 = fields[1]
var flags: int = int(fields[3])
if flags < 0 or flags & ~MOVEMENT_ALLOWED_FLAGS != 0:
return {}
var decoded: Dictionary = {
"sequence": int(fields[0]),
"axis": [axis.x, axis.y],
"camera_yaw": float(fields[2]),
"jump": bool(flags & MOVEMENT_FLAG_JUMP),
"sprint": bool(flags & MOVEMENT_FLAG_SPRINT),
"sneak": bool(flags & MOVEMENT_FLAG_SNEAK),
"slow_walk": bool(flags & MOVEMENT_FLAG_SLOW_WALK),
"sitting": bool(flags & MOVEMENT_FLAG_SITTING),
"casting": bool(flags & MOVEMENT_FLAG_CASTING),
"animation_action": NetworkPlayerAnimationProtocol.make_action_state(
StringName(str(fields[4])),
int(fields[5]),
float(fields[6]),
bool(fields[7]),
),
}
return decoded if _is_valid_movement_input(decoded) else {}
static func _is_valid_movement_input(data: Dictionary) -> bool:
if (
typeof(data.get("sequence")) != TYPE_INT
or typeof(data.get("axis")) != TYPE_ARRAY
@ -1929,49 +2233,142 @@ func _is_valid_movement_input(data: Dictionary) -> bool:
func _broadcast_movement_snapshots() -> void:
var snapshots: Array = []
for peer_id: int in _registry.get_peer_ids():
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar == null:
_movement_snapshot_tick += 1
var peer_ids: Array[int] = _registry.get_peer_ids()
for recipient_id: int in peer_ids:
# Peer 1 is the local listen-server player. Dedicated servers do not
# register a peer 1, so every remaining record is a remote recipient.
if recipient_id == 1:
continue
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.
var recipient_avatar: Player = _spawn_service.get_avatar(recipient_id)
if recipient_avatar == null:
continue
var snapshots: Array = []
for subject_id: int in peer_ids:
var subject_avatar: Player = _spawn_service.get_avatar(subject_id)
if subject_avatar == null:
continue
if (
subject_id != recipient_id
and not _should_send_remote_snapshot(
recipient_avatar.global_position,
subject_avatar.global_position,
)
):
continue
var encoded: Array = _encode_movement_snapshot(
subject_avatar.make_network_snapshot(subject_id)
)
if not encoded.is_empty():
snapshots.append(encoded)
_send_movement_snapshot_batches(recipient_id, snapshots)
_broadcast_movement_animation_updates(peer_ids)
func _should_send_remote_snapshot(
recipient_position: Vector3,
subject_position: Vector3,
) -> bool:
var distance_squared: float = recipient_position.distance_squared_to(
subject_position
)
var divisor: int = DISTANT_REMOTE_SNAPSHOT_DIVISOR
if distance_squared <= NEAR_REMOTE_DISTANCE * NEAR_REMOTE_DISTANCE:
divisor = NEAR_REMOTE_SNAPSHOT_DIVISOR
elif distance_squared <= FAR_REMOTE_DISTANCE * FAR_REMOTE_DISTANCE:
divisor = FAR_REMOTE_SNAPSHOT_DIVISOR
return _movement_snapshot_tick % divisor == 0
func _send_movement_snapshot_batches(
recipient_id: int,
snapshots: Array,
) -> void:
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(),
),
)
var batch: Array = snapshots.slice(
start_index,
mini(
start_index + MOVEMENT_SNAPSHOT_BATCH_SIZE,
snapshots.size(),
),
)
receive_movement_snapshots.rpc_id(recipient_id, batch)
_movement_snapshot_packets_sent += 1
_movement_snapshot_states_sent += batch.size()
func _broadcast_movement_animation_updates(peer_ids: Array[int]) -> void:
var refresh_all: bool = (
_animation_refresh_accumulator >= ANIMATION_REFRESH_INTERVAL
)
if refresh_all:
_animation_refresh_accumulator = fmod(
_animation_refresh_accumulator,
ANIMATION_REFRESH_INTERVAL,
)
var updates: Array = []
for subject_id: int in peer_ids:
var avatar: Player = _spawn_service.get_avatar(subject_id)
if avatar == null:
continue
var state: Dictionary = avatar.make_network_animation_state()
var previous: Dictionary = _last_animation_state_by_peer.get(
subject_id, {}
)
if (
not refresh_all
and _movement_animation_signature(state)
== _movement_animation_signature(previous)
):
continue
_last_animation_state_by_peer[subject_id] = state.duplicate(true)
var encoded: Array = _encode_movement_animation(subject_id, state)
if not encoded.is_empty():
updates.append(encoded)
if updates.is_empty():
return
for recipient_id: int in peer_ids:
if recipient_id == 1:
continue
receive_movement_animations.rpc_id(recipient_id, updates)
_movement_animation_packets_sent += 1
_movement_animation_states_sent += updates.size()
static func _movement_animation_signature(state: Dictionary) -> Array:
if not NetworkPlayerAnimationProtocol.validate_state(state):
return []
var action: Dictionary = state["action"]
return [
str(state["locomotion_id"]),
bool(state["grounded"]),
str(action["id"]),
int(action["sequence"]),
bool(action.get("paused", false)),
]
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"]
var flags: int = 0
if bool(snapshot.get("grounded", false)):
flags |= SNAPSHOT_FLAG_GROUNDED
if bool(snapshot.get("sitting", false)):
flags |= SNAPSHOT_FLAG_SITTING
if bool(snapshot.get("casting", false)):
flags |= SNAPSHOT_FLAG_CASTING
return [
int(snapshot.get("peer_id", 0)),
int(snapshot.get("acknowledged_input", 0)),
@ -1982,14 +2379,7 @@ static func _encode_movement_snapshot(snapshot: Dictionary) -> Array:
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(action.get("paused", false)),
bool(snapshot.get("sitting", false)),
bool(snapshot.get("casting", false)),
flags,
]
@ -2004,18 +2394,22 @@ static func _decode_movement_snapshot(value: Variant) -> Dictionary:
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
or typeof(fields[12]) != TYPE_BOOL
or typeof(fields[5]) != TYPE_INT
):
return {}
var position: Vector3 = fields[2]
var snapshot_velocity: Vector3 = fields[3]
var flags: int = int(fields[5])
if (
int(fields[0]) <= 0
or int(fields[1]) < 0
or not position.is_finite()
or not snapshot_velocity.is_finite()
or not is_finite(float(fields[4]))
or flags < 0
or flags & ~SNAPSHOT_ALLOWED_FLAGS != 0
):
return {}
return {
"peer_id": int(fields[0]),
"acknowledged_input": int(fields[1]),
@ -2026,19 +2420,100 @@ static func _decode_movement_snapshot(value: Variant) -> Dictionary:
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]),
bool(fields[10]),
),
"sitting": bool(fields[11]),
"casting": bool(fields[12]),
"grounded": bool(flags & SNAPSHOT_FLAG_GROUNDED),
"sitting": bool(flags & SNAPSHOT_FLAG_SITTING),
"casting": bool(flags & SNAPSHOT_FLAG_CASTING),
}
static func _encode_movement_animation(
peer_id: int,
state: Dictionary,
) -> Array:
if not NetworkPlayerAnimationProtocol.validate_state(state):
return []
var action: Dictionary = state["action"]
return [
peer_id,
str(state["locomotion_id"]),
bool(state["grounded"]),
str(action["id"]),
int(action["sequence"]),
float(action["elapsed"]),
bool(action.get("paused", false)),
]
static func _decode_movement_animation(value: Variant) -> Dictionary:
if typeof(value) != TYPE_ARRAY:
return {}
var fields: Array = value
if (
fields.size() != MOVEMENT_ANIMATION_FIELD_COUNT
or typeof(fields[0]) != TYPE_INT
or typeof(fields[1]) not in [TYPE_STRING, TYPE_STRING_NAME]
or typeof(fields[2]) != TYPE_BOOL
or typeof(fields[3]) not in [TYPE_STRING, TYPE_STRING_NAME]
or typeof(fields[4]) != TYPE_INT
or typeof(fields[5]) not in [TYPE_FLOAT, TYPE_INT]
or typeof(fields[6]) != TYPE_BOOL
):
return {}
var state: Dictionary = NetworkPlayerAnimationProtocol.make_state(
StringName(str(fields[1])),
bool(fields[2]),
StringName(str(fields[3])),
int(fields[4]),
float(fields[5]),
bool(fields[6]),
)
if (
int(fields[0]) <= 0
or not NetworkPlayerAnimationProtocol.validate_state(state)
):
return {}
return {"peer_id": int(fields[0]), "state": state}
static func _encode_movement_animation_action(
action: Dictionary,
sitting: bool,
) -> Array:
if not NetworkPlayerAnimationProtocol.validate_action_state(action):
return []
return [
str(action["id"]),
int(action["sequence"]),
float(action["elapsed"]),
bool(action.get("paused", false)),
sitting,
]
static func _decode_movement_animation_action(value: Variant) -> Dictionary:
if typeof(value) != TYPE_ARRAY:
return {}
var fields: Array = value
if (
fields.size() != MOVEMENT_ANIMATION_ACTION_FIELD_COUNT
or typeof(fields[0]) not in [TYPE_STRING, TYPE_STRING_NAME]
or typeof(fields[1]) != TYPE_INT
or typeof(fields[2]) not in [TYPE_FLOAT, TYPE_INT]
or typeof(fields[3]) != TYPE_BOOL
or typeof(fields[4]) != TYPE_BOOL
):
return {}
var action: Dictionary = NetworkPlayerAnimationProtocol.make_action_state(
StringName(str(fields[0])),
int(fields[1]),
float(fields[2]),
bool(fields[3]),
)
if not NetworkPlayerAnimationProtocol.validate_action_state(action):
return {}
return {"action": action, "sitting": bool(fields[4])}
@rpc("authority", "call_remote", "unreliable_ordered", 2)
func receive_movement_snapshots(encoded_snapshots: Array) -> void:
if state != State.JOINED_CLIENT:
@ -2058,11 +2533,13 @@ func receive_movement_snapshots(encoded_snapshots: Array) -> void:
if avatar == null:
continue
if peer_id == local_peer_id:
_discard_acknowledged_movement_inputs(
int(snapshot.get("acknowledged_input", 0))
)
avatar.apply_local_prediction_correction(
snapshot,
_input_sequence,
_pending_movement_inputs,
INPUT_INTERVAL,
estimated_transit_seconds,
)
else:
avatar.push_network_snapshot(
@ -2071,6 +2548,39 @@ func receive_movement_snapshots(encoded_snapshots: Array) -> void:
)
func _discard_acknowledged_movement_inputs(acknowledged_sequence: int) -> void:
while (
not _pending_movement_inputs.is_empty()
and int(_pending_movement_inputs[0].get("sequence", 0))
<= acknowledged_sequence
):
_pending_movement_inputs.pop_front()
@rpc(
"authority",
"call_remote",
"reliable",
NetworkProtocol.MOVEMENT_ANIMATION_CHANNEL,
)
func receive_movement_animations(encoded_states: Array) -> void:
if state != State.JOINED_CLIENT:
return
for value: Variant in encoded_states:
var decoded: Dictionary = _decode_movement_animation(value)
if decoded.is_empty():
continue
var avatar: Player = _spawn_service.get_avatar(
int(decoded["peer_id"])
)
if avatar == null:
_pending_animation_state_by_peer[int(decoded["peer_id"])] = (
Dictionary(decoded["state"]).duplicate(true)
)
continue
avatar.apply_network_animation_state(decoded["state"])
func _estimated_movement_transit_seconds() -> float:
if state != State.JOINED_CLIENT:
return -1.0
@ -2346,7 +2856,15 @@ func _teardown_peer() -> void:
_connection_deadline = 0.0
_input_sequence = 0
_input_accumulator = 0.0
_idle_input_accumulator = 0.0
_last_input_state_hash = 0
_pending_movement_inputs.clear()
_snapshot_accumulator = 0.0
_movement_snapshot_tick = 0
_animation_refresh_accumulator = 0.0
_last_animation_state_by_peer.clear()
_pending_animation_state_by_peer.clear()
_last_local_animation_action_signature.clear()
_server_capabilities = PackedStringArray()
_server_identity_fingerprint = ""
_server_identity_public_key = ""