Fix multiplayer presentation and Fishnet synchronization

This commit is contained in:
Alexander Sellite 2026-08-13 21:58:43 -04:00
parent 31d459b3a7
commit 2045d8e288
19 changed files with 748 additions and 41 deletions

View file

@ -150,6 +150,10 @@ func get_plan_id() -> String:
return _active_plan_id return _active_plan_id
func has_active_board() -> bool:
return validate_board(_active_board)
func get_time_until_refresh_text() -> String: func get_time_until_refresh_text() -> String:
if _active_plan_id.is_empty() or _world_time == null: if _active_plan_id.is_empty() or _world_time == null:
return "daily jobs unavailable" return "daily jobs unavailable"

View file

@ -2,11 +2,13 @@ class_name NetworkJobService
extends Node extends Node
const MAX_SESSION_ID_LENGTH: int = 96 const MAX_SESSION_ID_LENGTH: int = 96
const BOARD_REQUEST_INTERVAL_SECONDS: float = 1.5
var _session: NetworkSession var _session: NetworkSession
var _jobs: PlayerJobService var _jobs: PlayerJobService
var _sequence: int = 0 var _sequence: int = 0
var _last_received_sequence: int = -1 var _last_received_sequence: int = -1
var _board_request_accumulator: float = 0.0
func setup(session: NetworkSession, jobs: PlayerJobService) -> void: func setup(session: NetworkSession, jobs: PlayerJobService) -> void:
@ -15,13 +17,36 @@ func setup(session: NetworkSession, jobs: PlayerJobService) -> void:
_session.state_changed.connect(_on_session_state_changed) _session.state_changed.connect(_on_session_state_changed)
_session.peer_authenticated.connect(_on_peer_authenticated) _session.peer_authenticated.connect(_on_peer_authenticated)
_jobs.board_changed.connect(_on_board_changed) _jobs.board_changed.connect(_on_board_changed)
set_process(true)
func _process(delta: float) -> void:
if (
_session == null
or _jobs == null
or not _session.is_joined_client()
or not _session.supports_server_capability(
NetworkProtocol.JOBS_CAPABILITY
)
or _jobs.has_active_board()
):
_board_request_accumulator = 0.0
return
_board_request_accumulator += delta
if _board_request_accumulator < BOARD_REQUEST_INTERVAL_SECONDS:
return
_board_request_accumulator = 0.0
_request_remote_board()
func _on_session_state_changed(state: NetworkSession.State) -> void: func _on_session_state_changed(state: NetworkSession.State) -> void:
_sequence = 0 _sequence = 0
_last_received_sequence = -1 _last_received_sequence = -1
_board_request_accumulator = 0.0
if state == NetworkSession.State.JOINED_CLIENT: if state == NetworkSession.State.JOINED_CLIENT:
if not _session.supports_server_capability(NetworkProtocol.JOBS_CAPABILITY): if _session.supports_server_capability(NetworkProtocol.JOBS_CAPABILITY):
_request_remote_board()
else:
_jobs.clear_remote_board() _jobs.clear_remote_board()
elif state in [ elif state in [
NetworkSession.State.INACTIVE, NetworkSession.State.INACTIVE,
@ -69,6 +94,33 @@ func _send_board(peer_id: int) -> void:
}) })
func _request_remote_board() -> void:
if (
_session != null
and _session.is_joined_client()
and _session.supports_server_capability(
NetworkProtocol.JOBS_CAPABILITY
)
):
request_job_board.rpc_id(1)
@rpc("any_peer", "call_remote", "reliable", 0)
func request_job_board() -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if (
_session == null
or not _session.is_host()
or sender_id <= 1
or not _session.is_authenticated_peer(sender_id)
or not _session.peer_supports_capability(
sender_id, NetworkProtocol.JOBS_CAPABILITY
)
):
return
_send_board(sender_id)
@rpc("authority", "call_remote", "reliable", 0) @rpc("authority", "call_remote", "reliable", 0)
func receive_job_board(data: Dictionary) -> void: func receive_job_board(data: Dictionary) -> void:
if ( if (
@ -86,6 +138,7 @@ func receive_job_board(data: Dictionary) -> void:
var board: Dictionary = data.get("board", {}) var board: Dictionary = data.get("board", {})
if _jobs.apply_remote_board(board): if _jobs.apply_remote_board(board):
_last_received_sequence = sequence _last_received_sequence = sequence
_board_request_accumulator = 0.0
static func validate_snapshot(value: Variant) -> bool: static func validate_snapshot(value: Variant) -> bool:

View file

@ -0,0 +1,94 @@
class_name NetworkPlayerAnimationProtocol
extends RefCounted
const FORMAT_VERSION: int = 1
const MAX_STATE_ID_LENGTH: int = 64
const MAX_ACTION_SEQUENCE: int = 2147483647
const MAX_ACTION_ELAPSED_SECONDS: float = 86400.0
const LOCOMOTION_IDLE: StringName = &"idle"
const LOCOMOTION_WALKING: StringName = &"walking"
const LOCOMOTION_RUNNING: StringName = &"running"
static func make_state(
locomotion_id: StringName,
grounded: bool,
action_id: StringName = &"",
action_sequence: int = 0,
action_elapsed: float = 0.0,
) -> Dictionary:
return {
"format_version": FORMAT_VERSION,
"locomotion_id": String(locomotion_id),
"grounded": grounded,
"action": make_action_state(
action_id, action_sequence, action_elapsed
),
}
static func make_action_state(
action_id: StringName = &"",
action_sequence: int = 0,
action_elapsed: float = 0.0,
) -> Dictionary:
return {
"id": String(action_id),
"sequence": action_sequence,
"elapsed": action_elapsed,
}
static func validate_state(value: Variant) -> bool:
if typeof(value) != TYPE_DICTIONARY:
return false
var state: Dictionary = value
return (
typeof(state.get("format_version")) == TYPE_INT
and int(state["format_version"]) == FORMAT_VERSION
and valid_state_id(state.get("locomotion_id"), false)
and typeof(state.get("grounded")) == TYPE_BOOL
and validate_action_state(state.get("action"))
)
static func validate_action_state(value: Variant) -> bool:
if typeof(value) != TYPE_DICTIONARY:
return false
var action: Dictionary = value
if (
not valid_state_id(action.get("id"), true)
or typeof(action.get("sequence")) != TYPE_INT
or typeof(action.get("elapsed")) not in [TYPE_FLOAT, TYPE_INT]
):
return false
var sequence: int = int(action["sequence"])
var elapsed: float = float(action["elapsed"])
return (
sequence >= 0
and sequence <= MAX_ACTION_SEQUENCE
and is_finite(elapsed)
and elapsed >= 0.0
and elapsed <= MAX_ACTION_ELAPSED_SECONDS
)
static func valid_state_id(value: Variant, allow_empty: bool) -> bool:
if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]:
return false
var state_id: String = str(value)
if state_id.is_empty():
return allow_empty
if state_id.length() > MAX_STATE_ID_LENGTH:
return false
for index: int in state_id.length():
var codepoint: int = state_id.unicode_at(index)
var valid_character: bool = (
codepoint >= 97 and codepoint <= 122
or codepoint >= 48 and codepoint <= 57
or codepoint == 95
)
if not valid_character:
return false
return true

View file

@ -0,0 +1 @@
uid://cmaaep4n3p2tq

View file

@ -1,7 +1,7 @@
class_name NetworkProtocol class_name NetworkProtocol
extends RefCounted extends RefCounted
const PROTOCOL_VERSION: int = 3 const PROTOCOL_VERSION: int = 4
const GAME_BUILD: String = "prealpha" const GAME_BUILD: String = "prealpha"
const MAX_GAME_VERSION_LENGTH: int = 64 const MAX_GAME_VERSION_LENGTH: int = 64
const MAX_DISPLAY_NAME_LENGTH: int = 24 const MAX_DISPLAY_NAME_LENGTH: int = 24

View file

@ -9,6 +9,7 @@ const CONNECTION_TIMEOUT_SECONDS: float = 10.0
const AUTHENTICATION_TIMEOUT_SECONDS: float = 60.0 const AUTHENTICATION_TIMEOUT_SECONDS: float = 60.0
const INPUT_INTERVAL: float = 1.0 / 30.0 const INPUT_INTERVAL: float = 1.0 / 30.0
const SNAPSHOT_INTERVAL: float = 1.0 / 30.0 const SNAPSHOT_INTERVAL: float = 1.0 / 30.0
const MAX_MOVEMENT_INPUT_SEQUENCE: int = 2147483647
signal state_changed(state: State) signal state_changed(state: State)
signal status_message_changed(message: String) signal status_message_changed(message: String)
@ -1741,9 +1742,10 @@ func _is_valid_movement_input(data: Dictionary) -> bool:
or typeof(data.get("sprint")) != TYPE_BOOL or typeof(data.get("sprint")) != TYPE_BOOL
or typeof(data.get("sneak")) != TYPE_BOOL or typeof(data.get("sneak")) != TYPE_BOOL
or typeof(data.get("slow_walk")) != TYPE_BOOL or typeof(data.get("slow_walk")) != TYPE_BOOL
or ( or typeof(data.get("sitting")) != TYPE_BOOL
data.has("sitting") or typeof(data.get("casting")) != TYPE_BOOL
and typeof(data.get("sitting")) != TYPE_BOOL or not NetworkPlayerAnimationProtocol.validate_action_state(
data.get("animation_action")
) )
or typeof(data.get("camera_yaw")) not in [TYPE_FLOAT, TYPE_INT] or typeof(data.get("camera_yaw")) not in [TYPE_FLOAT, TYPE_INT]
): ):
@ -1751,11 +1753,18 @@ func _is_valid_movement_input(data: Dictionary) -> bool:
var axis: Array = data["axis"] var axis: Array = data["axis"]
if axis.size() != 2: if axis.size() != 2:
return false return false
if (
typeof(axis[0]) not in [TYPE_FLOAT, TYPE_INT]
or typeof(axis[1]) not in [TYPE_FLOAT, TYPE_INT]
):
return false
var x: float = float(axis[0]) var x: float = float(axis[0])
var y: float = float(axis[1]) var y: float = float(axis[1])
var camera_yaw: float = float(data["camera_yaw"]) var camera_yaw: float = float(data["camera_yaw"])
return ( return (
is_finite(x) int(data["sequence"]) > 0
and int(data["sequence"]) <= MAX_MOVEMENT_INPUT_SEQUENCE
and is_finite(x)
and is_finite(y) and is_finite(y)
and is_finite(camera_yaw) and is_finite(camera_yaw)
and absf(x) <= 1.01 and absf(x) <= 1.01

View file

@ -68,6 +68,10 @@ const CHARACTER_FISHING_ANIMATION: StringName = &"fishing"
const CHARACTER_FISHING_SIT_ANIMATION: StringName = &"fishing_sit" const CHARACTER_FISHING_SIT_ANIMATION: StringName = &"fishing_sit"
const CHARACTER_FIGHTING_ANIMATION: StringName = &"fighting" const CHARACTER_FIGHTING_ANIMATION: StringName = &"fighting"
const CHARACTER_FIGHTING_SIT_ANIMATION: StringName = &"fighting_sit" const CHARACTER_FIGHTING_SIT_ANIMATION: StringName = &"fighting_sit"
# Add future networked emote animation IDs here. The protocol accepts unknown
# safe IDs so newer clients can extend it, but Player only presents actions
# explicitly approved by this catalog.
const NETWORK_ANIMATION_ACTION_IDS: Array[StringName] = []
enum FishingVisualPhase { enum FishingVisualPhase {
NONE, NONE,
@ -84,6 +88,12 @@ enum PocketVisualTarget {
ART_KIT, ART_KIT,
CATCH_SHOWCASE, CATCH_SHOWCASE,
} }
enum LocomotionState {
IDLE,
WALKING,
RUNNING,
}
const FIGHTING_EYES_ID: String = "alligator_eyes" const FIGHTING_EYES_ID: String = "alligator_eyes"
const BLINK_EYES_ID: String = "closed" const BLINK_EYES_ID: String = "closed"
const BLINK_INTERVAL_SECONDS := Vector2(2.8, 7.5) const BLINK_INTERVAL_SECONDS := Vector2(2.8, 7.5)
@ -393,8 +403,18 @@ var _last_network_input_sequence: int = 0
var _network_target_position: Vector3 var _network_target_position: Vector3
var _network_target_velocity: Vector3 var _network_target_velocity: Vector3
var _network_target_visual_yaw: float = 0.0 var _network_target_visual_yaw: float = 0.0
var _network_target_grounded: bool = false
var _network_target_locomotion_state: LocomotionState = LocomotionState.IDLE
var _network_target_animation_action_id: StringName = &""
var _network_target_animation_action_sequence: int = 0
var _network_target_animation_action_elapsed: float = 0.0
var _network_snapshot_ready: bool = false var _network_snapshot_ready: bool = false
var _network_snapshot_age: float = 0.0 var _network_snapshot_age: float = 0.0
var _animation_action_id: StringName = &""
var _animation_action_sequence: int = 0
var _animation_action_elapsed: float = 0.0
var _presented_animation_action_id: StringName = &""
var _presented_animation_action_sequence: int = -1
var _character_animation_name: StringName = &"" var _character_animation_name: StringName = &""
var _sitting: bool = false var _sitting: bool = false
var _sit_after_landing: bool = false var _sit_after_landing: bool = false
@ -475,6 +495,72 @@ func set_controller_mapping_manager(
_controller_mapping_manager = mapping_manager _controller_mapping_manager = mapping_manager
func begin_animation_action(
action_id: StringName,
elapsed_seconds: float = 0.0,
) -> bool:
if (
action_id.is_empty()
or not supports_network_animation_action(action_id)
or _character_animation_player == null
or not _character_animation_player.has_animation(action_id)
):
return false
var next_sequence: int = (
1
if _animation_action_sequence
>= NetworkPlayerAnimationProtocol.MAX_ACTION_SEQUENCE
else _animation_action_sequence + 1
)
var action_state: Dictionary = (
NetworkPlayerAnimationProtocol.make_action_state(
action_id, next_sequence, elapsed_seconds
)
)
if not NetworkPlayerAnimationProtocol.validate_action_state(action_state):
return false
_apply_animation_action_state(action_state)
return true
func end_animation_action() -> void:
if _animation_action_id.is_empty():
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(
&"", next_sequence, 0.0
)
)
func _apply_animation_action_state(action_state: Dictionary) -> void:
if not NetworkPlayerAnimationProtocol.validate_action_state(action_state):
return
var action_id := StringName(str(action_state["id"]))
if (
not action_id.is_empty()
and not supports_network_animation_action(action_id)
):
action_id = &""
var action_sequence: int = int(action_state["sequence"])
var action_changed: bool = (
action_id != _animation_action_id
or action_sequence != _animation_action_sequence
)
_animation_action_id = action_id
_animation_action_sequence = action_sequence
_animation_action_elapsed = float(action_state["elapsed"])
if action_changed:
_character_animation_name = &""
_update_character_animation()
func _initialize_fishing_rod() -> void: func _initialize_fishing_rod() -> void:
var skeleton := get_node_or_null( var skeleton := get_node_or_null(
"Visuals/CharacterRig/CharacterRig/Skeleton3D" "Visuals/CharacterRig/CharacterRig/Skeleton3D"
@ -613,6 +699,11 @@ func _physics_process(delta: float) -> void:
func _process(delta: float) -> void: func _process(delta: float) -> void:
if not _animation_action_id.is_empty():
_animation_action_elapsed = minf(
_animation_action_elapsed + delta,
NetworkPlayerAnimationProtocol.MAX_ACTION_ELAPSED_SECONDS,
)
_update_blink(delta) _update_blink(delta)
_update_character_animation() _update_character_animation()
_update_sprint_dust(delta) _update_sprint_dust(delta)
@ -705,7 +796,7 @@ func _update_sprint_dust(delta: float) -> void:
if _sprint_dust == null: if _sprint_dust == null:
return return
var horizontal_velocity := Vector3(velocity.x, 0.0, velocity.z) var horizontal_velocity := Vector3(velocity.x, 0.0, velocity.z)
var grounded: bool = is_on_floor() var grounded: bool = _get_presented_grounded()
if _water_recovery_active: if _water_recovery_active:
_sprint_dust_airborne = false _sprint_dust_airborne = false
_sprint_dust_fall_speed = 0.0 _sprint_dust_fall_speed = 0.0
@ -730,18 +821,11 @@ func _update_sprint_dust(delta: float) -> void:
) )
_sprint_dust_airborne = false _sprint_dust_airborne = false
_sprint_dust_fall_speed = 0.0 _sprint_dust_fall_speed = 0.0
var horizontal_speed_squared: float = horizontal_velocity.length_squared()
var fastest_non_sprint_speed := maxf(
walk_speed,
maxf(sneak_speed, slow_walk_speed)
)
var running_threshold := fastest_non_sprint_speed + 0.5
var should_emit: bool = ( var should_emit: bool = (
grounded grounded
and not _sitting and not _sitting
and not _water_recovery_active and not _water_recovery_active
and horizontal_speed_squared and _get_presented_locomotion_state() == LocomotionState.RUNNING
> running_threshold * running_threshold
) )
_sprint_dust.update_trail( _sprint_dust.update_trail(
delta, delta,
@ -778,18 +862,17 @@ func _get_controller_zoom_strength() -> float:
func _update_character_animation() -> void: func _update_character_animation() -> void:
if _character_animation_player == null: if _character_animation_player == null:
return return
var horizontal_speed_squared: float = ( var locomotion_state: LocomotionState = (
velocity.x * velocity.x + velocity.z * velocity.z _get_presented_locomotion_state()
) )
var is_walking: bool = horizontal_speed_squared > 0.0025 var is_walking: bool = locomotion_state != LocomotionState.IDLE
var fastest_non_sprint_speed := maxf( var is_running: bool = locomotion_state == LocomotionState.RUNNING
walk_speed, var animation_action: Dictionary = _get_presented_animation_action()
maxf(sneak_speed, slow_walk_speed) var animation_action_id := StringName(str(animation_action.get("id", "")))
) var animation_action_available: bool = (
var running_threshold := fastest_non_sprint_speed + 0.5 not animation_action_id.is_empty()
var is_running := ( and supports_network_animation_action(animation_action_id)
is_walking and _character_animation_player.has_animation(animation_action_id)
and horizontal_speed_squared > running_threshold * running_threshold
) )
var held_show_item_visible: bool = ( var held_show_item_visible: bool = (
_held_fish_visible or _held_art_kit_visible _held_fish_visible or _held_art_kit_visible
@ -878,6 +961,8 @@ func _update_character_animation() -> void:
CHARACTER_FISHING_ANIMATION, CHARACTER_FISHING_ANIMATION,
CHARACTER_IDLE_ANIMATION, CHARACTER_IDLE_ANIMATION,
] ]
elif animation_action_available:
requested_animation = [animation_action_id]
elif _sitting: elif _sitting:
if held_show_item_visible: if held_show_item_visible:
requested_animation = [ requested_animation = [
@ -934,13 +1019,45 @@ func _update_character_animation() -> void:
break break
if next_animation.is_empty(): if next_animation.is_empty():
return return
if _character_animation_name == next_animation: var action_selected: bool = (
animation_action_available and next_animation == animation_action_id
)
var action_sequence: int = int(animation_action.get("sequence", 0))
var action_changed: bool = (
action_selected
and (
animation_action_id != _presented_animation_action_id
or action_sequence != _presented_animation_action_sequence
)
)
if not action_selected:
_presented_animation_action_id = &""
_presented_animation_action_sequence = -1
if _character_animation_name == next_animation and not action_changed:
return return
_character_animation_player.play(next_animation) _character_animation_player.play(next_animation)
_character_animation_name = next_animation _character_animation_name = next_animation
if action_selected:
_presented_animation_action_id = animation_action_id
_presented_animation_action_sequence = action_sequence
var elapsed: float = float(animation_action.get("elapsed", 0.0))
var animation: Animation = _character_animation_player.get_animation(
next_animation
)
if elapsed > 0.0 and animation != null and animation.length > 0.0:
_character_animation_player.seek(
fposmod(elapsed, animation.length), true
)
func _on_character_animation_finished(animation_name: StringName) -> void: func _on_character_animation_finished(animation_name: StringName) -> void:
if (
local_control_enabled
and not _animation_action_id.is_empty()
and animation_name == _animation_action_id
):
end_animation_action()
return
if animation_name in [ if animation_name in [
CHARACTER_POCKET_IDLE_IDLE_ANIMATION, CHARACTER_POCKET_IDLE_IDLE_ANIMATION,
CHARACTER_POCKET_IDLE_SHOW_ANIMATION, CHARACTER_POCKET_IDLE_SHOW_ANIMATION,
@ -1278,6 +1395,11 @@ func configure_network_remote(authoritative_simulation: bool) -> void:
_network_interpolation_enabled = not authoritative_simulation _network_interpolation_enabled = not authoritative_simulation
_network_snapshot_ready = false _network_snapshot_ready = false
_network_snapshot_age = 0.0 _network_snapshot_age = 0.0
_network_target_grounded = false
_network_target_locomotion_state = LocomotionState.IDLE
_network_target_animation_action_id = &""
_network_target_animation_action_sequence = 0
_network_target_animation_action_elapsed = 0.0
_camera.current = false _camera.current = false
@ -1301,6 +1423,7 @@ func capture_network_input(sequence: int) -> Dictionary:
"casting": ( "casting": (
_fishing_visual_phase == FishingVisualPhase.CASTING _fishing_visual_phase == FishingVisualPhase.CASTING
), ),
"animation_action": _make_animation_action_state(),
} }
var axis: Vector2 = Input.get_vector( var axis: Vector2 = Input.get_vector(
"move_left", "move_left",
@ -1318,6 +1441,7 @@ func capture_network_input(sequence: int) -> Dictionary:
"slow_walk": Input.is_action_pressed("slow_walk"), "slow_walk": Input.is_action_pressed("slow_walk"),
"sitting": _sitting, "sitting": _sitting,
"casting": _fishing_visual_phase == FishingVisualPhase.CASTING, "casting": _fishing_visual_phase == FishingVisualPhase.CASTING,
"animation_action": _make_animation_action_state(),
} }
@ -1335,6 +1459,7 @@ func apply_authoritative_network_input(data: Dictionary) -> void:
_network_sprint = bool(data.get("sprint", false)) _network_sprint = bool(data.get("sprint", false))
_network_sneak = bool(data.get("sneak", false)) _network_sneak = bool(data.get("sneak", false))
_network_slow_walk = bool(data.get("slow_walk", false)) _network_slow_walk = bool(data.get("slow_walk", false))
_apply_animation_action_state(data.get("animation_action", {}))
_apply_network_casting(bool(data.get("casting", false))) _apply_network_casting(bool(data.get("casting", false)))
var sitting_requested: bool = bool(data.get("sitting", false)) var sitting_requested: bool = bool(data.get("sitting", false))
if sitting_requested and not is_on_floor(): if sitting_requested and not is_on_floor():
@ -1352,7 +1477,13 @@ func make_network_snapshot(peer_id: int) -> Dictionary:
"position": [global_position.x, global_position.y, global_position.z], "position": [global_position.x, global_position.y, global_position.z],
"velocity": [velocity.x, velocity.y, velocity.z], "velocity": [velocity.x, velocity.y, velocity.z],
"visual_yaw": _visuals.rotation.y, "visual_yaw": _visuals.rotation.y,
"grounded": is_on_floor(), "animation_state": NetworkPlayerAnimationProtocol.make_state(
_get_authoritative_locomotion_id(),
is_on_floor(),
_animation_action_id,
_animation_action_sequence,
_animation_action_elapsed,
),
"sitting": _sitting, "sitting": _sitting,
"casting": _fishing_visual_phase == FishingVisualPhase.CASTING, "casting": _fishing_visual_phase == FishingVisualPhase.CASTING,
} }
@ -1365,6 +1496,7 @@ func push_network_snapshot(snapshot: Dictionary) -> void:
_network_target_position = parsed["position"] _network_target_position = parsed["position"]
_network_target_velocity = parsed["velocity"] _network_target_velocity = parsed["velocity"]
_network_target_visual_yaw = parsed["visual_yaw"] _network_target_visual_yaw = parsed["visual_yaw"]
_apply_network_target_animation_state(parsed["animation_state"])
_network_snapshot_age = 0.0 _network_snapshot_age = 0.0
_apply_network_casting(bool(parsed["casting"])) _apply_network_casting(bool(parsed["casting"]))
_set_sitting(bool(parsed["sitting"])) _set_sitting(bool(parsed["sitting"]))
@ -1412,6 +1544,7 @@ func apply_network_teleport(snapshot: Dictionary) -> void:
_network_target_position = global_position _network_target_position = global_position
_network_target_velocity = velocity _network_target_velocity = velocity
_network_target_visual_yaw = _visuals.rotation.y _network_target_visual_yaw = _visuals.rotation.y
_apply_network_target_animation_state(parsed["animation_state"])
_network_snapshot_age = 0.0 _network_snapshot_age = 0.0
_network_snapshot_ready = true _network_snapshot_ready = true
@ -1437,6 +1570,12 @@ func _parse_network_snapshot(snapshot: Dictionary) -> Dictionary:
float(network_velocity[2]) float(network_velocity[2])
) )
var visual_yaw: float = float(snapshot.get("visual_yaw", 0.0)) 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)
if ( if (
snapshot.has("acknowledged_input") snapshot.has("acknowledged_input")
and ( and (
@ -1464,12 +1603,119 @@ func _parse_network_snapshot(snapshot: Dictionary) -> Dictionary:
"position": parsed_position, "position": parsed_position,
"velocity": parsed_velocity, "velocity": parsed_velocity,
"visual_yaw": visual_yaw, "visual_yaw": visual_yaw,
"animation_state": animation_state,
"acknowledged_input": acknowledged_input, "acknowledged_input": acknowledged_input,
"sitting": sitting, "sitting": sitting,
"casting": casting, "casting": casting,
} }
func _make_animation_action_state() -> Dictionary:
return NetworkPlayerAnimationProtocol.make_action_state(
_animation_action_id,
_animation_action_sequence,
_animation_action_elapsed,
)
func _apply_network_target_animation_state(state: Dictionary) -> void:
if not NetworkPlayerAnimationProtocol.validate_state(state):
return
_network_target_grounded = bool(state["grounded"])
_network_target_locomotion_state = _locomotion_state_from_id(
StringName(str(state["locomotion_id"]))
)
var action: Dictionary = state["action"]
_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"])
func _get_authoritative_locomotion_state() -> LocomotionState:
if (
_network_authoritative_simulation
and _is_movement_input_enabled()
and not _water_recovery_active
and not _sitting
and _network_axis.length_squared() > 0.0025
):
return (
LocomotionState.RUNNING
if _network_sprint
else LocomotionState.WALKING
)
return _locomotion_state_from_velocity()
func _get_authoritative_locomotion_id() -> StringName:
return _locomotion_id_from_state(_get_authoritative_locomotion_state())
func _get_presented_locomotion_state() -> LocomotionState:
if _network_interpolation_enabled and _network_snapshot_ready:
return _network_target_locomotion_state
return _locomotion_state_from_velocity()
func _get_presented_animation_action() -> Dictionary:
if _network_interpolation_enabled and _network_snapshot_ready:
return NetworkPlayerAnimationProtocol.make_action_state(
_network_target_animation_action_id,
_network_target_animation_action_sequence,
minf(
_network_target_animation_action_elapsed + _network_snapshot_age,
NetworkPlayerAnimationProtocol.MAX_ACTION_ELAPSED_SECONDS,
),
)
return _make_animation_action_state()
func _locomotion_id_from_state(state: LocomotionState) -> StringName:
match state:
LocomotionState.WALKING:
return NetworkPlayerAnimationProtocol.LOCOMOTION_WALKING
LocomotionState.RUNNING:
return NetworkPlayerAnimationProtocol.LOCOMOTION_RUNNING
_:
return NetworkPlayerAnimationProtocol.LOCOMOTION_IDLE
func _locomotion_state_from_id(state_id: StringName) -> LocomotionState:
match state_id:
NetworkPlayerAnimationProtocol.LOCOMOTION_WALKING:
return LocomotionState.WALKING
NetworkPlayerAnimationProtocol.LOCOMOTION_RUNNING:
return LocomotionState.RUNNING
_:
return LocomotionState.IDLE
static func supports_network_animation_action(action_id: StringName) -> bool:
return action_id in NETWORK_ANIMATION_ACTION_IDS
func _locomotion_state_from_velocity() -> LocomotionState:
var horizontal_speed_squared: float = (
velocity.x * velocity.x + velocity.z * velocity.z
)
if horizontal_speed_squared <= 0.0025:
return LocomotionState.IDLE
var fastest_non_sprint_speed := maxf(
walk_speed,
maxf(sneak_speed, slow_walk_speed)
)
var running_threshold := fastest_non_sprint_speed + 0.5
if horizontal_speed_squared > running_threshold * running_threshold:
return LocomotionState.RUNNING
return LocomotionState.WALKING
func _get_presented_grounded() -> bool:
if _network_interpolation_enabled and _network_snapshot_ready:
return _network_target_grounded
return is_on_floor()
func _apply_network_casting(casting: bool) -> void: func _apply_network_casting(casting: bool) -> void:
if casting: if casting:
if _fishing_visual_phase == FishingVisualPhase.NONE: if _fishing_visual_phase == FishingVisualPhase.NONE:

View file

@ -17,6 +17,7 @@ readonly -a QUICK_TESTS=(
"tests/fishing_surface_validation.gd" "tests/fishing_surface_validation.gd"
"tests/fur_pattern_validation.gd" "tests/fur_pattern_validation.gd"
"tests/logbook_validation.gd" "tests/logbook_validation.gd"
"tests/network_player_animation_protocol_validation.gd"
"tests/player_experience_validation.gd" "tests/player_experience_validation.gd"
"tests/shoreline_ambience_validation.gd" "tests/shoreline_ambience_validation.gd"
"tests/surface_drawing_validation.gd" "tests/surface_drawing_validation.gd"
@ -49,6 +50,7 @@ readonly -a NETWORK_TESTS=(
"tests/fish_showcase_multiplayer_validation.gd" "tests/fish_showcase_multiplayer_validation.gd"
"tests/fishing_multiplayer_validation.gd" "tests/fishing_multiplayer_validation.gd"
"tests/job_multiplayer_validation.gd" "tests/job_multiplayer_validation.gd"
"tests/movement_multiplayer_validation.gd"
"tests/operator_multiplayer_validation.gd" "tests/operator_multiplayer_validation.gd"
"tests/surface_drawing_multiplayer_validation.gd" "tests/surface_drawing_multiplayer_validation.gd"
"tests/world_time_multiplayer_validation.gd" "tests/world_time_multiplayer_validation.gd"

View file

@ -181,7 +181,7 @@ func _run() -> void:
var invalid_state: Dictionary = valid_state.duplicate(true) var invalid_state: Dictionary = valid_state.duplicate(true)
invalid_state["display_scale"] = 1000.0 invalid_state["display_scale"] = 1000.0
assert(not NetworkFishShowcaseProtocol.validate_state(invalid_state)) assert(not NetworkFishShowcaseProtocol.validate_state(invalid_state))
assert(NetworkProtocol.PROTOCOL_VERSION == 3) assert(NetworkProtocol.PROTOCOL_VERSION == 4)
assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10) assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10)
assert( assert(
NetworkProtocol.FISH_QUALITY_CAPABILITY NetworkProtocol.FISH_QUALITY_CAPABILITY

View file

@ -25,7 +25,7 @@ func _run() -> void:
_validate_mail_round_trip() _validate_mail_round_trip()
_validate_collection_mastery() _validate_collection_mastery()
_validate_version_four_migration() _validate_version_four_migration()
assert(NetworkProtocol.PROTOCOL_VERSION == 3) assert(NetworkProtocol.PROTOCOL_VERSION == 4)
assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10) assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10)
assert(NetworkProtocol.FISH_QUALITY_CAPABILITY == "fish_quality_v1") assert(NetworkProtocol.FISH_QUALITY_CAPABILITY == "fish_quality_v1")
print("Fish quality validation: PASS") print("Fish quality validation: PASS")

View file

@ -147,12 +147,6 @@ func _run_host() -> void:
await process_frame await process_frame
assert(remote_animation_player.current_animation == &"retract_sit") assert(remote_animation_player.current_animation == &"retract_sit")
var completion_deadline: int = Time.get_ticks_msec() + 12000
while (
Time.get_ticks_msec() < completion_deadline
and session.get_authenticated_peer_ids().size() == 2
):
await process_frame
print("Fishing multiplayer host validation: PASS") print("Fishing multiplayer host validation: PASS")
session.disconnect_session("") session.disconnect_session("")
main.queue_free() main.queue_free()
@ -337,7 +331,14 @@ func _run_client() -> void:
while Time.get_ticks_msec() < host_observation_deadline: while Time.get_ticks_msec() < host_observation_deadline:
await process_frame await process_frame
print("Fishing multiplayer client validation: PASS") print("Fishing multiplayer client validation: PASS")
session.disconnect_session("") var host_completion_deadline: int = Time.get_ticks_msec() + 10000
while (
Time.get_ticks_msec() < host_completion_deadline
and session.is_joined_client()
):
await process_frame
if session.is_joined_client():
session.disconnect_session("")
main.queue_free() main.queue_free()
for _frame: int in 4: for _frame: int in 4:
await process_frame await process_frame

View file

@ -87,6 +87,20 @@ func _run_client() -> void:
assert(not jobs.get_plan_id().is_empty()) assert(not jobs.get_plan_id().is_empty())
assert(jobs.get_daily_jobs().size() == JobCatalog.DAILY_JOB_COUNT) assert(jobs.get_daily_jobs().size() == JobCatalog.DAILY_JOB_COUNT)
assert(jobs.get_forecast().size() == JobCatalog.WEATHER_SEGMENT_COUNT) assert(jobs.get_forecast().size() == JobCatalog.WEATHER_SEGMENT_COUNT)
var preserved_plan_id: String = jobs.get_plan_id()
jobs.clear_remote_board()
assert(not jobs.has_active_board())
assert(jobs.get_plan_id() == preserved_plan_id)
var retry_deadline: int = Time.get_ticks_msec() + 5000
while (
Time.get_ticks_msec() < retry_deadline
and not jobs.has_active_board()
):
await process_frame
assert(jobs.has_active_board())
assert(jobs.get_plan_id() == preserved_plan_id)
assert(jobs.get_daily_jobs().size() == JobCatalog.DAILY_JOB_COUNT)
assert(jobs.get_forecast().size() == JobCatalog.WEATHER_SEGMENT_COUNT)
assert(weather.get_daily_plan_id().is_empty()) assert(weather.get_daily_plan_id().is_empty())
var game_ui := main.get_node("%GameUI") as GameUI var game_ui := main.get_node("%GameUI") as GameUI
var player_menu := game_ui.get("_player_menu") as PlayerMenu var player_menu := game_ui.get("_player_menu") as PlayerMenu

View file

@ -85,6 +85,7 @@ func _run() -> void:
(player_menu.get_node("%NavigationCluster") as Control).get_child_count() (player_menu.get_node("%NavigationCluster") as Control).get_child_count()
== 7 == 7
) )
await _validate_unavailable_fishnet_layout()
var wallet_before: int = player.wallet.get_balance() var wallet_before: int = player.wallet.get_balance()
var experience_before: int = player.experience.get_total_experience() var experience_before: int = player.experience.get_total_experience()
@ -162,6 +163,24 @@ func _run() -> void:
var pause_menu := game_ui.get_pause_menu() var pause_menu := game_ui.get_pause_menu()
var join_page := pause_menu.get_node("%JoinGamePage") as JoinGamePage var join_page := pause_menu.get_node("%JoinGamePage") as JoinGamePage
assert(join_page != null) assert(join_page != null)
pause_menu.show()
join_page.set_status("Connection timed out.")
join_page.show()
join_page.call(
"_on_discovery_status_changed", "2 public rooms found", false
)
var join_status := join_page.get_node("%Status") as Label
assert(join_status.text == "Could not reach the server.")
join_page.hide()
join_page.call("_set_mode", JoinGamePage.Mode.DIRECT)
join_page.call("_set_mode", JoinGamePage.Mode.DISCOVER)
join_page.show()
join_page.call(
"_on_discovery_status_changed", "2 public rooms found", false
)
assert(join_status.text == "2 public rooms found")
join_page.close_page()
pause_menu.hide()
join_page.call("_refresh") join_page.call("_refresh")
var session_summary := ( var session_summary := (
join_page.get_node("%SessionSummary") as Label join_page.get_node("%SessionSummary") as Label
@ -191,10 +210,37 @@ func _run() -> void:
main.queue_free() main.queue_free()
for _frame: int in 4: for _frame: int in 4:
await process_frame await process_frame
await create_timer(0.1).timeout
print("Job system validation: PASS") print("Job system validation: PASS")
quit() quit()
func _validate_unavailable_fishnet_layout() -> void:
var unavailable_page := TheNetPage.new()
unavailable_page.size = Vector2(1280.0, 720.0)
root.add_child(unavailable_page)
for _frame: int in 3:
await process_frame
unavailable_page.call("_refresh_forecast", true)
await process_frame
var forecast_list := unavailable_page.get("_forecast_list") as HBoxContainer
assert(forecast_list != null)
assert(forecast_list.size.x >= 272.0)
assert(forecast_list.size.y <= 66.0)
assert(forecast_list.get_child_count() == 1)
var placeholder := forecast_list.get_child(0) as Label
assert(placeholder != null)
assert(placeholder.size.x >= 252.0)
assert(placeholder.size.y <= 66.0)
assert(placeholder.autowrap_mode == TextServer.AUTOWRAP_OFF)
var tabs := unavailable_page.get("_tabs") as HBoxContainer
assert(tabs != null)
assert(tabs.position.y + tabs.size.y < UtilityPageStyle.LAPTOP_RECT.end.y)
unavailable_page.queue_free()
for _frame: int in 2:
await process_frame
func _validate_weather_guarantees(board: Dictionary) -> void: func _validate_weather_guarantees(board: Dictionary) -> void:
var jobs: Array = board.get("jobs", []) var jobs: Array = board.get("jobs", [])
var schedule: Array = board.get("weather_schedule", []) var schedule: Array = board.get("weather_schedule", [])

View file

@ -0,0 +1,163 @@
extends SceneTree
const MainScene: PackedScene = preload("res://main/main.tscn")
const TEST_PORT: int = 18141
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var arguments: PackedStringArray = OS.get_cmdline_user_args()
if arguments.has("host"):
await _run_host()
return
if arguments.has("client"):
await _run_client()
return
push_error("Movement multiplayer validation needs host or client mode.")
quit(1)
func _run_host() -> void:
var main: Node = await _create_initialized_main()
var session := main.get_node("%NetworkSession") as NetworkSession
var save_manager := main.get("_save_manager") as PlayerSaveManager
assert(session.start_private_host(TEST_PORT))
assert(save_manager.initialize_new_game())
main.call("_enter_gameplay")
for _frame: int in 4:
await physics_frame
assert(session.set_host_open(true))
var remote_peer_id: int = await _wait_for_remote_peer(session)
assert(remote_peer_id > 1)
var player := main.get("_player") as Player
player.configure_network_remote(true)
player.apply_authoritative_network_input(_movement_input(1, true))
await create_timer(2.5).timeout
player.apply_authoritative_network_input(_movement_input(2, false))
await create_timer(2.5).timeout
player.apply_authoritative_network_input(_movement_input(3, false, false))
var disconnect_deadline: int = Time.get_ticks_msec() + 8000
while (
Time.get_ticks_msec() < disconnect_deadline
and session.is_authenticated_peer(remote_peer_id)
):
await process_frame
assert(not session.is_authenticated_peer(remote_peer_id))
print("Movement multiplayer host validation: PASS")
session.disconnect_session("")
main.queue_free()
for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit()
func _run_client() -> void:
var main: Node = await _create_initialized_main()
main.call(
"_on_title_join_game_requested",
"127.0.0.1:%d" % TEST_PORT,
)
var session := main.get_node("%NetworkSession") as NetworkSession
var join_deadline: int = Time.get_ticks_msec() + 20000
while Time.get_ticks_msec() < join_deadline:
await process_frame
if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY:
main.call("_confirm_server_trust")
if session.is_joined_client() and bool(main.get("_gameplay_started")):
break
assert(session.is_joined_client())
var spawn_service := main.get_node(
"%PlayerSpawnService"
) as PlayerSpawnService
var host_avatar: Player = spawn_service.get_avatar(1)
assert(host_avatar != null)
var animation_player := host_avatar.get_node(
"Visuals/CharacterRig/AnimationPlayer"
) as AnimationPlayer
var sprint_dust := host_avatar.get_node("%SprintDust") as SprintDustTrail
var saw_running: bool = false
var saw_walking: bool = false
var saw_animation_advance: bool = false
var saw_dust: bool = false
var previous_animation: StringName = &""
var previous_animation_position: float = -1.0
var observation_deadline: int = Time.get_ticks_msec() + 7000
while Time.get_ticks_msec() < observation_deadline:
await process_frame
var current_animation: StringName = animation_player.current_animation
saw_running = saw_running or current_animation.begins_with("running")
saw_walking = saw_walking or current_animation.begins_with("walking")
var current_position: float = (
animation_player.current_animation_position
)
if (
current_animation == previous_animation
and previous_animation_position >= 0.0
and absf(current_position - previous_animation_position) > 0.001
):
saw_animation_advance = true
previous_animation = current_animation
previous_animation_position = current_position
saw_dust = saw_dust or sprint_dust.get_active_puff_count() > 0
if saw_running and saw_walking and saw_animation_advance and saw_dust:
break
assert(saw_running)
assert(saw_walking)
assert(saw_animation_advance)
assert(saw_dust)
print("Movement multiplayer client validation: PASS")
session.disconnect_session("")
main.queue_free()
for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit()
func _movement_input(
sequence: int,
sprinting: bool,
moving: bool = true,
) -> Dictionary:
return {
"sequence": sequence,
"axis": [0.0, -1.0] if moving else [0.0, 0.0],
"camera_yaw": 0.0,
"jump": false,
"sprint": sprinting,
"sneak": false,
"slow_walk": false,
"sitting": false,
"casting": false,
"animation_action": (
NetworkPlayerAnimationProtocol.make_action_state()
),
}
func _create_initialized_main() -> Node:
root.size = Vector2i(1280, 720)
var main: Node = MainScene.instantiate()
root.add_child(main)
for _frame: int in 4:
await process_frame
if not bool(main.get("_application_initialized")):
main.call("_activate_selected_data_path", "", true)
for _frame: int in 8:
await process_frame
assert(bool(main.get("_application_initialized")))
return main
func _wait_for_remote_peer(session: NetworkSession) -> int:
var deadline: int = Time.get_ticks_msec() + 20000
while Time.get_ticks_msec() < deadline:
await process_frame
for peer_id: int in session.get_authenticated_peer_ids():
if peer_id != session.get_local_peer_id():
return peer_id
return 0

View file

@ -0,0 +1 @@
uid://dlpu5lwb2pges

View file

@ -0,0 +1,49 @@
extends SceneTree
func _initialize() -> void:
var idle := NetworkPlayerAnimationProtocol.make_state(
NetworkPlayerAnimationProtocol.LOCOMOTION_IDLE,
true,
)
assert(NetworkPlayerAnimationProtocol.validate_state(idle))
var emote := NetworkPlayerAnimationProtocol.make_state(
NetworkPlayerAnimationProtocol.LOCOMOTION_RUNNING,
false,
&"wave_hello",
17,
1.25,
)
assert(NetworkPlayerAnimationProtocol.validate_state(emote))
var future_locomotion := NetworkPlayerAnimationProtocol.make_state(
&"swimming_fast",
false,
)
assert(NetworkPlayerAnimationProtocol.validate_state(future_locomotion))
var extra_future_field: Dictionary = emote.duplicate(true)
extra_future_field["future_layer"] = {"id": "umbrella"}
assert(NetworkPlayerAnimationProtocol.validate_state(extra_future_field))
var invalid_action: Dictionary = emote.duplicate(true)
invalid_action["action"] = {
"id": "../unsafe",
"sequence": 17,
"elapsed": 1.25,
}
assert(not NetworkPlayerAnimationProtocol.validate_state(invalid_action))
var invalid_sequence: Dictionary = emote.duplicate(true)
invalid_sequence["action"] = {
"id": "wave_hello",
"sequence": -1,
"elapsed": 1.25,
}
assert(not NetworkPlayerAnimationProtocol.validate_state(invalid_sequence))
var invalid_elapsed: Dictionary = emote.duplicate(true)
invalid_elapsed["action"] = {
"id": "wave_hello",
"sequence": 17,
"elapsed": INF,
}
assert(not NetworkPlayerAnimationProtocol.validate_state(invalid_elapsed))
print("Network player animation protocol validation: PASS")
quit()

View file

@ -0,0 +1 @@
uid://cjgelp62dkykn

View file

@ -62,6 +62,7 @@ var _discovery_refresh_timer: Timer
var _editing_entry_id: String = "" var _editing_entry_id: String = ""
var _name_entry_active: bool = false var _name_entry_active: bool = false
var _delete_armed: bool = false var _delete_armed: bool = false
var _connection_error_latched: bool = false
func _ready() -> void: func _ready() -> void:
@ -181,7 +182,7 @@ func open_page(preserved_endpoint: String = "") -> void:
_address.text = "127.0.0.1:7777" _address.text = "127.0.0.1:7777"
show() show()
UtilityPageStyle.animate_in(self) UtilityPageStyle.animate_in(self)
_set_mode(_mode) _set_mode(_mode, false)
if _mode == Mode.DIRECT: if _mode == Mode.DIRECT:
_address.grab_focus() _address.grab_focus()
_address.select_all() _address.select_all()
@ -201,10 +202,13 @@ func get_endpoint_text() -> String:
func set_status(message: String) -> void: func set_status(message: String) -> void:
_connection_error_latched = true
_set_status(_friendly_connection_message(message), true) _set_status(_friendly_connection_message(message), true)
func _set_mode(mode: Mode) -> void: func _set_mode(mode: Mode, clear_connection_error: bool = true) -> void:
if clear_connection_error:
_connection_error_latched = false
_mode = mode _mode = mode
_selected_entry = null _selected_entry = null
_selected_discovery_index = -1 _selected_discovery_index = -1
@ -225,6 +229,7 @@ func _set_mode(mode: Mode) -> void:
func _request_join() -> void: func _request_join() -> void:
_connection_error_latched = false
if _network_session.state in [ if _network_session.state in [
NetworkSession.State.CONNECTION_FAILED, NetworkSession.State.CONNECTION_FAILED,
NetworkSession.State.SERVER_LOST, NetworkSession.State.SERVER_LOST,
@ -267,6 +272,10 @@ func _on_public_join_prepared(endpoint_text: String) -> void:
func _on_public_join_status_changed(message: String, is_error: bool) -> void: func _on_public_join_status_changed(message: String, is_error: bool) -> void:
if _mode == Mode.DISCOVER and is_visible_in_tree(): if _mode == Mode.DISCOVER and is_visible_in_tree():
if is_error:
_connection_error_latched = true
elif _connection_error_latched:
return
_set_status(message, is_error) _set_status(message, is_error)
_refresh() _refresh()
@ -708,7 +717,11 @@ func _on_discovery_rooms_updated(rooms: Array[Dictionary]) -> void:
func _on_discovery_status_changed(message: String, is_error: bool) -> void: func _on_discovery_status_changed(message: String, is_error: bool) -> void:
if _mode == Mode.DISCOVER and is_visible_in_tree(): if (
_mode == Mode.DISCOVER
and is_visible_in_tree()
and not _connection_error_latched
):
_set_status(message, is_error) _set_status(message, is_error)
@ -740,6 +753,7 @@ func _friendly_connection_message(message: String) -> String:
return "Connection cancelled." return "Connection cancelled."
if ( if (
"timeout" in normalized "timeout" in normalized
or "timed out" in normalized
or "unavailable" in normalized or "unavailable" in normalized
or "refused" in normalized or "refused" in normalized
or "reach" in normalized or "reach" in normalized
@ -769,6 +783,7 @@ func _clear_edit_state() -> void:
func _on_cancel_pressed() -> void: func _on_cancel_pressed() -> void:
_connection_error_latched = false
if _network_session != null: if _network_session != null:
_network_session.cancel_connection() _network_session.cancel_connection()
_refresh() _refresh()
@ -795,11 +810,14 @@ func _on_state_changed(_state: NetworkSession.State) -> void:
func _on_status_message_changed(message: String) -> void: func _on_status_message_changed(message: String) -> void:
if _connection_error_latched:
return
_set_status(_friendly_connection_message(message)) _set_status(_friendly_connection_message(message))
_refresh() _refresh()
func _on_connection_error(message: String) -> void: func _on_connection_error(message: String) -> void:
_connection_error_latched = true
_set_status(_friendly_connection_message(message), true) _set_status(_friendly_connection_message(message), true)
_refresh() _refresh()

View file

@ -158,6 +158,8 @@ func _build_laptop() -> void:
) )
forecast_stack.add_child(forecast_heading) forecast_stack.add_child(forecast_heading)
_forecast_list = HBoxContainer.new() _forecast_list = HBoxContainer.new()
_forecast_list.custom_minimum_size = Vector2(272.0, 66.0)
_forecast_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_forecast_list.alignment = BoxContainer.ALIGNMENT_CENTER _forecast_list.alignment = BoxContainer.ALIGNMENT_CENTER
_forecast_list.add_theme_constant_override("separation", 6) _forecast_list.add_theme_constant_override("separation", 6)
forecast_stack.add_child(_forecast_list) forecast_stack.add_child(_forecast_list)
@ -501,7 +503,10 @@ func _daily_refresh_text() -> String:
func _add_forecast_empty(message: String) -> void: func _add_forecast_empty(message: String) -> void:
var label := Label.new() var label := Label.new()
label.text = message label.text = message
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART label.custom_minimum_size = Vector2(252.0, 66.0)
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.autowrap_mode = TextServer.AUTOWRAP_OFF
label.add_theme_color_override( label.add_theme_color_override(
"font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
) )