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

@ -21,6 +21,8 @@ const MAX_LEDGER_ENTRIES_PER_PEER: int = 64
const CAST_ORIGIN_TOLERANCE: float = 2.5
const CAPACITY_RESPONSE_TIMEOUT: float = 5.0
const MIN_CAST_INTERVAL: float = 0.25
const OBSERVER_SNAPSHOT_INTERVAL: float = 0.1
const OBSERVER_RELEVANCE_DISTANCE: float = 96.0
signal local_cast_accepted(attempt_id: String, target: Vector3)
signal local_cast_rejected(message: String)
@ -51,7 +53,13 @@ var _last_input_time: Dictionary[int, float] = {}
var _remote_presentations: Dictionary[int, RemoteFishingPresentation] = {}
var _pending_local_bait_by_request: Dictionary[String, StringName] = {}
var _snapshot_accumulator: float = 0.0
var _observer_snapshot_accumulator: float = 0.0
var _local_input_sequence: int = 0
var _presentation_enabled: bool = true
var _owner_snapshot_packets_sent: int = 0
var _owner_snapshot_states_sent: int = 0
var _observer_snapshot_packets_sent: int = 0
var _observer_snapshot_states_sent: int = 0
func setup(
@ -67,6 +75,7 @@ func setup(
item_catalog: ItemCatalog,
fish_catalog: FishPoolType,
item_use: NetworkItemUseService,
presentation_enabled: bool = true,
) -> void:
_session = session
_spawn_service = spawn_service
@ -80,6 +89,7 @@ func setup(
_item_catalog = item_catalog
_fish_catalog = fish_catalog
_item_use = item_use
_presentation_enabled = presentation_enabled
if not _session.peer_removed.is_connected(_on_peer_removed):
_session.peer_removed.connect(_on_peer_removed)
if not _session.state_changed.is_connected(_on_session_state_changed):
@ -130,6 +140,7 @@ func request_local_cast(
"capacity_available": bool(evidence.get("capacity_available", false)),
"bait_id": str(bait_id),
"lure_id": str(lure_id),
"movement_sequence": _session.get_latest_movement_input_sequence(),
}
if _session.is_host():
_handle_cast_request(_session.get_local_peer_id(), data)
@ -213,15 +224,27 @@ func _process(delta: float) -> void:
if now >= attempt.capacity_deadline:
_cancel_attempt(peer_id, "Fishing attempt ended.")
_snapshot_accumulator += delta
_observer_snapshot_accumulator += delta
if _snapshot_accumulator >= NetworkFishingProtocol.SNAPSHOT_RATE:
_snapshot_accumulator = fmod(
_snapshot_accumulator,
NetworkFishingProtocol.SNAPSHOT_RATE
)
_broadcast_snapshots()
_broadcast_owner_snapshots()
if _observer_snapshot_accumulator >= OBSERVER_SNAPSHOT_INTERVAL:
_observer_snapshot_accumulator = fmod(
_observer_snapshot_accumulator,
OBSERVER_SNAPSHOT_INTERVAL,
)
_broadcast_observer_snapshots()
@rpc("any_peer", "call_remote", "reliable", 0)
@rpc(
"any_peer",
"call_remote",
"reliable",
NetworkFishingProtocol.RELIABLE_CHANNEL,
)
func submit_cast_request(data: Dictionary) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if not _session.is_host() or not _session.is_authenticated_peer(sender_id):
@ -268,7 +291,13 @@ func _handle_cast_request(peer_id: int, data: Dictionary) -> void:
return
var origin: Vector3 = NetworkFishingProtocol.array_to_vector3(data["origin"])
var target: Vector3 = NetworkFishingProtocol.array_to_vector3(data["target"])
var authoritative_origin: Vector3 = avatar.get_cast_origin_position()
var movement_state: Dictionary = avatar.get_lag_compensated_movement_state(
int(data.get("movement_sequence", 0))
)
var authoritative_origin: Vector3 = movement_state.get(
"cast_origin",
avatar.get_cast_origin_position(),
)
if origin.distance_to(authoritative_origin) > CAST_ORIGIN_TOLERANCE:
_record_and_reject(peer_id, request_id, "Cannot fish here.")
return
@ -279,7 +308,10 @@ func _handle_cast_request(peer_id: int, data: Dictionary) -> void:
_fishing_spot.maximum_cast_distance,
float(data["charge"])
)
var facing: Vector3 = avatar.get_facing_direction()
var facing: Vector3 = movement_state.get(
"facing",
avatar.get_facing_direction(),
)
facing.y = 0.0
if (
cast_offset.length() < _fishing_spot.minimum_cast_distance - 0.25
@ -642,7 +674,12 @@ func _handle_fishing_input(peer_id: int, data: Dictionary) -> void:
attempt.controller.handle_primary_pressed()
@rpc("any_peer", "call_remote", "reliable", 0)
@rpc(
"any_peer",
"call_remote",
"reliable",
NetworkFishingProtocol.RELIABLE_CHANNEL,
)
func submit_cancel_request(attempt_id: String) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
var attempt: NetworkFishingAttempt = _attempts.get(sender_id)
@ -687,16 +724,59 @@ func _on_encounter_updated(
})
func _broadcast_snapshots() -> void:
var snapshots: Array[Dictionary] = []
func _broadcast_owner_snapshots() -> void:
for attempt: NetworkFishingAttempt in _attempts.values():
var snapshot: Dictionary = attempt.get_meta("snapshot", {})
if not snapshot.is_empty():
snapshots.append(snapshot)
if snapshots.is_empty():
if snapshot.is_empty():
continue
var owner_peer_id: int = attempt.owner_peer_id
if owner_peer_id == _session.get_local_peer_id():
_apply_snapshots([snapshot])
elif owner_peer_id > 1:
receive_fishing_snapshots.rpc_id(owner_peer_id, [snapshot])
_owner_snapshot_packets_sent += 1
_owner_snapshot_states_sent += 1
func _broadcast_observer_snapshots() -> void:
if _attempts.is_empty():
return
_apply_snapshots(snapshots)
receive_fishing_snapshots.rpc(snapshots)
var observer_ids: Array[int] = _session.get_authenticated_peer_ids()
for observer_peer_id: int in observer_ids:
var observer_avatar: Player = _spawn_service.get_avatar(observer_peer_id)
if observer_avatar == null:
continue
var summaries: Array = []
for attempt: NetworkFishingAttempt in _attempts.values():
if attempt.owner_peer_id == observer_peer_id:
continue
var owner_avatar: Player = _spawn_service.get_avatar(
attempt.owner_peer_id
)
if (
owner_avatar == null
or observer_avatar.global_position.distance_squared_to(
owner_avatar.global_position
) > OBSERVER_RELEVANCE_DISTANCE * OBSERVER_RELEVANCE_DISTANCE
):
continue
summaries.append([
attempt.attempt_id,
attempt.owner_peer_id,
attempt.bobber_position,
int(attempt.phase),
])
if summaries.is_empty():
continue
if observer_peer_id == _session.get_local_peer_id():
_apply_observer_snapshots(summaries)
elif observer_peer_id > 1:
receive_fishing_observer_snapshots.rpc_id(
observer_peer_id,
summaries,
)
_observer_snapshot_packets_sent += 1
_observer_snapshot_states_sent += summaries.size()
@rpc("authority", "call_remote", "unreliable_ordered", 4)
@ -704,6 +784,55 @@ func receive_fishing_snapshots(snapshots: Array) -> void:
_apply_snapshots(snapshots)
@rpc(
"authority",
"call_remote",
"unreliable_ordered",
NetworkFishingProtocol.OBSERVER_SNAPSHOT_CHANNEL,
)
func receive_fishing_observer_snapshots(summaries: Array) -> void:
_apply_observer_snapshots(summaries)
func _apply_observer_snapshots(summaries: Array) -> void:
if not _presentation_enabled or summaries.size() > 128:
return
for value: Variant in summaries:
if typeof(value) != TYPE_ARRAY:
continue
var fields: Array = value
if (
fields.size() != 4
or typeof(fields[0]) != TYPE_STRING
or str(fields[0]).is_empty()
or str(fields[0]).length() > NetworkFishingProtocol.MAX_ID_LENGTH
or typeof(fields[1]) != TYPE_INT
or int(fields[1]) <= 0
or typeof(fields[2]) != TYPE_VECTOR3
or typeof(fields[3]) != TYPE_INT
or not (fields[2] as Vector3).is_finite()
or int(fields[3]) not in [
NetworkFishingAttempt.Phase.WAITING_FOR_BITE,
NetworkFishingAttempt.Phase.FIGHTING,
NetworkFishingAttempt.Phase.PENDING_CAPACITY,
]
):
continue
var owner_peer_id: int = int(fields[1])
if owner_peer_id == _session.get_local_peer_id():
continue
var presentation := _get_remote_presentation(owner_peer_id)
if presentation != null:
presentation.synchronize_active(
str(fields[0]),
fields[2],
int(fields[3]) in [
NetworkFishingAttempt.Phase.FIGHTING,
NetworkFishingAttempt.Phase.PENDING_CAPACITY,
],
)
func _apply_snapshots(snapshots: Array) -> void:
var local_peer_id: int = _session.get_local_peer_id()
for value: Variant in snapshots:
@ -810,7 +939,12 @@ func _on_attempt_caught(peer_id: int) -> void:
receive_capacity_probe.rpc_id(peer_id, probe)
@rpc("authority", "call_remote", "reliable", 0)
@rpc(
"authority",
"call_remote",
"reliable",
NetworkFishingProtocol.RELIABLE_CHANNEL,
)
func receive_capacity_probe(data: Dictionary) -> void:
_handle_local_capacity_probe(data)
@ -844,7 +978,12 @@ func _handle_local_capacity_probe(data: Dictionary) -> void:
)
@rpc("any_peer", "call_remote", "reliable", 0)
@rpc(
"any_peer",
"call_remote",
"reliable",
NetworkFishingProtocol.RELIABLE_CHANNEL,
)
func submit_capacity_response(
attempt_id: String,
capacity_nonce: String,
@ -902,7 +1041,12 @@ func _finalize_catch(attempt: NetworkFishingAttempt) -> void:
_dispose_attempt(attempt.owner_peer_id)
@rpc("authority", "call_remote", "reliable", 0)
@rpc(
"authority",
"call_remote",
"reliable",
NetworkFishingProtocol.RELIABLE_CHANNEL,
)
func receive_target_outcome(data: Dictionary) -> void:
_apply_target_outcome(data)
@ -934,7 +1078,7 @@ func _apply_target_outcome(data: Dictionary) -> void:
)
)
_local_inventory.add_catch(fish_catch)
_local_collection.mark_quality_discovered(
_local_collection.record_catch(
fish_id,
fish_catch.quality,
)
@ -970,7 +1114,12 @@ func _acknowledge_result(result_id: String, catch_id: StringName) -> void:
acknowledge_fishing_result.rpc_id(1, result_id, str(catch_id))
@rpc("any_peer", "call_remote", "reliable", 0)
@rpc(
"any_peer",
"call_remote",
"reliable",
NetworkFishingProtocol.RELIABLE_CHANNEL,
)
func acknowledge_fishing_result(result_id: String, catch_id: String) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if (
@ -1027,7 +1176,12 @@ func _broadcast_public_outcome(
receive_public_outcome.rpc(data)
@rpc("authority", "call_remote", "reliable", 0)
@rpc(
"authority",
"call_remote",
"reliable",
NetworkFishingProtocol.RELIABLE_CHANNEL,
)
func receive_public_outcome(data: Dictionary) -> void:
_apply_public_outcome(data)
@ -1079,7 +1233,12 @@ func _broadcast_cast_accepted(data: Dictionary) -> void:
receive_cast_accepted.rpc(data)
@rpc("authority", "call_remote", "reliable", 0)
@rpc(
"authority",
"call_remote",
"reliable",
NetworkFishingProtocol.RELIABLE_CHANNEL,
)
func receive_cast_accepted(data: Dictionary) -> void:
_apply_cast_accepted(data)
@ -1114,10 +1273,19 @@ func _apply_cast_accepted(data: Dictionary) -> void:
else:
var presentation := _get_remote_presentation(peer_id)
if presentation != null:
presentation.show_cast(origin, target)
presentation.show_cast(
origin,
target,
str(data["attempt_id"]),
)
@rpc("authority", "call_remote", "reliable", 0)
@rpc(
"authority",
"call_remote",
"reliable",
NetworkFishingProtocol.RELIABLE_CHANNEL,
)
func receive_bite_pending(data: Dictionary) -> void:
_apply_bite_pending(data)
@ -1132,7 +1300,12 @@ func _apply_bite_pending(data: Dictionary) -> void:
local_bite_pending.emit(str(data["attempt_id"]))
@rpc("authority", "call_remote", "reliable", 0)
@rpc(
"authority",
"call_remote",
"reliable",
NetworkFishingProtocol.RELIABLE_CHANNEL,
)
func receive_bite_started(data: Dictionary) -> void:
_apply_bite_started(data)
@ -1193,7 +1366,12 @@ func _send_cast_rejected(
receive_cast_rejected.rpc_id(peer_id, request_id, message.left(128))
@rpc("authority", "call_remote", "reliable", 0)
@rpc(
"authority",
"call_remote",
"reliable",
NetworkFishingProtocol.RELIABLE_CHANNEL,
)
func receive_cast_rejected(request_id: String, message: String) -> void:
_pending_local_bait_by_request.erase(request_id)
local_cast_rejected.emit(message)
@ -1228,6 +1406,8 @@ func _resend_request_response(peer_id: int, response: Dictionary) -> void:
func _get_remote_presentation(
peer_id: int,
) -> RemoteFishingPresentation:
if not _presentation_enabled:
return null
var existing: RemoteFishingPresentation = _remote_presentations.get(peer_id)
if existing != null and is_instance_valid(existing):
return existing
@ -1330,9 +1510,21 @@ func _clear_all() -> void:
_last_input_time.clear()
_pending_local_bait_by_request.clear()
_snapshot_accumulator = 0.0
_observer_snapshot_accumulator = 0.0
_local_input_sequence = 0
func get_network_metrics() -> Dictionary:
return {
"active_attempts": _attempts.size(),
"remote_presentations": _remote_presentations.size(),
"owner_snapshot_packets_sent": _owner_snapshot_packets_sent,
"owner_snapshot_states_sent": _owner_snapshot_states_sent,
"observer_snapshot_packets_sent": _observer_snapshot_packets_sent,
"observer_snapshot_states_sent": _observer_snapshot_states_sent,
}
func _bound_result_ledger() -> void:
while _result_ledgers.size() > MAX_LEDGER_ENTRIES_PER_PEER:
_result_ledgers.erase(_result_ledgers.keys().front())