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

@ -139,6 +139,10 @@ const NETWORK_SNAPSHOT_JITTER_WEIGHT: float = 0.15
const NETWORK_REMOTE_SMOOTHING_RATE: float = 12.0
const NETWORK_REMOTE_JITTER_SMOOTHING_RATE: float = 6.0
const NETWORK_INPUT_STALE_TIMEOUT_SECONDS: float = 0.25
const NETWORK_INPUT_STALE_TIMEOUT_MAX_SECONDS: float = 1.25
const NETWORK_INPUT_STALE_RTT_MULTIPLIER: float = 1.5
const NETWORK_MOVEMENT_HISTORY_SECONDS: float = 1.25
const NETWORK_MAX_LAG_COMPENSATION_SECONDS: float = 0.75
const LOCAL_PREDICTION_EXTRAPOLATION_LIMIT_SECONDS: float = 0.25
const LOCAL_PREDICTION_FALLBACK_TRANSIT_RATIO: float = 0.5
const LOCAL_PREDICTION_CORRECTION_THRESHOLD: float = 0.12
@ -417,6 +421,7 @@ class ShowcaseCameraSnapshot:
@export var controller_camera_speed: float = 2.5
@export_range(0.0, 1.0, 0.01) var controller_camera_deadzone: float = 0.2
@export var invert_camera_y: bool = false
var _swap_hotbar_camera_scroll: bool = false
@export_range(-89.0, 0.0, 0.5) var minimum_pitch_degrees: float = -65.0
@export_range(0.0, 89.0, 0.5) var maximum_pitch_degrees: float = 45.0
@export var minimum_zoom: float = 2.0
@ -506,6 +511,9 @@ var _network_slow_walk: bool = false
var _last_network_input_sequence: int = 0
var _network_input_age: float = 0.0
var _network_input_stale: bool = false
var _network_input_stale_timeout_seconds: float = (
NETWORK_INPUT_STALE_TIMEOUT_SECONDS
)
var _network_jump_intent_active: bool = false
var _network_target_position: Vector3
var _network_target_velocity: Vector3
@ -519,6 +527,9 @@ var _network_target_animation_action_paused: bool = false
var _network_snapshot_ready: bool = false
var _network_snapshot_age: float = 0.0
var _network_snapshot_jitter: float = 0.0
var _network_simulation_only: bool = false
var _local_reconciliation_visual_offset: Vector3 = Vector3.ZERO
var _authoritative_movement_history: Array[Dictionary] = []
var _local_network_jump_intent_pending: bool = false
var _local_network_jump_intent_sequence: int = -1
var _animation_action_id: StringName = &""
@ -791,10 +802,16 @@ func _finish_successful_net_strike_pause() -> void:
func _set_animation_action_paused(paused: bool) -> void:
if _animation_action_id.is_empty() or _animation_action_paused == paused:
return
var next_sequence: int = (
1
if _animation_action_sequence
>= NetworkPlayerAnimationProtocol.MAX_ACTION_SEQUENCE
else _animation_action_sequence + 1
)
_apply_animation_action_state(
NetworkPlayerAnimationProtocol.make_action_state(
_animation_action_id,
_animation_action_sequence,
next_sequence,
_animation_action_elapsed,
paused,
)
@ -972,6 +989,12 @@ func _physics_process(delta: float) -> void:
if _network_interpolation_enabled:
_update_network_interpolation(delta)
return
_simulate_movement_physics(delta)
if _network_authoritative_simulation:
_record_authoritative_movement_state()
func _simulate_movement_physics(delta: float) -> void:
if _network_authoritative_simulation:
_update_network_input_freshness(delta)
if local_control_enabled and _free_camera_active:
@ -1075,6 +1098,9 @@ func _physics_process(delta: float) -> void:
func _process(delta: float) -> void:
if _network_simulation_only:
return
_update_local_reconciliation_visuals(delta)
if not _animation_action_id.is_empty() and not _animation_action_paused:
_animation_action_elapsed = minf(
_animation_action_elapsed + delta,
@ -1607,6 +1633,23 @@ func is_sitting() -> bool:
return _sitting
func get_network_sitting_state() -> bool:
return _sitting or _sit_after_landing
func apply_network_sitting_state(should_sit: bool) -> void:
_set_sitting(should_sit)
func apply_authoritative_network_sitting_state(should_sit: bool) -> void:
if should_sit and not is_on_floor():
_sit_after_landing = true
_set_sitting(false)
return
_sit_after_landing = false
_set_sitting(should_sit)
func _input(event: InputEvent) -> void:
# Camera dragging must observe the complete mouse gesture before GUI controls
# get a chance to consume one part of it. Do not claim the event here: the
@ -1648,7 +1691,7 @@ func _unhandled_input(event: InputEvent) -> void:
var mouse_zoom_in: bool = (
event is InputEventMouseButton
and event.shift_pressed
and event.shift_pressed != _swap_hotbar_camera_scroll
and event.is_action_pressed("camera_zoom_in")
)
var controller_zoom_in: bool = (
@ -1657,7 +1700,7 @@ func _unhandled_input(event: InputEvent) -> void:
)
var mouse_zoom_out: bool = (
event is InputEventMouseButton
and event.shift_pressed
and event.shift_pressed != _swap_hotbar_camera_scroll
and event.is_action_pressed("camera_zoom_out")
)
var controller_zoom_out: bool = (
@ -1878,10 +1921,14 @@ func get_network_peer_id() -> int:
return _network_peer_id
func configure_network_remote(authoritative_simulation: bool) -> void:
func configure_network_remote(
authoritative_simulation: bool,
simulation_only: bool = false,
) -> void:
set_local_control(false)
_network_authoritative_simulation = authoritative_simulation
_network_interpolation_enabled = not authoritative_simulation
set_network_simulation_only(simulation_only)
_network_axis = Vector2.ZERO
_network_jump_pending = false
_network_sprint = false
@ -1889,6 +1936,7 @@ func configure_network_remote(authoritative_simulation: bool) -> void:
_network_slow_walk = false
_network_input_age = 0.0
_network_input_stale = false
_network_input_stale_timeout_seconds = NETWORK_INPUT_STALE_TIMEOUT_SECONDS
_network_jump_intent_active = false
_network_snapshot_ready = false
_network_snapshot_age = 0.0
@ -1900,10 +1948,43 @@ func configure_network_remote(authoritative_simulation: bool) -> void:
_network_target_animation_action_elapsed = 0.0
_network_target_animation_action_paused = false
_camera.current = false
_authoritative_movement_history.clear()
func set_network_simulation_only(enabled: bool) -> void:
_network_simulation_only = enabled
if not is_node_ready():
return
set_process(not enabled)
for presentation_root: Node in [
_visuals,
get_node_or_null("GroundShadow"),
get_node_or_null("SprintDust"),
_camera_yaw,
]:
if presentation_root == null:
continue
presentation_root.process_mode = (
Node.PROCESS_MODE_DISABLED
if enabled
else Node.PROCESS_MODE_INHERIT
)
if presentation_root is Node3D:
(presentation_root as Node3D).visible = not enabled
func is_network_simulation_only() -> bool:
return _network_simulation_only
func reset_network_movement_state() -> void:
_clear_local_network_jump_intent()
if not _animation_action_id.is_empty():
end_animation_action()
_sit_after_landing = false
_sitting_intent_pending = false
_sitting_intent_sequence = -1
_set_sitting(false)
_network_axis = Vector2.ZERO
_network_jump_pending = false
_network_sprint = false
@ -1911,6 +1992,7 @@ func reset_network_movement_state() -> void:
_network_slow_walk = false
_network_input_age = 0.0
_network_input_stale = false
_network_input_stale_timeout_seconds = NETWORK_INPUT_STALE_TIMEOUT_SECONDS
_network_jump_intent_active = false
_last_network_input_sequence = 0
@ -1939,7 +2021,7 @@ func capture_network_input(sequence: int) -> Dictionary:
"sprint": false,
"sneak": false,
"slow_walk": false,
"sitting": _sitting,
"sitting": get_network_sitting_state(),
"casting": (
_fishing_visual_phase == FishingVisualPhase.CASTING
),
@ -1964,13 +2046,66 @@ func capture_network_input(sequence: int) -> Dictionary:
"sprint": Input.is_action_pressed("sprint"),
"sneak": Input.is_action_pressed("sneak"),
"slow_walk": Input.is_action_pressed("slow_walk"),
"sitting": _sitting,
"sitting": get_network_sitting_state(),
"casting": _fishing_visual_phase == FishingVisualPhase.CASTING,
"animation_action": _make_animation_action_state(),
}
func apply_authoritative_network_input(data: Dictionary) -> void:
func has_active_network_input() -> bool:
if not local_control_enabled:
return false
if _local_network_jump_intent_pending or _sitting_intent_pending:
return true
if not _is_movement_input_enabled() or _free_camera_active:
return false
return (
not Input.get_vector(
"move_left",
"move_right",
"move_forward",
"move_backward",
).is_zero_approx()
or Input.is_action_pressed("sprint")
or Input.is_action_pressed("sneak")
or Input.is_action_pressed("slow_walk")
or _fishing_visual_phase == FishingVisualPhase.CASTING
or not _animation_action_id.is_empty()
)
func get_network_input_state_hash() -> int:
var axis: Vector2 = Vector2.ZERO
if (
local_control_enabled
and _is_movement_input_enabled()
and not _free_camera_active
):
axis = Input.get_vector(
"move_left",
"move_right",
"move_forward",
"move_backward",
)
return hash([
axis,
_camera_yaw.global_rotation.y,
_local_network_jump_intent_pending,
Input.is_action_pressed("sprint"),
Input.is_action_pressed("sneak"),
Input.is_action_pressed("slow_walk"),
get_network_sitting_state(),
_fishing_visual_phase == FishingVisualPhase.CASTING,
_animation_action_id,
_animation_action_sequence,
_animation_action_paused,
])
func apply_authoritative_network_input(
data: Dictionary,
stale_timeout_seconds: float = NETWORK_INPUT_STALE_TIMEOUT_SECONDS,
) -> void:
var sequence: int = int(data.get("sequence", 0))
if sequence <= _last_network_input_sequence:
return
@ -1980,6 +2115,11 @@ func apply_authoritative_network_input(data: Dictionary) -> void:
_last_network_input_sequence = sequence
_network_input_age = 0.0
_network_input_stale = false
_network_input_stale_timeout_seconds = clampf(
stale_timeout_seconds,
NETWORK_INPUT_STALE_TIMEOUT_SECONDS,
NETWORK_INPUT_STALE_TIMEOUT_MAX_SECONDS,
)
_network_axis = Vector2(float(axis[0]), float(axis[1])).limit_length(1.0)
_network_camera_yaw = float(data.get("camera_yaw", 0.0))
var jump_intent_active: bool = bool(data.get("jump", false))
@ -1989,15 +2129,13 @@ func apply_authoritative_network_input(data: Dictionary) -> void:
_network_sprint = bool(data.get("sprint", false))
_network_sneak = bool(data.get("sneak", false))
_network_slow_walk = bool(data.get("slow_walk", false))
_apply_animation_action_state(data.get("animation_action", {}))
apply_authoritative_network_animation_action(
data.get("animation_action", {})
)
_apply_network_casting(bool(data.get("casting", false)))
var sitting_requested: bool = bool(data.get("sitting", false))
if sitting_requested and not is_on_floor():
_sit_after_landing = true
_set_sitting(false)
else:
_sit_after_landing = false
_set_sitting(sitting_requested)
apply_authoritative_network_sitting_state(
bool(data.get("sitting", false))
)
func make_network_snapshot(peer_id: int) -> Dictionary:
@ -2007,19 +2145,49 @@ func make_network_snapshot(peer_id: int) -> Dictionary:
"position": [global_position.x, global_position.y, global_position.z],
"velocity": [velocity.x, velocity.y, velocity.z],
"visual_yaw": _visuals.rotation.y,
"animation_state": NetworkPlayerAnimationProtocol.make_state(
_get_authoritative_locomotion_id(),
is_on_floor(),
_animation_action_id,
_animation_action_sequence,
_animation_action_elapsed,
_animation_action_paused,
),
"sitting": _sitting,
"animation_state": make_network_animation_state(),
"grounded": is_on_floor(),
"sitting": get_network_sitting_state(),
"casting": _fishing_visual_phase == FishingVisualPhase.CASTING,
}
func make_network_animation_state() -> Dictionary:
return NetworkPlayerAnimationProtocol.make_state(
_get_authoritative_locomotion_id(),
is_on_floor(),
_animation_action_id,
_animation_action_sequence,
_animation_action_elapsed,
_animation_action_paused,
)
func apply_network_animation_state(state: Dictionary) -> void:
_apply_network_target_animation_state(state)
func apply_authoritative_network_animation_action(
action_state: Dictionary,
) -> void:
if not NetworkPlayerAnimationProtocol.validate_action_state(action_state):
return
var incoming_sequence: int = int(action_state["sequence"])
var wrapped_sequence: bool = (
_animation_action_sequence
== NetworkPlayerAnimationProtocol.MAX_ACTION_SEQUENCE
and incoming_sequence == 1
)
if incoming_sequence < _animation_action_sequence and not wrapped_sequence:
return
if (
incoming_sequence == _animation_action_sequence
and StringName(str(action_state["id"])) != _animation_action_id
):
return
_apply_animation_action_state(action_state)
func push_network_snapshot(
snapshot: Dictionary,
estimated_transit_seconds: float = -1.0,
@ -2053,7 +2221,8 @@ func push_network_snapshot(
)
_network_target_velocity = parsed["velocity"]
_network_target_visual_yaw = parsed["visual_yaw"]
_apply_network_target_animation_state(parsed["animation_state"])
if not Dictionary(parsed.get("animation_state", {})).is_empty():
_apply_network_target_animation_state(parsed["animation_state"])
_network_snapshot_age = 0.0
_apply_network_casting(bool(parsed["casting"]))
_set_sitting(bool(parsed["sitting"]))
@ -2066,9 +2235,8 @@ func push_network_snapshot(
func apply_local_prediction_correction(
snapshot: Dictionary,
latest_input_sequence: int = 0,
pending_inputs: Array[Dictionary] = [],
input_interval_seconds: float = 0.0,
estimated_transit_seconds: float = -1.0,
) -> void:
var parsed: Dictionary = _parse_network_snapshot(snapshot)
if parsed.is_empty():
@ -2090,27 +2258,158 @@ func apply_local_prediction_correction(
_clear_local_network_jump_intent()
if not _sitting_intent_pending:
_set_sitting(bool(parsed["sitting"]))
var authoritative_position: Vector3 = parsed["position"]
var transit_seconds: float = resolve_local_prediction_transit_seconds(
acknowledged_input,
latest_input_sequence,
input_interval_seconds,
estimated_transit_seconds,
var previous_visual_position: Vector3 = _visuals.global_position
var base_visual_local_position: Vector3 = (
_visuals.position - _local_reconciliation_visual_offset
)
if transit_seconds > 0.0:
authoritative_position += (
(parsed["velocity"] as Vector3) * transit_seconds
)
var error_distance: float = global_position.distance_to(
authoritative_position
var previous_position: Vector3 = global_position
global_position = parsed["position"]
velocity = parsed["velocity"]
if input_interval_seconds > 0.0:
for input: Dictionary in pending_inputs:
_replay_network_movement_input(input, input_interval_seconds)
var correction_distance: float = previous_position.distance_to(
global_position
)
if error_distance > LOCAL_PREDICTION_SNAP_DISTANCE:
global_position = authoritative_position
elif error_distance > LOCAL_PREDICTION_CORRECTION_THRESHOLD:
global_position = global_position.lerp(
authoritative_position,
LOCAL_PREDICTION_CORRECTION_WEIGHT,
if correction_distance <= LOCAL_PREDICTION_SNAP_DISTANCE:
_visuals.global_position = previous_visual_position
_local_reconciliation_visual_offset = (
_visuals.position - base_visual_local_position
)
else:
_local_reconciliation_visual_offset = Vector3.ZERO
func _replay_network_movement_input(
data: Dictionary,
delta: float,
) -> void:
var axis_value: Variant = data.get("axis", [])
if typeof(axis_value) != TYPE_ARRAY or axis_value.size() != 2:
return
if bool(data.get("sitting", false)) or _water_recovery_active:
velocity = Vector3.ZERO
return
var input_vector := Vector2(
float(axis_value[0]),
float(axis_value[1]),
).limit_length(1.0)
var camera_basis := Basis(
Vector3.UP,
float(data.get("camera_yaw", 0.0)),
)
var move_direction: Vector3 = (
camera_basis.x * input_vector.x
+ camera_basis.z * input_vector.y
)
move_direction.y = 0.0
move_direction = move_direction.normalized()
_network_sprint = bool(data.get("sprint", false))
_network_sneak = bool(data.get("sneak", false))
_network_slow_walk = bool(data.get("slow_walk", false))
# Replay the speed authored by this exact pending input. Consulting the
# current InputMap here would make an older walk replay as a sprint (or the
# reverse) whenever the local button changed while a snapshot was in flight.
var replay_speed: float = walk_speed
if _network_sneak:
replay_speed = sneak_speed
elif _network_slow_walk:
replay_speed = slow_walk_speed
elif _network_sprint:
replay_speed = sprint_speed
if item_effects != null:
replay_speed *= item_effects.get_movement_multiplier()
var input_strength: float = minf(input_vector.length(), 1.0)
velocity.x = move_direction.x * replay_speed * input_strength
velocity.z = move_direction.z * replay_speed * input_strength
if not is_on_floor():
var gravity_multiplier: float = (
upward_gravity_multiplier
if velocity.y > 0.0
else fall_gravity_multiplier
)
velocity.y -= _gravity * gravity_multiplier * delta
elif bool(data.get("jump", false)):
velocity.y = jump_velocity
move_and_slide()
func _update_local_reconciliation_visuals(delta: float) -> void:
if _local_reconciliation_visual_offset.is_zero_approx():
_local_reconciliation_visual_offset = Vector3.ZERO
return
var retained_ratio: float = exp(-14.0 * delta)
var retained_offset: Vector3 = (
_local_reconciliation_visual_offset * retained_ratio
)
_visuals.position += retained_offset - _local_reconciliation_visual_offset
_local_reconciliation_visual_offset = retained_offset
static func resolve_network_input_stale_timeout_seconds(
round_trip_msec: int,
) -> float:
if round_trip_msec < 0:
return NETWORK_INPUT_STALE_TIMEOUT_SECONDS
return clampf(
NETWORK_INPUT_STALE_TIMEOUT_SECONDS
+ float(round_trip_msec) / 1000.0
* NETWORK_INPUT_STALE_RTT_MULTIPLIER,
NETWORK_INPUT_STALE_TIMEOUT_SECONDS,
NETWORK_INPUT_STALE_TIMEOUT_MAX_SECONDS,
)
func _record_authoritative_movement_state() -> void:
var now_seconds: float = Time.get_ticks_msec() / 1000.0
_authoritative_movement_history.append({
"sequence": _last_network_input_sequence,
"recorded_at": now_seconds,
"position": global_position,
"velocity": velocity,
"cast_origin": get_cast_origin_position(),
"facing": get_facing_direction(),
"sneaking": is_sneaking(),
})
var oldest_allowed: float = now_seconds - NETWORK_MOVEMENT_HISTORY_SECONDS
while (
not _authoritative_movement_history.is_empty()
and float(
_authoritative_movement_history[0].get("recorded_at", 0.0)
) < oldest_allowed
):
_authoritative_movement_history.pop_front()
func get_lag_compensated_movement_state(
input_sequence: int,
max_age_seconds: float = NETWORK_MAX_LAG_COMPENSATION_SECONDS,
) -> Dictionary:
var now_seconds: float = Time.get_ticks_msec() / 1000.0
for index: int in range(
_authoritative_movement_history.size() - 1,
-1,
-1,
):
var candidate: Dictionary = _authoritative_movement_history[index]
if now_seconds - float(candidate.get("recorded_at", 0.0)) > (
max_age_seconds
):
break
if (
input_sequence <= 0
or int(candidate.get("sequence", 0)) <= input_sequence
):
return candidate.duplicate(true)
return {
"sequence": _last_network_input_sequence,
"recorded_at": now_seconds,
"position": global_position,
"velocity": velocity,
"cast_origin": get_cast_origin_position(),
"facing": get_facing_direction(),
"sneaking": is_sneaking(),
}
static func resolve_local_prediction_transit_seconds(
@ -2154,7 +2453,8 @@ func apply_network_teleport(snapshot: Dictionary) -> void:
_network_target_position = global_position
_network_target_velocity = velocity
_network_target_visual_yaw = _visuals.rotation.y
_apply_network_target_animation_state(parsed["animation_state"])
if not Dictionary(parsed.get("animation_state", {})).is_empty():
_apply_network_target_animation_state(parsed["animation_state"])
_network_snapshot_age = 0.0
_network_snapshot_ready = true
@ -2180,12 +2480,16 @@ func _parse_network_snapshot(snapshot: Dictionary) -> Dictionary:
float(network_velocity[2])
)
var visual_yaw: float = float(snapshot.get("visual_yaw", 0.0))
var animation_state_value: Variant = snapshot.get("animation_state")
if not NetworkPlayerAnimationProtocol.validate_state(animation_state_value):
return {}
var animation_state: Dictionary = (
animation_state_value as Dictionary
).duplicate(true)
var animation_state: Dictionary = {}
if snapshot.has("animation_state"):
var animation_state_value: Variant = snapshot.get("animation_state")
if not NetworkPlayerAnimationProtocol.validate_state(
animation_state_value
):
return {}
animation_state = (
animation_state_value as Dictionary
).duplicate(true)
if (
snapshot.has("acknowledged_input")
and (
@ -2237,6 +2541,23 @@ func _apply_network_target_animation_state(state: Dictionary) -> void:
StringName(str(state["locomotion_id"]))
)
var action: Dictionary = state["action"]
var incoming_sequence: int = int(action["sequence"])
var wrapped_sequence: bool = (
_network_target_animation_action_sequence
== NetworkPlayerAnimationProtocol.MAX_ACTION_SEQUENCE
and incoming_sequence == 1
)
if (
incoming_sequence < _network_target_animation_action_sequence
and not wrapped_sequence
):
return
if (
incoming_sequence == _network_target_animation_action_sequence
and StringName(str(action["id"]))
!= _network_target_animation_action_id
):
return
_network_target_animation_action_id = StringName(str(action["id"]))
_network_target_animation_action_sequence = int(action["sequence"])
_network_target_animation_action_elapsed = float(action["elapsed"])
@ -2414,11 +2735,11 @@ func _update_network_interpolation(delta: float) -> void:
func _update_network_input_freshness(delta: float) -> void:
_network_input_age = minf(
_network_input_age + delta,
NETWORK_INPUT_STALE_TIMEOUT_SECONDS + 1.0,
_network_input_stale_timeout_seconds + 1.0,
)
if (
_network_input_stale
or _network_input_age <= NETWORK_INPUT_STALE_TIMEOUT_SECONDS
or _network_input_age <= _network_input_stale_timeout_seconds
):
return
_network_input_stale = true
@ -2523,6 +2844,7 @@ func apply_camera_settings(
new_mouse_sensitivity: float,
new_controller_sensitivity: float,
invert_vertical: bool,
swap_hotbar_camera_scroll: bool = false,
) -> void:
mouse_sensitivity = clampf(new_mouse_sensitivity, 0.001, 0.012)
controller_camera_speed = clampf(
@ -2531,6 +2853,7 @@ func apply_camera_settings(
5.0
)
invert_camera_y = invert_vertical
_swap_hotbar_camera_scroll = swap_hotbar_camera_scroll
func is_camera_input_enabled() -> bool: