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

@ -16,7 +16,9 @@ const CalendarSeasonType = preload("res://world/calendar_season.gd")
signal local_capture_received(fish_catch: FishCatch)
signal local_interaction_finished(accepted: bool, message: String)
const SNAPSHOT_INTERVAL_SECONDS: float = 0.2
const SIMULATION_INTERVAL_SECONDS: float = 1.0 / 15.0
const SNAPSHOT_INTERVAL_SECONDS: float = 0.1
const PLAYER_SPATIAL_CELL_SIZE: float = 8.0
const CAPACITY_RESPONSE_TIMEOUT_SECONDS: float = 3.0
const MAX_REQUEST_ID_LENGTH: int = 128
const MAX_LEDGER_ENTRIES: int = 96
@ -49,8 +51,16 @@ var _pending_captures: Dictionary = {}
var _received_results: Dictionary = {}
var _showcase_deadlines: Dictionary = {}
var _envelope_sequence: int = 0
var _simulation_elapsed: float = 0.0
var _snapshot_elapsed: float = 0.0
var _population_session_id: String = ""
var _dirty_entity_ids: Dictionary[String, bool] = {}
var _moving_players_by_cell: Dictionary[Vector2i, Array] = {}
var _presentation_enabled: bool = true
var _spawn_events_sent: int = 0
var _despawn_events_sent: int = 0
var _snapshot_packets_sent: int = 0
var _snapshot_states_sent: int = 0
var _rng := RandomNumberGenerator.new()
@ -68,6 +78,7 @@ func setup(
save_manager: PlayerSaveManager,
item_use: NetworkItemUseService,
world_time: WorldTimeService = null,
presentation_enabled: bool = true,
) -> void:
_session = session
_spawn_service = spawn_service
@ -82,6 +93,7 @@ func setup(
_save_manager = save_manager
_item_use = item_use
_world_time = world_time
_presentation_enabled = presentation_enabled
_rng.randomize()
_session.peer_authenticated.connect(_on_peer_authenticated)
_session.peer_removed.connect(_on_peer_removed)
@ -136,6 +148,7 @@ func finish_local_interaction(
"target_position": NetworkWorldSpawnProtocol.vector3_to_array(
target_position
),
"movement_sequence": _session.get_latest_movement_input_sequence(),
}
if _session.is_host():
_handle_interaction_finish(_session.get_local_peer_id(), data)
@ -217,6 +230,13 @@ func get_entry_for_entity(entity_id: String) -> GatherableDataType:
return state.get("data") as GatherableDataType
func refresh_world_context() -> void:
_clear_world()
_population_session_id = ""
if _session != null and _session.is_gameplay_session_active():
_begin_population_if_ready.call_deferred()
func _physics_process(delta: float) -> void:
if (
_session == null
@ -225,11 +245,27 @@ func _physics_process(delta: float) -> void:
):
return
_begin_population_if_ready()
# There is no gameplay state to advance while a dedicated server has no
# spawned players. Pausing the roaming/respawn loop also prevents idle
# gatherables from producing a continuous stream of snapshots to nobody.
if _spawn_service == null or _spawn_service.get_peer_ids().is_empty():
_simulation_elapsed = 0.0
_snapshot_elapsed = 0.0
return
_simulation_elapsed += delta
_snapshot_elapsed += delta
if _simulation_elapsed < SIMULATION_INTERVAL_SECONDS:
return
var simulation_delta: float = minf(_simulation_elapsed, 0.2)
_simulation_elapsed = fmod(
_simulation_elapsed,
SIMULATION_INTERVAL_SECONDS,
)
_update_pending_capture_timeouts()
_update_respawns()
_update_showcase_deadlines()
_update_host_entities(delta)
_snapshot_elapsed += delta
_rebuild_moving_player_spatial_index()
_update_host_entities(simulation_delta)
if _snapshot_elapsed >= SNAPSHOT_INTERVAL_SECONDS:
_snapshot_elapsed = fmod(
_snapshot_elapsed,
@ -246,6 +282,7 @@ func _begin_population_if_ready() -> void:
or _catalog == null
or _world == null
or _world_root == null
or not _world.is_world_ready()
):
return
var session_id: String = _session.get_session_id()
@ -273,7 +310,10 @@ func _cache_spawn_surface(entry: GatherableDataType) -> void:
_world.get_gatherable_spawn_positions(entry.spawn_anchor_set_id)
)
_spawn_anchor_positions[entry.type_id] = anchor_positions
if anchor_positions.is_empty():
if (
anchor_positions.is_empty()
and _world.get_world_layout() == WorldLayout.GENERATED
):
push_warning(
"No gatherable spawn anchors were found for %s." % entry.type_id
)
@ -299,7 +339,10 @@ func _cache_spawn_surface(entry: GatherableDataType) -> void:
_surface_triangles[entry.type_id] = triangles
_surface_areas[entry.type_id] = areas
_surface_total_areas[entry.type_id] = total_area
if triangles.is_empty():
if (
triangles.is_empty()
and _world.get_world_layout() == WorldLayout.GENERATED
):
push_warning(
"No valid spawn surface was found for %s." % entry.type_id
)
@ -341,6 +384,7 @@ func _spawn_entity(entry: GatherableDataType) -> void:
)
_apply_envelope(envelope)
receive_world_envelope.rpc(envelope)
_spawn_events_sent += 1
func _update_host_entities(delta: float) -> void:
@ -392,6 +436,8 @@ func _update_host_entities(delta: float) -> void:
minf(step / maxf(horizontal_delta.length(), 0.001), 1.0),
)
state["yaw"] = atan2(-direction.x, -direction.z)
state["revision"] = int(state["revision"]) + 1
_dirty_entity_ids[entity_id] = true
state["position"] = position
_entities[entity_id] = state
if entry.can_be_scared() and _should_scare(entry, position, quality):
@ -405,6 +451,34 @@ func _should_scare(
) -> bool:
var scare_radius: float = entry.get_scare_radius_for_quality(quality)
var radius_squared: float = scare_radius * scare_radius
var center_cell := _player_spatial_cell(position)
var cell_radius: int = ceili(scare_radius / PLAYER_SPATIAL_CELL_SIZE)
for cell_x: int in range(
center_cell.x - cell_radius,
center_cell.x + cell_radius + 1,
):
for cell_y: int in range(
center_cell.y - cell_radius,
center_cell.y + cell_radius + 1,
):
for value: Variant in _moving_players_by_cell.get(
Vector2i(cell_x, cell_y),
[],
):
var avatar := value as Player
if avatar == null:
continue
var player_delta := Vector2(
avatar.global_position.x - position.x,
avatar.global_position.z - position.z,
)
if player_delta.length_squared() <= radius_squared:
return true
return false
func _rebuild_moving_player_spatial_index() -> void:
_moving_players_by_cell.clear()
for peer_id: int in _spawn_service.get_peer_ids():
var avatar: Player = _spawn_service.get_avatar(peer_id)
if (
@ -413,13 +487,17 @@ func _should_scare(
or avatar.is_sneaking()
):
continue
var delta := Vector2(
avatar.global_position.x - position.x,
avatar.global_position.z - position.z,
)
if delta.length_squared() <= radius_squared:
return true
return false
var cell: Vector2i = _player_spatial_cell(avatar.global_position)
var players: Array = _moving_players_by_cell.get(cell, [])
players.append(avatar)
_moving_players_by_cell[cell] = players
static func _player_spatial_cell(position: Vector3) -> Vector2i:
return Vector2i(
floori(position.x / PLAYER_SPATIAL_CELL_SIZE),
floori(position.z / PLAYER_SPATIAL_CELL_SIZE),
)
func _sample_surface_position(
@ -506,28 +584,69 @@ func _anchor_is_occupied(type_id: StringName, anchor: Vector3) -> bool:
func _broadcast_entity_snapshots() -> void:
if _entities.is_empty():
if _dirty_entity_ids.is_empty() or not _can_broadcast_snapshots():
return
var has_remote_recipients: bool = _has_authenticated_remote_peers()
var snapshots: Array[Dictionary] = []
for entity_id: String in _entities:
var state: Dictionary = _entities[entity_id]
state["revision"] = int(state["revision"]) + 1
_entities[entity_id] = state
snapshots.append(_state_to_network(state))
while not snapshots.is_empty():
for entity_id: String in _dirty_entity_ids:
var state: Dictionary = _entities.get(entity_id, {})
if not state.is_empty():
snapshots.append(_state_to_network(state))
_dirty_entity_ids.clear()
var maximum_chunk_size: int = (
NetworkWorldSpawnProtocol.SNAPSHOT_ENTITIES_PER_ENVELOPE
)
for start_index: int in range(
0,
snapshots.size(),
maximum_chunk_size,
):
var chunk: Array[Dictionary] = []
var chunk_size: int = mini(
NetworkWorldSpawnProtocol.SNAPSHOT_ENTITIES_PER_ENVELOPE,
snapshots.size(),
)
for _index: int in chunk_size:
chunk.append(snapshots.pop_front())
for value: Dictionary in snapshots.slice(
start_index,
mini(start_index + maximum_chunk_size, snapshots.size()),
):
chunk.append(value)
var envelope: Dictionary = _make_envelope(
&"snapshot",
{"entities": chunk},
)
_apply_envelope(envelope)
if not has_remote_recipients:
continue
# A peer-disconnected callback can resume validation/gameplay code and
# close the host while this physics update is still unwinding. Never send
# through the replacement OfflineMultiplayerPeer in that narrow window.
if not _can_broadcast_snapshots():
return
receive_world_snapshot_envelope.rpc(envelope)
_snapshot_packets_sent += 1
_snapshot_states_sent += chunk.size()
func _has_authenticated_remote_peers() -> bool:
if _session == null:
return false
var local_peer_id: int = _session.get_local_peer_id()
for peer_id: int in _session.get_authenticated_peer_ids():
if peer_id != local_peer_id:
return true
return false
func _can_broadcast_snapshots() -> bool:
if (
_session == null
or not _session.is_host()
or not _session.is_gameplay_session_active()
):
return false
var peer: MultiplayerPeer = multiplayer.multiplayer_peer
return (
peer != null
and peer.get_connection_status()
== MultiplayerPeer.CONNECTION_CONNECTED
)
func _despawn_entity(
@ -541,6 +660,7 @@ func _despawn_entity(
return
var entry := state.get("data") as GatherableDataType
_entities.erase(entity_id)
_dirty_entity_ids.erase(entity_id)
var envelope: Dictionary = _make_envelope(
&"despawn",
{
@ -551,6 +671,7 @@ func _despawn_entity(
)
_apply_envelope(envelope)
receive_world_envelope.rpc(envelope)
_despawn_events_sent += 1
if schedule_respawn and entry != null:
_respawns.append({
"type_id": entry.type_id,
@ -633,6 +754,7 @@ func _handle_interaction_finish(peer_id: int, data: Dictionary) -> void:
var state: Dictionary = _entities.get(entity_id, {})
var entry := state.get("data") as GatherableDataType
var avatar: Player = _spawn_service.get_avatar(peer_id)
var movement_sequence_value: Variant = data.get("movement_sequence", null)
var error: String = ""
if (
str(data.get("session_id", "")) != _session.get_session_id()
@ -640,6 +762,9 @@ func _handle_interaction_finish(peer_id: int, data: Dictionary) -> void:
or request_id.length() > MAX_REQUEST_ID_LENGTH
or str(charge.get("request_id", "")) != request_id
or not target_position.is_finite()
or typeof(movement_sequence_value) != TYPE_INT
or int(movement_sequence_value) < 0
or int(movement_sequence_value) > 2147483647
):
error = "The catch attempt was invalid."
elif state.is_empty() or entry == null or bool(state.get("locked", false)):
@ -650,14 +775,28 @@ func _handle_interaction_finish(peer_id: int, data: Dictionary) -> void:
error = "Equip the correct gathering tool."
elif avatar == null:
error = "The player is unavailable."
elif entry.requires_sneaking and not avatar.is_sneaking():
error = "Sneak closer before using the tool."
else:
var movement_state: Dictionary = (
avatar.get_lag_compensated_movement_state(
int(movement_sequence_value)
)
)
if entry.requires_sneaking and not bool(
movement_state.get("sneaking", avatar.is_sneaking())
):
error = "Sneak closer before using the tool."
if not error.is_empty():
_send_interaction_result(peer_id, request_id, false, error)
return
var entity_position: Vector3 = state["position"]
var target_distance: float = entity_position.distance_to(target_position)
var compensated_position: Vector3 = movement_state.get(
"position",
avatar.global_position,
)
var player_distance: float = Vector2(
avatar.global_position.x - entity_position.x,
avatar.global_position.z - entity_position.z,
compensated_position.x - entity_position.x,
compensated_position.z - entity_position.z,
).length()
if target_distance > entry.capture_radius:
error = "The gathering tool missed."
@ -903,7 +1042,7 @@ func _apply_capture_result(data: Dictionary) -> void:
_local_collection,
)
_local_inventory.add_catch(fish_catch)
_local_collection.mark_quality_discovered(
_local_collection.record_catch(
fish_catch.fish_id,
fish_catch.quality,
)
@ -1078,6 +1217,9 @@ func _apply_entity_state(value: Variant, immediate: bool) -> void:
var position: Vector3 = NetworkWorldSpawnProtocol.array_to_vector3(
state["position"]
)
if not _presentation_enabled:
_entity_revisions[entity_id] = revision
return
var presentation := _presentations.get(entity_id) as WorldGatherableType
if presentation == null or not is_instance_valid(presentation):
presentation = WorldGatherableType.new()
@ -1127,6 +1269,8 @@ func _remove_presentation(entity_id: String, with_dust: bool) -> void:
func _apply_showcase(payload: Dictionary) -> void:
if not _presentation_enabled:
return
if (
typeof(payload.get("owner_peer_id")) != TYPE_INT
or typeof(payload.get("visible")) != TYPE_BOOL
@ -1268,6 +1412,8 @@ func _clear_world() -> void:
if presentation != null and is_instance_valid(presentation):
presentation.queue_free()
_entities.clear()
_dirty_entity_ids.clear()
_moving_players_by_cell.clear()
_presentations.clear()
_entity_revisions.clear()
_surface_triangles.clear()
@ -1279,9 +1425,22 @@ func _clear_world() -> void:
_charge_requests.clear()
_pending_captures.clear()
_showcase_deadlines.clear()
_simulation_elapsed = 0.0
_snapshot_elapsed = 0.0
func get_network_metrics() -> Dictionary:
return {
"entities": _entities.size(),
"presentations": _presentations.size(),
"dirty_entities": _dirty_entity_ids.size(),
"spawn_events_sent": _spawn_events_sent,
"despawn_events_sent": _despawn_events_sent,
"snapshot_packets_sent": _snapshot_packets_sent,
"snapshot_states_sent": _snapshot_states_sent,
}
func _bound(values: Dictionary) -> void:
while values.size() > MAX_LEDGER_ENTRIES:
values.erase(values.keys().front())