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:
parent
1db1a5b754
commit
3b84bfe3a0
97 changed files with 7869 additions and 982 deletions
|
|
@ -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())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue