diff --git a/economy/item_resale_policy.gd b/economy/item_resale_policy.gd index 299fc48..bfc5944 100644 --- a/economy/item_resale_policy.gd +++ b/economy/item_resale_policy.gd @@ -18,4 +18,4 @@ static func get_unit_value(item: ItemDataType) -> int: if not is_sellable(item): return -1 var purchase_price := FishingShopStockType.get_price(item.item_id) - return purchase_price / 2 if purchase_price >= 0 else -1 + return int(float(purchase_price) / 2.0) if purchase_price >= 0 else -1 diff --git a/gathering/gathering_controller.gd b/gathering/gathering_controller.gd index c061db5..36aa2d5 100644 --- a/gathering/gathering_controller.gd +++ b/gathering/gathering_controller.gd @@ -262,10 +262,10 @@ func _cast_marker_ray(from: Vector3, to: Vector3) -> Dictionary: func _apply_marker_surface(hit: Dictionary, facing: Vector3) -> bool: if hit.is_empty(): return false - var position: Variant = hit.get("position") + var surface_position: Variant = hit.get("position") var normal_value: Variant = hit.get("normal") if ( - typeof(position) != TYPE_VECTOR3 + typeof(surface_position) != TYPE_VECTOR3 or typeof(normal_value) != TYPE_VECTOR3 ): return false @@ -274,7 +274,7 @@ func _apply_marker_surface(hit: Dictionary, facing: Vector3) -> bool: return false surface_normal = surface_normal.normalized() _marker_position = ( - (position as Vector3) + surface_normal * marker_surface_offset + (surface_position as Vector3) + surface_normal * marker_surface_offset ) _marker.global_position = _marker_position _marker.global_basis = marker_basis_for_surface(surface_normal, facing) diff --git a/gathering/world_gatherable.gd b/gathering/world_gatherable.gd index 1a71477..a30ffc7 100644 --- a/gathering/world_gatherable.gd +++ b/gathering/world_gatherable.gd @@ -23,7 +23,7 @@ var _despawning: bool = false func configure( configured_entity_id: String, configured_data: GatherableDataType, - position: Vector3, + network_position: Vector3, yaw: float, ) -> void: entity_id = configured_entity_id @@ -31,7 +31,7 @@ func configure( type_id = data.type_id if data != null else StringName() # Establish the authoritative transform before constructing transient visuals. # Effects emitted before this point would begin at the default world origin. - apply_network_state(position, yaw, true) + apply_network_state(network_position, yaw, true) if data != null and data.is_stationary_hotspot(): _ensure_water_spurt_visual() _water_spurt_elapsed = ( @@ -57,16 +57,16 @@ func configure( func apply_network_state( - position: Vector3, + network_position: Vector3, yaw: float, immediate: bool = false, ) -> void: - if _despawning or not position.is_finite() or not is_finite(yaw): + if _despawning or not network_position.is_finite() or not is_finite(yaw): return - _target_position = position + _target_position = network_position _target_yaw = yaw if immediate or not _has_state: - global_position = position + global_position = network_position rotation.y = yaw _has_state = true diff --git a/homes/home_world_service.gd b/homes/home_world_service.gd index 4ec0645..6148776 100644 --- a/homes/home_world_service.gd +++ b/homes/home_world_service.gd @@ -253,19 +253,19 @@ func _find_exterior_transform( ] if not _is_exterior_candidate_triangle(triangle): continue - var position: Vector3 = ( + var candidate_position: Vector3 = ( triangle[0] + triangle[1] + triangle[2] ) / 3.0 if ( require_spawn_clearance - and position.distance_to(spawn_transform.origin) < 9.0 + and candidate_position.distance_to(spawn_transform.origin) < 9.0 ): continue - if _too_close_to_reserved(position, reserved_transforms): + if _too_close_to_reserved(candidate_position, reserved_transforms): continue var yaw: float = float(posmod(owner_peer_id + offset, 4)) * PI * 0.5 var grounded_position: Vector3 = _ground_exterior_footprint( - position, yaw, triangles, surface_index + candidate_position, yaw, triangles, surface_index ) if not grounded_position.is_finite(): continue @@ -299,7 +299,7 @@ func _is_exterior_candidate_triangle(triangle: PackedVector3Array) -> bool: func _ground_exterior_footprint( - position: Vector3, + candidate_position: Vector3, yaw: float, triangles: Array[PackedVector3Array], surface_index: Dictionary, @@ -310,11 +310,11 @@ func _ground_exterior_footprint( local_sample.x, 0.0, local_sample.y ).rotated(Vector3.UP, yaw) var sample := Vector2( - position.x + rotated_sample.x, - position.z + rotated_sample.z, + candidate_position.x + rotated_sample.x, + candidate_position.z + rotated_sample.z, ) var height: float = _surface_height_at( - sample, position.y, triangles, surface_index + sample, candidate_position.y, triangles, surface_index ) if not is_finite(height): return Vector3.INF @@ -323,7 +323,9 @@ func _ground_exterior_footprint( var maximum_height: float = heights.max() if maximum_height - minimum_height > EXTERIOR_MAX_SURFACE_HEIGHT_DELTA: return Vector3.INF - return Vector3(position.x, maximum_height, position.z) + return Vector3( + candidate_position.x, maximum_height, candidate_position.z + ) func _exterior_footprint_samples() -> Array[Vector2]: @@ -434,7 +436,9 @@ func _triangle_height_at( ) -func _exterior_volume_is_clear(position: Vector3, yaw: float) -> bool: +func _exterior_volume_is_clear( + candidate_position: Vector3, yaw: float +) -> bool: if not is_inside_tree() or get_world_3d() == null: return true var shape := BoxShape3D.new() @@ -447,7 +451,8 @@ func _exterior_volume_is_clear(position: Vector3, yaw: float) -> bool: query.shape = shape query.transform = Transform3D( Basis(Vector3.UP, yaw), - position + Vector3.UP * (EXTERIOR_CLEARANCE_HEIGHT * 0.5 + 0.2), + candidate_position + + Vector3.UP * (EXTERIOR_CLEARANCE_HEIGHT * 0.5 + 0.2), ) query.collision_mask = 1 query.collide_with_areas = false @@ -563,9 +568,11 @@ func get_interior_anchor(owner_fingerprint: String) -> Vector3: func is_avatar_near_exterior(avatar: Player, owner_fingerprint: String) -> bool: if avatar == null or not _presentations_by_fingerprint.has(owner_fingerprint): return false - var transform: Transform3D = get_exterior_transform(owner_fingerprint) + var exterior_transform: Transform3D = get_exterior_transform( + owner_fingerprint + ) var door_position: Vector3 = ( - transform * _exterior_door_local(owner_fingerprint) + exterior_transform * _exterior_door_local(owner_fingerprint) ) return avatar.global_position.distance_to(door_position) <= ( EXTERIOR_INTERACTION_DISTANCE @@ -639,9 +646,9 @@ func interior_local_to_global( func interior_global_to_local( owner_fingerprint: String, - global_position: Vector3, + world_position: Vector3, ) -> Vector3: - return global_position - get_interior_anchor(owner_fingerprint) + return world_position - get_interior_anchor(owner_fingerprint) func get_floor_position_from_screen( @@ -709,15 +716,19 @@ func get_decor_preview_transform( func set_decor_preview_transform( owner_fingerprint: String, instance_id: String, - position: Vector3, + decor_position: Vector3, yaw_radians: float, ) -> bool: var decor: Node3D = _get_decor_presentation( owner_fingerprint, instance_id ) - if decor == null or not position.is_finite() or not is_finite(yaw_radians): + if ( + decor == null + or not decor_position.is_finite() + or not is_finite(yaw_radians) + ): return false - decor.position = Vector3(position.x, 0.18, position.z) + decor.position = Vector3(decor_position.x, 0.18, decor_position.z) decor.rotation.y = yaw_radians return true @@ -817,9 +828,11 @@ func find_nearby_exterior_owner(avatar: Player) -> String: var closest: String = "" var closest_distance: float = EXTERIOR_INTERACTION_DISTANCE for fingerprint: String in _presentations_by_fingerprint: - var transform: Transform3D = get_exterior_transform(fingerprint) + var exterior_transform: Transform3D = get_exterior_transform( + fingerprint + ) var distance: float = avatar.global_position.distance_to( - transform * _exterior_door_local(fingerprint) + exterior_transform * _exterior_door_local(fingerprint) ) if distance <= closest_distance: closest = fingerprint @@ -1261,16 +1274,16 @@ func _add_wall( parent: Node3D, node_name: String, size: Vector3, - position: Vector3, + local_position: Vector3, ) -> void: - _add_box(parent, node_name, size, position, Color("d6caaa"), true) + _add_box(parent, node_name, size, local_position, Color("d6caaa"), true) func _add_box( parent: Node3D, node_name: String, size: Vector3, - position: Vector3, + local_position: Vector3, color: Color, with_collision: bool, invert_faces: bool = false, @@ -1283,10 +1296,10 @@ func _add_box( if invert_faces: mesh.flip_faces = true mesh_instance.mesh = mesh - mesh_instance.position = position + mesh_instance.position = local_position parent.add_child(mesh_instance) if with_collision: - return _add_box_collision(parent, node_name, size, position) + return _add_box_collision(parent, node_name, size, local_position) return null @@ -1294,11 +1307,11 @@ func _add_box_collision( parent: Node3D, node_name: String, size: Vector3, - position: Vector3, + local_position: Vector3, ) -> StaticBody3D: var body := StaticBody3D.new() body.name = "%sCollision" % node_name - body.position = position + body.position = local_position var collision := CollisionShape3D.new() var shape := BoxShape3D.new() shape.size = size @@ -1490,15 +1503,18 @@ func _refresh_nearest_wall() -> void: func _too_close_to_reserved( - position: Vector3, + candidate_position: Vector3, reserved_transforms: Array[Transform3D], ) -> bool: - for transform: Transform3D in reserved_transforms: - if position.distance_to(transform.origin) < MIN_EXTERIOR_SEPARATION: + for reserved_transform: Transform3D in reserved_transforms: + if ( + candidate_position.distance_to(reserved_transform.origin) + < MIN_EXTERIOR_SEPARATION + ): return true return false -func _set_geometry_visible(root: Node, visible: bool) -> void: +func _set_geometry_visible(root: Node, should_show: bool) -> void: for node: Node in root.find_children("*", "GeometryInstance3D", true, false): - (node as GeometryInstance3D).visible = visible + (node as GeometryInstance3D).visible = should_show diff --git a/homes/player_home_state.gd b/homes/player_home_state.gd index a89d6a3..87643c1 100644 --- a/homes/player_home_state.gd +++ b/homes/player_home_state.gd @@ -369,7 +369,7 @@ func restore_from_save_data(data: Dictionary) -> bool: return false _tier = int(sanitized["tier"]) _exterior_id = exterior_id_for_tier(_tier) - _privacy = int(sanitized["privacy"]) + _privacy = int(sanitized["privacy"]) as Privacy _owned_decor.clear() for key: Variant in (sanitized["owned_decor"] as Dictionary): _owned_decor[StringName(str(key))] = int( diff --git a/main/main.gd b/main/main.gd index 324c268..f7f4f0f 100644 --- a/main/main.gd +++ b/main/main.gd @@ -1541,6 +1541,12 @@ func _configure_popup_dialog( func _input(event: InputEvent) -> void: + # The exit walk and its authoritative relocation own gameplay input until the + # return fade completes. This also keeps a late cancel/pause press from + # manipulating a menu while the world space is changing. + if _home_exit_transition_active: + get_viewport().set_input_as_handled() + return # Do not let controller aliases close or manipulate the world/menu behind # the modal on-screen keyboard. The keyboard consumes the event itself. if _game_ui.is_controller_text_entry_open(): @@ -1671,6 +1677,9 @@ func _is_pause_open_request(event: InputEvent) -> bool: func _unhandled_input(event: InputEvent) -> void: + if _home_exit_transition_active: + get_viewport().set_input_as_handled() + return if ( _gameplay_started and event.is_action_pressed("fish_primary") @@ -1858,6 +1867,12 @@ func _reset_pixelation() -> void: func _set_gameplay_active(active: bool) -> void: var was_gameplay_started: bool = _gameplay_started _gameplay_started = active + # Session loss, a world switch, or a return to title can interrupt the home + # handoff before its fade callback runs. Do not carry its input suppressors + # or black screen into the next gameplay session. + if not active: + _cancel_home_entry_transition() + _cancel_home_exit_transition(true) _shoreline_ambience.set_active(active) _rain_ambience.set_active(active) if active and not was_gameplay_started: @@ -2499,6 +2514,7 @@ func _on_local_respawn_completed(entry_position: Vector3) -> void: func _on_remote_recovery_requested( peer_id: int, entry_position: Vector3, + recovery_input_sequence: int, ) -> void: if not _network_session.is_host(): return @@ -2509,6 +2525,9 @@ func _on_remote_recovery_requested( peer_id, "Fishing attempt ended." ) + avatar.neutralize_authoritative_input_for_relocation( + recovery_input_sequence + ) var target_position := _test_world.get_water_recovery_position( entry_position ) @@ -3039,6 +3058,12 @@ func _begin_home_exit_transition(owner_fingerprint: String) -> void: _refresh_home_door_prompt({}) _player.set_local_input_suppressed(HOME_EXIT_INPUT_OWNER, true) _player.set_camera_input_suppressed(HOME_EXIT_INPUT_OWNER, true) + # A same-frame interact can otherwise open home storage after the threshold + # was crossed. The exit suppressor is already active, so restoring storage's + # base snapshot cannot return effective control before the transition ends. + _game_ui.get_player_storage().close_storage() + _game_ui.set_rv_storage_button_visible(false) + _game_ui.set_gameplay_input_locked(true) var exit_direction: Vector3 = _home_world.get_interior_exit_direction( owner_fingerprint ) @@ -3126,6 +3151,11 @@ func _cancel_home_exit_transition(reset_fade: bool) -> void: _home_exit_transition_active = false _home_exit_waiting_for_network = false _home_exit_start_position = Vector3.ZERO + if is_instance_valid(_game_ui): + _game_ui.set_gameplay_input_locked(false) + _game_ui.set_rv_storage_button_visible( + _can_show_rv_storage_button() + ) func _on_rv_storage_requested() -> void: @@ -3247,6 +3277,7 @@ func _refresh_home_door_prompt(prompt: Dictionary) -> void: func _can_show_rv_storage_button() -> bool: return ( _gameplay_started + and not _home_exit_transition_active and not _network_home.get_local_home_owner_fingerprint().is_empty() and _network_home.can_local_decorate() and not _water_recovery.is_recovery_active() @@ -3257,6 +3288,7 @@ func _can_show_rv_storage_button() -> bool: func _get_home_prompt() -> Dictionary: if ( not _gameplay_started + or _home_exit_transition_active or _game_ui.get_player_storage().visible or _game_ui.get_fishing_shop().visible or _game_ui.get_decor_shop().visible diff --git a/network/discovery_client.gd b/network/discovery_client.gd index d8e602a..1e9272f 100644 --- a/network/discovery_client.gd +++ b/network/discovery_client.gd @@ -1042,9 +1042,11 @@ func _valid_json_integer(value: Variant, minimum: int, maximum: int) -> bool: ) -func _make_social_request(name: String, callback: Callable) -> HTTPRequest: +func _make_social_request( + request_name: String, callback: Callable +) -> HTTPRequest: var request := HTTPRequest.new() - request.name = name + request.name = request_name request.timeout = REQUEST_TIMEOUT_SECONDS add_child(request) request.request_completed.connect(callback) diff --git a/network/network_chat_service.gd b/network/network_chat_service.gd index 55ba768..03b6e66 100644 --- a/network/network_chat_service.gd +++ b/network/network_chat_service.gd @@ -340,11 +340,12 @@ func _handle_request(peer_id: int, data: Dictionary) -> void: message["request_id"] = request_id message["sender_fingerprint"] = data["sender_fingerprint"] message["sender_signature"] = data["sender_signature"] - ledger[request_id] = ( - message.duplicate(true) - if _should_store_host_history() - else true - ) + # `true` is the intentional no-history replay sentinel. It records that the + # request was handled without retaining a message payload for replay. + var ledger_entry: Variant = true + if _should_store_host_history(): + ledger_entry = message.duplicate(true) + ledger[request_id] = ledger_entry while ledger.size() > 64: ledger.erase(ledger.keys().front()) _request_ledgers[peer_id] = ledger diff --git a/network/network_session.gd b/network/network_session.gd index 7c259e0..9576ceb 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -29,6 +29,11 @@ const ANIMATION_REFRESH_INTERVAL: float = 1.0 const MAX_MOVEMENT_INPUT_SEQUENCE: int = 2147483647 const MAX_MOVEMENT_ONE_WAY_TRANSIT_SECONDS: float = 0.25 const SPACE_TRANSITION_SNAPSHOT_GUARD_DISTANCE: float = 12.0 +# A reliable teleport travels on a different channel from ordinary movement +# audits. Keep the acceptance radius below Player's hard-reconciliation +# distance so an old audit can never pull a just-relocated local player back +# into the place they were recovered from. +const AUTHORITATIVE_TELEPORT_SNAPSHOT_GUARD_DISTANCE: float = 2.0 const MOVEMENT_FLAG_JUMP: int = 1 << 0 const MOVEMENT_FLAG_SPRINT: int = 1 << 1 @@ -77,7 +82,11 @@ signal server_trust_required( signal peer_identity_observed(peer_id: int, status: String) signal operator_status_changed(peer_id: int, is_operator: bool) signal server_lost(message: String) -signal remote_recovery_requested(peer_id: int, entry_position: Vector3) +signal remote_recovery_requested( + peer_id: int, + entry_position: Vector3, + recovery_input_sequence: int, +) signal remote_recovery_presentation_changed( peer_id: int, active: bool, @@ -128,6 +137,9 @@ var _last_local_snapshot_received_msec: int = 0 var _local_space_transition_guard_active: bool = false var _local_space_transition_guard_position: Vector3 = Vector3.ZERO var _local_space_transition_minimum_ack: int = 0 +var _local_authoritative_teleport_guard_active: bool = false +var _local_authoritative_teleport_guard_position: Vector3 = Vector3.ZERO +var _local_authoritative_teleport_minimum_ack: int = 0 var _animation_refresh_accumulator: float = 0.0 var _last_animation_state_by_peer: Dictionary[int, Dictionary] = {} var _pending_animation_state_by_peer: Dictionary[int, Dictionary] = {} @@ -601,20 +613,20 @@ func get_last_server_metadata() -> Dictionary: } -func set_host_world_seed(seed: int) -> bool: - return set_host_world(WorldLayout.GENERATED, seed) +func set_host_world_seed(world_seed: int) -> bool: + return set_host_world(WorldLayout.GENERATED, world_seed) -func set_host_world(world_layout: StringName, seed: int) -> bool: +func set_host_world(world_layout: StringName, world_seed: int) -> bool: if ( state != State.INACTIVE or not WorldLayout.is_valid(world_layout) - or seed <= 0 - or seed > NetworkProtocol.MAX_WORLD_SEED + or world_seed <= 0 + or world_seed > NetworkProtocol.MAX_WORLD_SEED ): return false _host_world_layout = world_layout - _host_world_seed = seed + _host_world_seed = world_seed return true @@ -2088,10 +2100,10 @@ func _maybe_send_local_input() -> void: if avatar == null: return var state_hash: int = avatar.get_network_input_state_hash() - var state_changed: bool = state_hash != _last_input_state_hash + var input_state_changed: bool = state_hash != _last_input_state_hash if ( avatar.has_active_network_input() - or state_changed + or input_state_changed or _idle_input_accumulator >= IDLE_INPUT_INTERVAL ): _send_local_input() @@ -2125,8 +2137,8 @@ func _maybe_send_local_animation_action() -> void: var avatar: Player = _spawn_service.get_avatar(peer_id) if avatar == null: return - var state: Dictionary = avatar.make_network_animation_state() - var action: Dictionary = state.get("action", {}) + var animation_state: Dictionary = avatar.make_network_animation_state() + var action: Dictionary = animation_state.get("action", {}) if not NetworkPlayerAnimationProtocol.validate_action_state(action): return var signature: Array = [ @@ -2182,11 +2194,15 @@ func submit_movement_animation_action(encoded: Array) -> void: if not is_host() or not _registry.has_peer(sender_id): return var avatar: Player = _spawn_service.get_avatar(sender_id) - var state: Dictionary = _decode_movement_animation_action(encoded) - if avatar == null or state.is_empty(): + var animation_state: Dictionary = _decode_movement_animation_action(encoded) + if avatar == null or animation_state.is_empty(): return - avatar.apply_authoritative_network_animation_action(state["action"]) - avatar.apply_authoritative_network_sitting_state(bool(state["sitting"])) + avatar.apply_authoritative_network_animation_action( + animation_state["action"] + ) + avatar.apply_authoritative_network_sitting_state( + bool(animation_state["sitting"]) + ) static func _encode_movement_input(data: Dictionary) -> Array: @@ -2386,18 +2402,20 @@ func _broadcast_movement_animation_updates(peer_ids: Array[int]) -> void: var avatar: Player = _spawn_service.get_avatar(subject_id) if avatar == null: continue - var state: Dictionary = avatar.make_network_animation_state() + var animation_state: Dictionary = avatar.make_network_animation_state() var previous: Dictionary = _last_animation_state_by_peer.get( subject_id, {} ) if ( not refresh_all - and _movement_animation_signature(state) + and _movement_animation_signature(animation_state) == _movement_animation_signature(previous) ): continue - _last_animation_state_by_peer[subject_id] = state.duplicate(true) - var encoded: Array = _encode_movement_animation(subject_id, state) + _last_animation_state_by_peer[subject_id] = animation_state.duplicate(true) + var encoded: Array = _encode_movement_animation( + subject_id, animation_state + ) if not encoded.is_empty(): updates_by_subject[subject_id] = encoded if updates_by_subject.is_empty(): @@ -2416,13 +2434,13 @@ func _broadcast_movement_animation_updates(peer_ids: Array[int]) -> void: _movement_animation_states_sent += updates.size() -static func _movement_animation_signature(state: Dictionary) -> Array: - if not NetworkPlayerAnimationProtocol.validate_state(state): +static func _movement_animation_signature(animation_state: Dictionary) -> Array: + if not NetworkPlayerAnimationProtocol.validate_state(animation_state): return [] - var action: Dictionary = state["action"] + var action: Dictionary = animation_state["action"] return [ - str(state["locomotion_id"]), - bool(state["grounded"]), + str(animation_state["locomotion_id"]), + bool(animation_state["grounded"]), str(action["id"]), int(action["sequence"]), bool(action.get("paused", false)), @@ -2505,15 +2523,15 @@ static func _decode_movement_snapshot(value: Variant) -> Dictionary: static func _encode_movement_animation( peer_id: int, - state: Dictionary, + animation_state: Dictionary, ) -> Array: - if not NetworkPlayerAnimationProtocol.validate_state(state): + if not NetworkPlayerAnimationProtocol.validate_state(animation_state): return [] - var action: Dictionary = state["action"] + var action: Dictionary = animation_state["action"] return [ peer_id, - str(state["locomotion_id"]), - bool(state["grounded"]), + str(animation_state["locomotion_id"]), + bool(animation_state["grounded"]), str(action["id"]), int(action["sequence"]), float(action["elapsed"]), @@ -2536,7 +2554,7 @@ static func _decode_movement_animation(value: Variant) -> Dictionary: or typeof(fields[6]) != TYPE_BOOL ): return {} - var state: Dictionary = NetworkPlayerAnimationProtocol.make_state( + var animation_state: Dictionary = NetworkPlayerAnimationProtocol.make_state( StringName(str(fields[1])), bool(fields[2]), StringName(str(fields[3])), @@ -2546,10 +2564,10 @@ static func _decode_movement_animation(value: Variant) -> Dictionary: ) if ( int(fields[0]) <= 0 - or not NetworkPlayerAnimationProtocol.validate_state(state) + or not NetworkPlayerAnimationProtocol.validate_state(animation_state) ): return {} - return {"peer_id": int(fields[0]), "state": state} + return {"peer_id": int(fields[0]), "state": animation_state} static func _encode_movement_animation_action( @@ -2610,7 +2628,10 @@ func receive_movement_snapshots(encoded_snapshots: Array) -> void: if avatar == null: continue if peer_id == local_peer_id: - if _reject_stale_space_transition_snapshot(snapshot): + if ( + _reject_stale_space_transition_snapshot(snapshot) + or _reject_stale_authoritative_teleport_snapshot(snapshot) + ): continue _discard_acknowledged_movement_inputs( int(snapshot.get("acknowledged_input", 0)) @@ -2656,6 +2677,37 @@ func _reject_stale_space_transition_snapshot(snapshot: Dictionary) -> bool: return false +func _reject_stale_authoritative_teleport_snapshot( + snapshot: Dictionary, +) -> bool: + if not _local_authoritative_teleport_guard_active: + return false + var position_values: Array = snapshot.get("position", []) + if position_values.size() != 3: + return true + var position := Vector3( + float(position_values[0]), + float(position_values[1]), + float(position_values[2]), + ) + var acknowledged_input: int = int( + snapshot.get("acknowledged_input", 0) + ) + if acknowledged_input < _local_authoritative_teleport_minimum_ack: + return true + # Movement input and reliable recovery RPCs travel on different channels. + # A higher input acknowledgement alone therefore does not prove that this + # snapshot was authored after the teleport; a held pre-recovery packet can + # arrive at the host late. Only a snapshot that is still near the teleported + # position is allowed to release the barrier. + if position.distance_to(_local_authoritative_teleport_guard_position) > ( + AUTHORITATIVE_TELEPORT_SNAPSHOT_GUARD_DISTANCE + ): + return true + _local_authoritative_teleport_guard_active = false + return false + + func _discard_acknowledged_movement_inputs(acknowledged_sequence: int) -> void: while ( not _pending_movement_inputs.is_empty() @@ -2823,11 +2875,16 @@ func request_safe_respawn(entry_position: Vector3) -> void: if not entry_position.is_finite(): return if is_host(): - remote_recovery_requested.emit(1, entry_position) + remote_recovery_requested.emit(1, entry_position, _input_sequence) elif state == State.JOINED_CLIENT: submit_safe_respawn_request.rpc_id( 1, - [entry_position.x, entry_position.y, entry_position.z] + [ + entry_position.x, + entry_position.y, + entry_position.z, + _input_sequence, + ] ) @@ -2837,9 +2894,18 @@ func submit_safe_respawn_request(position_data: Array) -> void: if ( not is_host() or not _registry.has_peer(sender_id) - or position_data.size() != 3 + or position_data.size() not in [3, 4] ): return + var recovery_input_sequence: int = -1 + if position_data.size() == 4: + if ( + typeof(position_data[3]) != TYPE_INT + or int(position_data[3]) < 0 + or int(position_data[3]) > MAX_MOVEMENT_INPUT_SEQUENCE + ): + return + recovery_input_sequence = int(position_data[3]) var entry_position := Vector3( float(position_data[0]), float(position_data[1]), @@ -2847,7 +2913,11 @@ func submit_safe_respawn_request(position_data: Array) -> void: ) if not entry_position.is_finite(): return - remote_recovery_requested.emit(sender_id, entry_position) + remote_recovery_requested.emit( + sender_id, + entry_position, + recovery_input_sequence, + ) @rpc("authority", "call_remote", "reliable", 0) @@ -2856,8 +2926,38 @@ func receive_authoritative_teleport(snapshot: Dictionary) -> void: return var peer_id: int = int(snapshot.get("peer_id", 0)) var avatar: Player = _spawn_service.get_avatar(peer_id) - if avatar != null: - avatar.apply_network_teleport(snapshot) + if avatar == null or not avatar.apply_network_teleport(snapshot): + return + if peer_id != multiplayer.get_unique_id(): + return + _begin_local_authoritative_teleport_barrier( + avatar, + int(snapshot.get("acknowledged_input", 0)), + ) + + +func _begin_local_authoritative_teleport_barrier( + avatar: Player, + acknowledged_input: int, +) -> void: + if avatar == null or not avatar.global_position.is_finite(): + return + # A recovery teleport is a movement discontinuity, just like a home-space + # transition. Drop pre-teleport client prediction and request an immediate + # neutral input so the first post-teleport audit is unambiguous. + _pending_movement_inputs.clear() + _last_local_snapshot_received_msec = 0 + _last_input_state_hash = 0 + _idle_input_accumulator = IDLE_INPUT_INTERVAL + avatar.reset_local_prediction_after_authoritative_teleport() + _local_authoritative_teleport_guard_active = true + _local_authoritative_teleport_guard_position = avatar.global_position + _local_authoritative_teleport_minimum_ack = maxi(acknowledged_input, 0) + # Water recovery has already suppressed local movement, so this sends a + # neutral post-relocation input on the next server tick instead of waiting + # for the regular input cadence. + if avatar.is_water_recovery_active(): + submit_neutral_local_movement() func _expire_pending_authentication(now: float) -> void: @@ -2987,6 +3087,9 @@ func _teardown_peer() -> void: _local_space_transition_guard_active = false _local_space_transition_guard_position = Vector3.ZERO _local_space_transition_minimum_ack = 0 + _local_authoritative_teleport_guard_active = false + _local_authoritative_teleport_guard_position = Vector3.ZERO + _local_authoritative_teleport_minimum_ack = 0 _animation_refresh_accumulator = 0.0 _last_animation_state_by_peer.clear() _pending_animation_state_by_peer.clear() diff --git a/player/net_attachment.gd b/player/net_attachment.gd index 3c7843a..4ca210f 100644 --- a/player/net_attachment.gd +++ b/player/net_attachment.gd @@ -79,10 +79,10 @@ func get_impact_world_position() -> Vector3: func _sync_spring_activity() -> void: if _spring == null: return - var is_visible := _catching_net.is_visible_in_tree() - if is_visible and not _spring_was_visible: + var net_is_visible: bool = _catching_net.is_visible_in_tree() + if net_is_visible and not _spring_was_visible: _spring.active = true _spring.reset() - elif not is_visible: + elif not net_is_visible: _spring.active = false - _spring_was_visible = is_visible + _spring_was_visible = net_is_visible diff --git a/player/player.gd b/player/player.gd index 78dac3d..54bfa49 100644 --- a/player/player.gd +++ b/player/player.gd @@ -531,6 +531,7 @@ var _network_sprint: bool = false var _network_sneak: bool = false var _network_slow_walk: bool = false var _last_network_input_sequence: int = 0 +var _authoritative_relocation_input_quarantine: bool = false var _network_input_age: float = 0.0 var _network_input_stale: bool = false var _network_input_stale_timeout_seconds: float = ( @@ -1383,7 +1384,7 @@ func _update_character_animation() -> void: var is_sneaking_pose: bool = ( locomotion_state == LocomotionState.SNEAKING ) - var is_moving_horizontally: bool = ( + var is_presented_moving_horizontally: bool = ( _is_presented_moving_horizontally() ) var animation_action: Dictionary = _get_presented_animation_action() @@ -1500,7 +1501,7 @@ func _update_character_animation() -> void: &"idle_loop_sit", ] elif is_sneaking_pose: - if is_moving_horizontally: + if is_presented_moving_horizontally: requested_animation = [ CHARACTER_SNEAKING_ANIMATION, CHARACTER_WALKING_ANIMATION, @@ -1787,6 +1788,11 @@ func apply_network_sitting_state(should_sit: bool) -> void: func apply_authoritative_network_sitting_state(should_sit: bool) -> void: + # Reliable animation updates use a separate channel from movement input. + # Never let a delayed seated state re-arm motion while a recovery relocation + # is still waiting for its explicit post-relocation neutral input. + if _water_recovery_active or _authoritative_relocation_input_quarantine: + return if should_sit and ( not _can_begin_sitting() or _network_jump_pending ): @@ -2149,6 +2155,7 @@ func reset_network_movement_state() -> void: _network_sprint = false _network_sneak = false _network_slow_walk = false + _authoritative_relocation_input_quarantine = false _network_input_age = 0.0 _network_input_stale = false _network_input_stale_timeout_seconds = NETWORK_INPUT_STALE_TIMEOUT_SECONDS @@ -2161,6 +2168,53 @@ func reset_network_movement_state() -> void: _local_prediction_largest_error = 0.0 +func neutralize_authoritative_input_for_relocation( + recovery_input_sequence: int = -1, +) -> void: + # A host-side relocation must not inherit a held movement input from the + # previous position. The client stamps its reliable recovery request with + # the most recent input sequence it created; every lower sequence was made + # before that request, even if it arrives on the movement channel later. + # Keep that watermark in the acknowledgement and accept movement only after + # an explicit neutral packet beyond it. + var acknowledged_input: int = maxi( + _last_network_input_sequence, + maxi(recovery_input_sequence, 0), + ) + reset_network_movement_state() + _last_network_input_sequence = acknowledged_input + _authoritative_relocation_input_quarantine = true + _clear_authoritative_network_motion_intent() + velocity = Vector3.ZERO + + +func _clear_authoritative_network_motion_intent() -> void: + _network_axis = Vector2.ZERO + _network_jump_pending = false + _network_jump_intent_active = false + _network_sprint = false + _network_sneak = false + _network_slow_walk = false + _sit_after_landing = false + _set_sitting(false) + _apply_network_casting(false) + + +func _is_neutral_authoritative_network_input(data: Dictionary) -> bool: + var axis: Array = data.get("axis", []) + if axis.size() != 2: + return false + # Sprint/sneak/slow-walk modifiers alone cannot move the avatar. Letting a + # held modifier through preserves a responsive recovery while still requiring + # zero movement, jump, sitting, and fishing state at the relocation barrier. + return ( + Vector2(float(axis[0]), float(axis[1])).length_squared() <= 0.0025 + and not bool(data.get("jump", false)) + and not bool(data.get("sitting", false)) + and not bool(data.get("casting", false)) + ) + + func apply_network_space_transition( target_transform: Transform3D, align_local_camera: bool, @@ -2315,8 +2369,17 @@ func apply_authoritative_network_input( 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)) + if _water_recovery_active or _authoritative_relocation_input_quarantine: + var clears_relocation_quarantine: bool = ( + _authoritative_relocation_input_quarantine + and _is_neutral_authoritative_network_input(data) + ) + _clear_authoritative_network_motion_intent() + if clears_relocation_quarantine: + _authoritative_relocation_input_quarantine = false + return + _network_axis = Vector2(float(axis[0]), float(axis[1])).limit_length(1.0) var jump_intent_active: bool = bool(data.get("jump", false)) if jump_intent_active and not _network_jump_intent_active: _network_jump_pending = true @@ -2365,6 +2428,8 @@ func apply_network_animation_state(state: Dictionary) -> void: func apply_authoritative_network_animation_action( action_state: Dictionary, ) -> void: + if _water_recovery_active or _authoritative_relocation_input_quarantine: + return if not NetworkPlayerAnimationProtocol.validate_action_state(action_state): return var incoming_sequence: int = int(action_state["sequence"]) @@ -2438,6 +2503,13 @@ func apply_local_prediction_correction( var parsed: Dictionary = _parse_network_snapshot(snapshot) if parsed.is_empty(): return + # Water recovery owns the local transform until its reliable respawn + # teleport arrives. Ordinary movement audits may have been authored before + # the recovery presentation reached the host, so ignore every one of their + # fields while recovery is active. + if _water_recovery_active: + _reset_local_prediction_error() + return var acknowledged_input: int = parsed["acknowledged_input"] var sitting_intent_acknowledged: bool = ( _sitting_intent_pending @@ -2717,10 +2789,10 @@ static func resolve_local_prediction_transit_seconds( ) -func apply_network_teleport(snapshot: Dictionary) -> void: +func apply_network_teleport(snapshot: Dictionary) -> bool: var parsed: Dictionary = _parse_network_snapshot(snapshot) if parsed.is_empty(): - return + return false global_position = parsed["position"] velocity = parsed["velocity"] _visuals.rotation.y = parsed["visual_yaw"] @@ -2733,6 +2805,13 @@ func apply_network_teleport(snapshot: Dictionary) -> void: _apply_network_target_animation_state(parsed["animation_state"]) _network_snapshot_age = 0.0 _network_snapshot_ready = true + return true + + +func reset_local_prediction_after_authoritative_teleport() -> void: + _clear_local_network_jump_intent() + _reset_local_prediction_error() + _clear_local_reconciliation_offsets() func _parse_network_snapshot(snapshot: Dictionary) -> Dictionary: @@ -3070,16 +3149,18 @@ func is_movement_enabled() -> bool: return _movement_enabled -func set_local_input_suppressed(owner: StringName, suppressed: bool) -> void: - if owner.is_empty(): +func set_local_input_suppressed( + suppressor_id: StringName, suppressed: bool +) -> void: + if suppressor_id.is_empty(): return if suppressed: - _local_input_suppressors[owner] = true + _local_input_suppressors[suppressor_id] = true velocity.x = 0.0 velocity.z = 0.0 _set_camera_dragging(false) else: - _local_input_suppressors.erase(owner) + _local_input_suppressors.erase(suppressor_id) func _is_movement_input_enabled() -> bool: @@ -3101,16 +3182,16 @@ func set_camera_input_enabled(enabled: bool) -> void: func set_camera_input_suppressed( - owner: StringName, + suppressor_id: StringName, suppressed: bool, ) -> void: - if owner.is_empty(): + if suppressor_id.is_empty(): return if suppressed: - _camera_input_suppressors[owner] = true + _camera_input_suppressors[suppressor_id] = true _set_camera_dragging(false) else: - _camera_input_suppressors.erase(owner) + _camera_input_suppressors.erase(suppressor_id) func set_camera_active(active: bool) -> void: @@ -3198,6 +3279,8 @@ func is_camera_input_enabled() -> bool: func set_water_recovery_active(active: bool) -> void: _water_recovery_active = active + if active: + _clear_authoritative_network_motion_intent() velocity = Vector3.ZERO @@ -3374,8 +3457,8 @@ func set_active_item_is_rod( if _showcase_rod_state_stored: _showcase_rod_visibility = active_is_rod return - var visibility_changed: bool = _fishing_rod.visible != active_is_rod - if not animate_transition or not visibility_changed: + var rod_visibility_changed: bool = _fishing_rod.visible != active_is_rod + if not animate_transition or not rod_visibility_changed: _fishing_rod.visible = active_is_rod return if _has_held_show_item() or _showcase_animation_active: diff --git a/player/sprint_dust_trail.gd b/player/sprint_dust_trail.gd index 124b6ae..16422ad 100644 --- a/player/sprint_dust_trail.gd +++ b/player/sprint_dust_trail.gd @@ -156,24 +156,24 @@ func _initialize_pool() -> void: func _spawn_puff(source_position: Vector3, direction: Vector3) -> void: var lateral := Vector3(-direction.z, 0.0, direction.x) - var position := ( + var puff_position := ( source_position - direction * PUFF_BACK_OFFSET + lateral * PUFF_SIDE_OFFSET * _foot_side + Vector3.UP * PUFF_HEIGHT ) - _spawn_puff_at(position) + _spawn_puff_at(puff_position) _foot_side *= -1.0 func _spawn_puff_at( - position: Vector3, + puff_position: Vector3, size_multiplier: float = 1.0, ) -> void: var variation: float = _stable_variation(_spawn_serial) var second_variation: float = _stable_variation(_spawn_serial + 37) var index: int = _next_puff - _puff_positions[index] = position + _puff_positions[index] = puff_position _puff_ages[index] = 0.0 _puff_widths[index] = ( lerpf(0.34, 0.43, variation) * size_multiplier diff --git a/scripts/normalize_texture_imports.gd b/scripts/normalize_texture_imports.gd index e7b2406..edea5b1 100644 --- a/scripts/normalize_texture_imports.gd +++ b/scripts/normalize_texture_imports.gd @@ -56,8 +56,8 @@ func _initialize() -> void: quit(2) return - for root: String in roots: - var normalized_root := root + for root_path: String in roots: + var normalized_root := root_path if not normalized_root.ends_with("/"): normalized_root += "/" if not normalized_root.begins_with("res://") and not normalized_root.is_absolute_path(): diff --git a/settings/player_settings.gd b/settings/player_settings.gd index 59099fc..5734925 100644 --- a/settings/player_settings.gd +++ b/settings/player_settings.gd @@ -101,7 +101,7 @@ func copy() -> PlayerSettings: func normalize_implicit_presentation_layout() -> bool: if presentation_layout_customized: return false - var changed: bool = ( + var layout_changed: bool = ( chat_dock_right != DEFAULT_CHAT_DOCK_RIGHT or chat_mobile_mode != DEFAULT_CHAT_MOBILE_MODE or paint_dock_right != DEFAULT_PAINT_DOCK_RIGHT @@ -109,7 +109,7 @@ func normalize_implicit_presentation_layout() -> bool: chat_dock_right = DEFAULT_CHAT_DOCK_RIGHT chat_mobile_mode = DEFAULT_CHAT_MOBILE_MODE paint_dock_right = DEFAULT_PAINT_DOCK_RIGHT - return changed + return layout_changed static func _is_valid_audio_volume(value: float) -> bool: diff --git a/tests/android_readiness_validation.gd b/tests/android_readiness_validation.gd index 7472063..372051a 100644 --- a/tests/android_readiness_validation.gd +++ b/tests/android_readiness_validation.gd @@ -44,18 +44,20 @@ func _init() -> void: assert(_has_joypad_button(&"open_quick_actions", JOY_BUTTON_DPAD_DOWN)) assert(_has_joypad_button(&"open_chat", JOY_BUTTON_BACK)) assert(_has_joypad_button(&"focus_gameplay", JOY_BUTTON_LEFT_SHOULDER)) - assert( - GameUIType.VIRTUAL_MOUSE_TRIGGER_AXIS == JOY_AXIS_TRIGGER_RIGHT - ) - assert( + var virtual_mouse_trigger_axis: int = GameUIType.VIRTUAL_MOUSE_TRIGGER_AXIS + var virtual_mouse_shared_trigger_axis: int = ( GameUIType.VIRTUAL_MOUSE_SHARED_TRIGGER_AXIS - == JOY_AXIS_TRIGGER_LEFT ) - assert( + var virtual_mouse_secondary_click_axis: int = ( GameUIType.VIRTUAL_MOUSE_SECONDARY_CLICK_AXIS - == JOY_AXIS_TRIGGER_LEFT ) - assert(PlayerType.CONTROLLER_ZOOM_TRIGGER_AXIS == JOY_AXIS_TRIGGER_LEFT) + var controller_zoom_trigger_axis: int = ( + PlayerType.CONTROLLER_ZOOM_TRIGGER_AXIS + ) + assert(virtual_mouse_trigger_axis == JOY_AXIS_TRIGGER_RIGHT) + assert(virtual_mouse_shared_trigger_axis == JOY_AXIS_TRIGGER_LEFT) + assert(virtual_mouse_secondary_click_axis == JOY_AXIS_TRIGGER_LEFT) + assert(controller_zoom_trigger_axis == JOY_AXIS_TRIGGER_LEFT) assert(is_zero_approx( GameUIType.normalized_trigger_strength(-1.0, -1.0) )) diff --git a/tests/character_rig_validation.gd b/tests/character_rig_validation.gd index 2aece2b..95af5b7 100644 --- a/tests/character_rig_validation.gd +++ b/tests/character_rig_validation.gd @@ -112,7 +112,9 @@ func _validate_mesh_weights(mesh_instance: MeshInstance3D) -> int: var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] assert(not weights.is_empty()) assert(not vertices.is_empty()) - var components_per_vertex := weights.size() / vertices.size() + var components_per_vertex: int = int( + float(weights.size()) / float(vertices.size()) + ) assert(components_per_vertex == 4 or components_per_vertex == 8) for offset in range(0, weights.size(), components_per_vertex): var total := 0.0 diff --git a/tests/controller_mapping_validation.gd b/tests/controller_mapping_validation.gd index a5938d6..fcbf7e3 100644 --- a/tests/controller_mapping_validation.gd +++ b/tests/controller_mapping_validation.gd @@ -367,13 +367,13 @@ func _validate_auto_map(manager: ControllerMappingManagerType) -> String: if str(binding.get("kind", "")) == "button": var button := InputEventJoypadButton.new() button.device = manager.get_active_device_id() - button.button_index = int(binding.get("button", -1)) + button.button_index = int(binding.get("button", -1)) as JoyButton button.pressed = true manager.controller_input_observed.emit(button) else: var motion := InputEventJoypadMotion.new() motion.device = manager.get_active_device_id() - motion.axis = int(binding.get("axis", -1)) + motion.axis = int(binding.get("axis", -1)) as JoyAxis motion.axis_value = float(binding.get("direction", -1.0)) manager.controller_input_observed.emit(motion) if panel._auto_map_active: diff --git a/tests/controller_menu_accessibility_validation.gd b/tests/controller_menu_accessibility_validation.gd index 37e43d5..283290a 100644 --- a/tests/controller_menu_accessibility_validation.gd +++ b/tests/controller_menu_accessibility_validation.gd @@ -2015,7 +2015,7 @@ func _assert_neighbor( % [ origin.name, property, - actual.name if actual != null else "nothing", + str(actual.name) if actual != null else "nothing", expected.name, ], ) diff --git a/tests/dedicated_server_config_validation.gd b/tests/dedicated_server_config_validation.gd index 2125220..5cff1bb 100644 --- a/tests/dedicated_server_config_validation.gd +++ b/tests/dedicated_server_config_validation.gd @@ -21,10 +21,16 @@ func _run() -> void: assert(not bool(defaults.get("public_listing"))) assert(not bool(defaults.get("chat_logging"))) assert(str(defaults.get("chat_log_path")).is_empty()) - assert(NetworkChatService.BURST_COUNT == 3) - assert(NetworkChatService.WINDOW_COUNT == 5) - assert(is_equal_approx(NetworkChatService.WINDOW_SECONDS, 10.0)) - assert(NetworkChatService.CALL_COOLDOWN_MILLISECONDS == 90) + var chat_burst_count: int = NetworkChatService.BURST_COUNT + var chat_window_count: int = NetworkChatService.WINDOW_COUNT + var chat_window_seconds: float = NetworkChatService.WINDOW_SECONDS + var chat_call_cooldown_milliseconds: int = ( + NetworkChatService.CALL_COOLDOWN_MILLISECONDS + ) + assert(chat_burst_count == 3) + assert(chat_window_count == 5) + assert(is_equal_approx(chat_window_seconds, 10.0)) + assert(chat_call_cooldown_milliseconds == 90) var path: String = ProjectSettings.globalize_path( "user://dedicated-server-validation.cfg" @@ -156,14 +162,17 @@ func _run() -> void: str(discovery.call("_default_room_name", "River")) == "River's Server" ) - assert( + var upnp_renew_interval_seconds: float = ( DiscoveryClient.UPNP_RENEW_INTERVAL_SECONDS - < float(DiscoveryClient.UPNP_MAPPING_DURATION_SECONDS) ) - assert( + var upnp_mapping_duration_seconds: float = float( + DiscoveryClient.UPNP_MAPPING_DURATION_SECONDS + ) + var upnp_retry_interval_seconds: float = ( DiscoveryClient.UPNP_RETRY_INTERVAL_SECONDS - < DiscoveryClient.UPNP_RENEW_INTERVAL_SECONDS ) + assert(upnp_renew_interval_seconds < upnp_mapping_duration_seconds) + assert(upnp_retry_interval_seconds < upnp_renew_interval_seconds) var discovery_settings_path: String = ProjectSettings.globalize_path( DiscoveryClient.SETTINGS_PATH ) diff --git a/tests/economy_regression_validation.gd b/tests/economy_regression_validation.gd index 0728d82..1bb6409 100644 --- a/tests/economy_regression_validation.gd +++ b/tests/economy_regression_validation.gd @@ -76,7 +76,7 @@ func _run() -> void: assert(session.set_host_open(false)) await _test_host_shop_purchase(main, player, shop_service) - await _test_host_rod_purchase(player, shop_service) + _test_host_rod_purchase(player, shop_service) await _test_host_art_shop_purchase(main, player, shop_service) await _test_fishing_shop_sale_ui( main, player, catalog, sale_service, reservations diff --git a/tests/file_dialog_controller_navigation_validation.gd b/tests/file_dialog_controller_navigation_validation.gd index 7ac8df9..03b4e6a 100644 --- a/tests/file_dialog_controller_navigation_validation.gd +++ b/tests/file_dialog_controller_navigation_validation.gd @@ -34,20 +34,20 @@ func _run() -> void: var root_controls: Array[Control] = ( FileDialogControllerNavigationType.interactive_controls(root_scope) ) - var directory_list: ItemList - var parent_button: Button - var create_folder_button: Button - var path_edit: LineEdit - var drive_button: MenuButton - var refresh_button: Button - var favorite_button: Button - var hidden_button: Button - var grid_button: Button - var list_button: Button - var filter_button: Button - var sort_button: MenuButton - var cancel_button: Button - var select_button: Button + var directory_list: ItemList = null + var parent_button: Button = null + var create_folder_button: Button = null + var path_edit: LineEdit = null + var drive_button: MenuButton = null + var refresh_button: Button = null + var favorite_button: Button = null + var hidden_button: Button = null + var grid_button: Button = null + var list_button: Button = null + var filter_button: Button = null + var sort_button: MenuButton = null + var cancel_button: Button = null + var select_button: Button = null for control: Control in root_controls: if ( control is ItemList diff --git a/tests/fish_hotbar_showcase_validation.gd b/tests/fish_hotbar_showcase_validation.gd index af3644b..77f6ed6 100644 --- a/tests/fish_hotbar_showcase_validation.gd +++ b/tests/fish_hotbar_showcase_validation.gd @@ -200,15 +200,17 @@ func _run() -> void: var invalid_state: Dictionary = valid_state.duplicate(true) invalid_state["display_scale"] = 1000.0 assert(not NetworkFishShowcaseProtocol.validate_state(invalid_state)) - assert(NetworkProtocol.PROTOCOL_VERSION == 12) - assert(NetworkProtocol.ENET_CHANNEL_COUNT == 18) + var protocol_version: int = NetworkProtocol.PROTOCOL_VERSION + var enet_channel_count: int = NetworkProtocol.ENET_CHANNEL_COUNT + var fish_quality_capability: String = NetworkProtocol.FISH_QUALITY_CAPABILITY + var showcase_capability: StringName = NetworkFishShowcaseProtocol.CAPABILITY + assert(protocol_version == 12) + assert(enet_channel_count == 18) assert( - NetworkProtocol.FISH_QUALITY_CAPABILITY - == "fish_quality_v1" + fish_quality_capability == "fish_quality_v1" ) assert( - NetworkFishShowcaseProtocol.CAPABILITY - == &"fish_showcase_v1" + showcase_capability == &"fish_showcase_v1" ) print("Fish hotbar showcase validation: PASS") diff --git a/tests/fish_quality_validation.gd b/tests/fish_quality_validation.gd index 99a2169..4be1ffc 100644 --- a/tests/fish_quality_validation.gd +++ b/tests/fish_quality_validation.gd @@ -26,18 +26,28 @@ func _run() -> void: _validate_collection_mastery() _validate_version_four_migration() _validate_fishing_protocol_v2() - assert(NetworkProtocol.PROTOCOL_VERSION == 12) - assert(NetworkProtocol.ENET_CHANNEL_COUNT == 18) - assert(NetworkProtocol.MOVEMENT_ANIMATION_CHANNEL == 11) - assert( + var protocol_version: int = NetworkProtocol.PROTOCOL_VERSION + var enet_channel_count: int = NetworkProtocol.ENET_CHANNEL_COUNT + var movement_animation_channel: int = ( + NetworkProtocol.MOVEMENT_ANIMATION_CHANNEL + ) + var movement_reconciliation_capability: String = ( NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY - == "movement_reconciliation_v2" + ) + var fishing_replication_capability: String = ( + NetworkProtocol.FISHING_REPLICATION_CAPABILITY + ) + var fish_quality_capability: String = NetworkProtocol.FISH_QUALITY_CAPABILITY + assert(protocol_version == 12) + assert(enet_channel_count == 18) + assert(movement_animation_channel == 11) + assert( + movement_reconciliation_capability == "movement_reconciliation_v2" ) assert( - NetworkProtocol.FISHING_REPLICATION_CAPABILITY - == "fishing_replication_v2" + fishing_replication_capability == "fishing_replication_v2" ) - assert(NetworkProtocol.FISH_QUALITY_CAPABILITY == "fish_quality_v1") + assert(fish_quality_capability == "fish_quality_v1") print("Fish quality validation: PASS") quit() @@ -71,11 +81,17 @@ func _validate_fishing_protocol_v2() -> void: NetworkFishingProtocol.validate_cast_request(invalid_sequence) == "Fishing request values are outside allowed limits." ) - assert(NetworkFishingProtocol.OBSERVER_SNAPSHOT_CHANNEL == 10) - assert(NetworkWorldSpawnProtocol.SNAPSHOT_CHANNEL == 15) - assert( + var observer_snapshot_channel: int = ( + NetworkFishingProtocol.OBSERVER_SNAPSHOT_CHANNEL + ) + var world_spawn_snapshot_channel: int = ( NetworkWorldSpawnProtocol.SNAPSHOT_CHANNEL - != NetworkFishingProtocol.SNAPSHOT_CHANNEL + ) + var fishing_snapshot_channel: int = NetworkFishingProtocol.SNAPSHOT_CHANNEL + assert(observer_snapshot_channel == 10) + assert(world_spawn_snapshot_channel == 15) + assert( + world_spawn_snapshot_channel != fishing_snapshot_channel ) @@ -240,10 +256,14 @@ func _validate_barrier_challenge_curve() -> void: func _validate_fight_pacing_and_reel_upgrades() -> void: - assert(is_equal_approx(CatchController.CHASE_SPEED, 0.07)) - assert(is_equal_approx(CatchController.CHASE_START_DELAY, 1.5)) - assert(is_equal_approx(CatchController.CHASE_START_OFFSET, 0.04)) - assert(is_equal_approx(Player.BASE_REEL_SPEED, 0.16)) + var chase_speed: float = CatchController.CHASE_SPEED + var chase_start_delay: float = CatchController.CHASE_START_DELAY + var chase_start_offset: float = CatchController.CHASE_START_OFFSET + var base_reel_speed: float = Player.BASE_REEL_SPEED + assert(is_equal_approx(chase_speed, 0.07)) + assert(is_equal_approx(chase_start_delay, 1.5)) + assert(is_equal_approx(chase_start_offset, 0.04)) + assert(is_equal_approx(base_reel_speed, 0.16)) var upgrades := PlayerFishingUpgrades.new() assert(is_equal_approx(upgrades.get_reel_speed_multiplier(), 1.0)) diff --git a/tests/fishing_authority_validation.gd b/tests/fishing_authority_validation.gd index b3c17dd..ec3320b 100644 --- a/tests/fishing_authority_validation.gd +++ b/tests/fishing_authority_validation.gd @@ -277,6 +277,28 @@ func _run() -> void: water_recovery.call("_finish_recovery") assert(player.is_movement_enabled()) + # Session/world teardown can interrupt the fade before its normal completion. + # Recovery must still release FishingSpot's external input lock without + # restoring movement that gameplay shutdown already disabled. + water_recovery.call( + "_on_recovery_requested", + player, + player.global_position.y, + ) + assert(water_recovery.is_recovery_active()) + var screen_fade := game_ui.get_screen_fade() as ScreenFade + assert(screen_fade != null) + screen_fade.fade_to_black(int(water_recovery.get("_generation"))) + water_recovery.set_recovery_enabled(false) + assert(not water_recovery.is_recovery_active()) + assert(not player.is_water_recovery_active()) + assert(not bool(fishing_spot.get("_external_input_blocked"))) + assert(not player.is_movement_enabled()) + assert(not screen_fade.visible) + water_recovery.set_recovery_enabled(true) + player.set_movement_enabled(true) + player.set_camera_input_enabled(true) + print("Fishing authority validation: PASS") session.disconnect_session("") main.queue_free() diff --git a/tests/fishing_multiplayer_validation.gd b/tests/fishing_multiplayer_validation.gd index 9d7ef7f..667d87e 100644 --- a/tests/fishing_multiplayer_validation.gd +++ b/tests/fishing_multiplayer_validation.gd @@ -271,7 +271,6 @@ func _run_client() -> void: assert(player.global_position.distance_to(exterior_spawn.origin) > 1.0) for _frame: int in 2: await physics_frame - var expected_position: Vector3 = player.global_position var expected_direction: Vector3 = player.get_facing_direction() player.set_casting_visual() diff --git a/tests/gathering_marker_surface_validation.gd b/tests/gathering_marker_surface_validation.gd index 72f15f0..4ec5429 100644 --- a/tests/gathering_marker_surface_validation.gd +++ b/tests/gathering_marker_surface_validation.gd @@ -59,11 +59,11 @@ func _run() -> void: wall.queue_free() await process_frame - var floor := _make_surface( + var floor_surface := _make_surface( Vector3(10.0, 0.2, 10.0), Vector3(0.0, -0.1, -1.7), ) - root.add_child(floor) + root.add_child(floor_surface) await physics_frame controller.call("_update_marker_target") assert(bool(controller.get("_marker_has_surface"))) @@ -80,7 +80,7 @@ func _run() -> void: ) ) - floor.queue_free() + floor_surface.queue_free() controller.queue_free() player.queue_free() await process_frame diff --git a/tests/generated_world_runtime_validation.gd b/tests/generated_world_runtime_validation.gd index 076f1c9..ac2f61a 100644 --- a/tests/generated_world_runtime_validation.gd +++ b/tests/generated_world_runtime_validation.gd @@ -18,6 +18,12 @@ const Gatherables: GatherableCatalog = preload( const PrecipitationOcclusionType = preload( "res://world/environment/precipitation_occlusion.gd" ) +const FOLIAGE_WIND_SHADER: Shader = preload( + "res://world/materials/foliage_wind.gdshader" +) +const FOLIAGE_WIND_SOURCE_MATERIALS_META: StringName = ( + &"foliage_wind_source_materials" +) const FIRST_SEED := 13001 const SECOND_SEED := 13002 const RIVER_FALLBACK_SEED := 13012 @@ -130,8 +136,8 @@ func _validate_generated_region( var placements := generator.placement_keys() var expected_chunk_count := generator.grid_size.x * generator.grid_size.y var center := Vector2i( - generator.grid_size.x / 2, - generator.grid_size.y / 2, + int(float(generator.grid_size.x) / 2.0), + int(float(generator.grid_size.y) / 2.0), ) var center_index := center.y * generator.grid_size.x + center.x assert(generator.grid_size == Vector2i(20, 20)) @@ -341,7 +347,7 @@ func _validate_generated_region( ): var outlet_coordinate := Vector2i( index % generator.grid_size.x, - index / generator.grid_size.x, + int(float(index) / float(generator.grid_size.x)), ) outlet_coordinates.append(outlet_coordinate) assert( @@ -366,7 +372,7 @@ func _validate_generated_region( for index: int in placements.size(): var coordinate := Vector2i( index % generator.grid_size.x, - index / generator.grid_size.x, + int(float(index) / float(generator.grid_size.x)), ) if placements[index].begins_with("chunk_0001@"): assert(generator._distance_from_map_boundary(coordinate) <= 1) @@ -587,8 +593,8 @@ func _validate_generated_region( Vector2i.ZERO, ) var spawn_coordinate := Vector2i( - generator.grid_size.x / 2, - generator.grid_size.y / 2, + int(float(generator.grid_size.x) / 2.0), + int(float(generator.grid_size.y) / 2.0), ) var spawn_distance := ( absi(coordinate.x - spawn_coordinate.x) @@ -1146,8 +1152,8 @@ func _validate_biome_catalog( for biome_id: StringName in EXPECTED_BIOME_IDS: assert(counts.get(biome_id, 0) > 0) var center := Vector2i( - generator.grid_size.x / 2, - generator.grid_size.y / 2, + int(float(generator.grid_size.x) / 2.0), + int(float(generator.grid_size.y) / 2.0), ) assert(region.get_biome_at(center) == &"biome_plains") for child: Node in generator.get_generated_chunks_root().get_children(): @@ -1504,12 +1510,27 @@ func _has_material_variant_override( return false var mesh_instance := root_node as MeshInstance3D if mesh_instance != null and mesh_instance.mesh != null: + var source_materials: Dictionary = mesh_instance.get_meta( + FOLIAGE_WIND_SOURCE_MATERIALS_META, + {}, + ) for surface_index: int in mesh_instance.mesh.get_surface_count(): var override := mesh_instance.get_surface_override_material( surface_index ) if override != null and override in variants: return true + var wind_material := override as ShaderMaterial + if ( + wind_material != null + and wind_material.shader == FOLIAGE_WIND_SHADER + ): + var source_material: Material = source_materials.get( + surface_index, + null, + ) as Material + if source_material != null and source_material in variants: + return true for child: Node in root_node.get_children(): if _has_material_variant_override(child, variants): return true @@ -1647,7 +1668,7 @@ func _material_names(mesh_instance: MeshInstance3D) -> PackedStringArray: return result -func _validate_projected_terrain_materials(root: Node) -> void: +func _validate_projected_terrain_materials(projected_root: Node) -> void: var expected_sizes := { "grass_lite": 1.75, "sand": 2.6, @@ -1658,17 +1679,21 @@ func _validate_projected_terrain_materials(root: Node) -> void: "sand": 0, "dirt": 0, } - _validate_projected_material_node(root, expected_sizes, surface_counts) + _validate_projected_material_node( + projected_root, + expected_sizes, + surface_counts, + ) for material_name: String in expected_sizes: assert(surface_counts[material_name] > 0) func _validate_projected_material_node( - root: Node, + node: Node, expected_sizes: Dictionary, surface_counts: Dictionary, ) -> void: - var mesh_instance := root as MeshInstance3D + var mesh_instance := node as MeshInstance3D if mesh_instance != null and mesh_instance.mesh != null: for surface_index: int in mesh_instance.mesh.get_surface_count(): var material := mesh_instance.get_active_material(surface_index) @@ -1689,7 +1714,7 @@ func _validate_projected_material_node( ) ) surface_counts[material.resource_name] += 1 - for child: Node in root.get_children(): + for child: Node in node.get_children(): _validate_projected_material_node( child, expected_sizes, @@ -1708,8 +1733,10 @@ func _validate_pond_collision( var vertices := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array var normals := arrays[Mesh.ARRAY_NORMAL] as PackedVector3Array var indices := arrays[Mesh.ARRAY_INDEX] as PackedInt32Array - var triangle_count := ( - indices.size() / 3 if not indices.is_empty() else vertices.size() / 3 + var triangle_count: int = ( + int(float(indices.size()) / 3.0) + if not indices.is_empty() + else int(float(vertices.size()) / 3.0) ) for triangle_index: int in triangle_count: var offset := triangle_index * 3 diff --git a/tests/inventory_notepad_art_validation.gd b/tests/inventory_notepad_art_validation.gd index d871b49..505ade6 100644 --- a/tests/inventory_notepad_art_validation.gd +++ b/tests/inventory_notepad_art_validation.gd @@ -678,7 +678,7 @@ func _capture_inventory_pages(player_menu: PlayerMenu) -> void: player_menu.call("_show_section_immediate", sections[index]) await process_frame await process_frame - await _save_capture(suffixes[index]) + _save_capture(suffixes[index]) player_menu.call("_show_section_immediate", PlayerMenu.Section.BAG) var sale_confirmation := player_menu.get_node("%SaleConfirmation") as Control var confirmation_message := player_menu.get_node( @@ -693,7 +693,7 @@ func _capture_inventory_pages(player_menu: PlayerMenu) -> void: sale_confirmation.visible = true await process_frame await process_frame - await _save_capture("sale-confirmation") + _save_capture("sale-confirmation") sale_confirmation.visible = false diff --git a/tests/keyboard_mouse_mapping_validation.gd b/tests/keyboard_mouse_mapping_validation.gd index 1b95e01..112c7e9 100644 --- a/tests/keyboard_mouse_mapping_validation.gd +++ b/tests/keyboard_mouse_mapping_validation.gd @@ -38,16 +38,18 @@ func _run() -> void: KeyboardMouseMappingManagerType.ROLE_INTERACT ) == "e" ) - assert( + var zoom_in_label: String = str( KeyboardMouseMappingManagerType.ROLE_LABELS[ KeyboardMouseMappingManagerType.ROLE_CAMERA_ZOOM_IN - ] == "hotbar select (+SHIFT to zoom)" + ] ) - assert( + var zoom_out_label: String = str( KeyboardMouseMappingManagerType.ROLE_LABELS[ KeyboardMouseMappingManagerType.ROLE_CAMERA_ZOOM_OUT - ] == "hotbar select (+SHIFT to zoom)" + ] ) + assert(zoom_in_label == "hotbar select (+SHIFT to zoom)") + assert(zoom_out_label == "hotbar select (+SHIFT to zoom)") assert( str(defaults[ str(KeyboardMouseMappingManagerType.ROLE_PRIMARY_ACTION) diff --git a/tests/late_join_multiplayer_validation.gd b/tests/late_join_multiplayer_validation.gd index 6ea544f..aaaa343 100644 --- a/tests/late_join_multiplayer_validation.gd +++ b/tests/late_join_multiplayer_validation.gd @@ -293,25 +293,25 @@ func _wait_for_avatar_sitting( var deadline: int = Time.get_ticks_msec() + 10000 while Time.get_ticks_msec() < deadline: await process_frame - var avatar: Player = spawn_service.get_avatar(peer_id) - if avatar != null and avatar.is_sitting() == expected: + var observed_avatar: Player = spawn_service.get_avatar(peer_id) + if observed_avatar != null and observed_avatar.is_sitting() == expected: return true - var avatar: Player = spawn_service.get_avatar(peer_id) - if avatar != null: + var timed_out_avatar: Player = spawn_service.get_avatar(peer_id) + if timed_out_avatar != null: print( "sitting wait timed out: ", { "peer_id": peer_id, "expected": expected, - "sitting": avatar.is_sitting(), - "sit_after_landing": bool(avatar.get("_sit_after_landing")), - "on_floor": avatar.is_on_floor(), - "position": avatar.global_position, - "velocity": avatar.velocity, - "authoritative": bool(avatar.get( + "sitting": timed_out_avatar.is_sitting(), + "sit_after_landing": bool(timed_out_avatar.get("_sit_after_landing")), + "on_floor": timed_out_avatar.is_on_floor(), + "position": timed_out_avatar.global_position, + "velocity": timed_out_avatar.velocity, + "authoritative": bool(timed_out_avatar.get( "_network_authoritative_simulation" )), - "last_input": int(avatar.get( + "last_input": int(timed_out_avatar.get( "_last_network_input_sequence" )), }, diff --git a/tests/light_performance_profile_validation.gd b/tests/light_performance_profile_validation.gd index 6a654f1..5cb8df3 100644 --- a/tests/light_performance_profile_validation.gd +++ b/tests/light_performance_profile_validation.gd @@ -217,11 +217,11 @@ func _validate_new_game_music_transition(main: Node) -> void: func _first_fresh_water_visual(main: Node) -> MeshInstance3D: - var root := main.get_node( + var fresh_water_root := main.get_node( "TestWorld/Regions/GeneratedWorldRegion/WaterBodies/FreshWaterBodies" ) as Node3D - assert(root != null and root.get_child_count() > 0) - return root.get_child(0).get_node("VisualWater") as MeshInstance3D + assert(fresh_water_root != null and fresh_water_root.get_child_count() > 0) + return fresh_water_root.get_child(0).get_node("VisualWater") as MeshInstance3D func _validate_ocean_fishing_coverage( diff --git a/tests/logbook_validation.gd b/tests/logbook_validation.gd index 67747a5..941fbe6 100644 --- a/tests/logbook_validation.gd +++ b/tests/logbook_validation.gd @@ -545,7 +545,7 @@ func _validate_page() -> void: == "flathead\ncatfish" ) - var shared_material: Material + var shared_material: Material = null var silhouette_count: int = 0 var target_silhouette_area: float = ( LogbookPage.CATALOG_PORTRAIT_SIZE.x @@ -558,9 +558,9 @@ func _validate_page() -> void: ]: page.call("_select_category", category) await create_timer(0.25).timeout - var entries: Dictionary = page.get("_entry_buttons") + var category_entries: Dictionary = page.get("_entry_buttons") assert( - entries.size() + category_entries.size() == (108 if category == LogbookCatalog.Category.FRESH_WATER else 202) ) for fish: FishDataType in LogbookCatalog.ordered_species( @@ -571,7 +571,7 @@ func _validate_page() -> void: var unknown_key := StringName( "unknown_%d" % LogbookCatalog.catalog_number(fish) ) - var entry := entries.get(unknown_key) as Button + var entry := category_entries.get(unknown_key) as Button assert(entry != null) assert(entry.text.is_empty()) assert(entry.tooltip_text.is_empty()) diff --git a/tests/movement_multiplayer_validation.gd b/tests/movement_multiplayer_validation.gd index 05a668d..a59f703 100644 --- a/tests/movement_multiplayer_validation.gd +++ b/tests/movement_multiplayer_validation.gd @@ -34,6 +34,7 @@ func _validate_latency_smoothing() -> void: avatar.set_physics_process(false) _validate_compact_input_encoding() _validate_compact_snapshot_encoding() + _validate_authoritative_teleport_snapshot_guard() _validate_compact_animation_encoding() _validate_animation_action_ordering(avatar) _validate_transit_estimation() @@ -72,8 +73,12 @@ func _validate_compact_snapshot_encoding() -> void: Vector3(4.5, 0.0, 0.0), 1, ) - assert(NetworkSession.MOVEMENT_SNAPSHOT_BATCH_SIZE == 8) - assert(NetworkSession.OWNER_SNAPSHOT_DIVISOR == 3) + var movement_snapshot_batch_size: int = ( + NetworkSession.MOVEMENT_SNAPSHOT_BATCH_SIZE + ) + var owner_snapshot_divisor: int = NetworkSession.OWNER_SNAPSHOT_DIVISOR + assert(movement_snapshot_batch_size == 8) + assert(owner_snapshot_divisor == 3) var encoded_snapshots: Array = [] for _peer: int in NetworkSession.MOVEMENT_SNAPSHOT_BATCH_SIZE: encoded_snapshots.append( @@ -100,6 +105,50 @@ func _validate_compact_snapshot_encoding() -> void: ) +func _validate_authoritative_teleport_snapshot_guard() -> void: + var session := NetworkSession.new() + var destination := Vector3(24.0, 0.0, -8.0) + session.set("_local_authoritative_teleport_guard_active", true) + session.set("_local_authoritative_teleport_guard_position", destination) + session.set("_local_authoritative_teleport_minimum_ack", 48) + + # A channel-two audit from before the reliable relocation must not move the + # local player back to its old position. + assert(bool(session.call( + "_reject_stale_authoritative_teleport_snapshot", + _network_snapshot(Vector3.ZERO, Vector3.ZERO, 48), + ))) + assert(bool(session.get("_local_authoritative_teleport_guard_active"))) + assert(bool(session.call( + "_reject_stale_authoritative_teleport_snapshot", + _network_snapshot(destination, Vector3.ZERO, 47), + ))) + + # A matching audit from the new location clears the barrier. A higher + # acknowledgement at the old position is not sufficient: movement input and + # the reliable teleport travelled on different channels, so it can still be + # a held packet from before recovery. + assert(not bool(session.call( + "_reject_stale_authoritative_teleport_snapshot", + _network_snapshot(destination + Vector3.RIGHT, Vector3.ZERO, 48), + ))) + assert(not bool(session.get("_local_authoritative_teleport_guard_active"))) + session.set("_local_authoritative_teleport_guard_active", true) + session.set("_local_authoritative_teleport_guard_position", destination) + session.set("_local_authoritative_teleport_minimum_ack", 48) + assert(bool(session.call( + "_reject_stale_authoritative_teleport_snapshot", + _network_snapshot(Vector3.ZERO, Vector3.ZERO, 49), + ))) + assert(bool(session.get("_local_authoritative_teleport_guard_active"))) + assert(not bool(session.call( + "_reject_stale_authoritative_teleport_snapshot", + _network_snapshot(destination, Vector3.ZERO, 49), + ))) + assert(not bool(session.get("_local_authoritative_teleport_guard_active"))) + session.free() + + func _validate_compact_animation_encoding() -> void: var paused_state := NetworkPlayerAnimationProtocol.make_state( NetworkPlayerAnimationProtocol.LOCOMOTION_IDLE, @@ -315,16 +364,16 @@ func _validate_remote_snapshot_smoothing(avatar: Player) -> void: # Reconciliation only applies after both simulations report a grounded body. # Give the unit avatar a real floor so this exercises the production path. - var floor := StaticBody3D.new() - floor.collision_layer = 1 - floor.collision_mask = 0 - floor.position = Vector3(0.0, -0.1, 0.0) + var floor_body := StaticBody3D.new() + floor_body.collision_layer = 1 + floor_body.collision_mask = 0 + floor_body.position = Vector3(0.0, -0.1, 0.0) var floor_shape := CollisionShape3D.new() var floor_box := BoxShape3D.new() floor_box.size = Vector3(100.0, 0.2, 100.0) floor_shape.shape = floor_box - floor.add_child(floor_shape) - root.add_child(floor) + floor_body.add_child(floor_shape) + root.add_child(floor_body) await physics_frame avatar.set_local_control(true) avatar.global_position = Vector3(0.8, 0.0, 0.0) @@ -434,6 +483,24 @@ func _validate_remote_snapshot_smoothing(avatar: Player) -> void: int(avatar.get("_local_prediction_hard_corrections")) == 1 ) + # Recovery owns all local state until its reliable teleport arrives. A + # delayed audit must neither pull the body back nor acknowledge an old jump. + avatar.global_position = Vector3(10.0, 0.0, 0.0) + avatar.call("_queue_local_network_jump_intent") + avatar.capture_network_input(20) + avatar.set_water_recovery_active(true) + avatar.apply_local_prediction_correction( + drift_snapshot, + 20, + 1.0 / 30.0, + 0.0, + 0.1, + ) + assert(avatar.global_position == Vector3(10.0, 0.0, 0.0)) + assert(bool(avatar.get("_local_network_jump_intent_pending"))) + avatar.set_water_recovery_active(false) + avatar.call("_clear_local_network_jump_intent") + # Soft reconciliation must respect the same terrain collision as ordinary # player movement instead of phasing the capsule through a solid obstacle. var blocker := StaticBody3D.new() @@ -470,7 +537,7 @@ func _validate_remote_snapshot_smoothing(avatar: Player) -> void: == blocked_camera_position ) blocker.queue_free() - floor.queue_free() + floor_body.queue_free() await physics_frame @@ -567,6 +634,40 @@ func _validate_stale_input_expiry(avatar: Player) -> void: assert(not bool(avatar.get("_network_input_stale"))) assert((avatar.get("_network_axis") as Vector2).length_squared() > 0.0) + # The reliable recovery request carries the last client-created sequence. + # A host uses it as a hard watermark, so every delayed input from before the + # request is discarded even if it arrives after the teleport RPC. + avatar.neutralize_authoritative_input_for_relocation(44) + assert(int(avatar.get("_last_network_input_sequence")) == 44) + assert((avatar.get("_network_axis") as Vector2) == Vector2.ZERO) + assert(not bool(avatar.get("_network_sprint"))) + assert(bool(avatar.get("_authoritative_relocation_input_quarantine"))) + avatar.apply_authoritative_network_input(_movement_input(41, true)) + assert((avatar.get("_network_axis") as Vector2) == Vector2.ZERO) + avatar.apply_authoritative_network_input(_movement_input(42, false, false)) + assert(int(avatar.get("_last_network_input_sequence")) == 44) + + # A non-neutral post-watermark packet cannot restart motion. The first + # neutral packet past the watermark releases the barrier; the following + # input is the first one allowed to move the recovered avatar. + avatar.apply_authoritative_network_input(_movement_input(45, false, true)) + assert((avatar.get("_network_axis") as Vector2) == Vector2.ZERO) + assert(bool(avatar.get("_authoritative_relocation_input_quarantine"))) + avatar.apply_authoritative_network_input(_movement_input(46, false, false)) + assert(not bool(avatar.get("_authoritative_relocation_input_quarantine"))) + avatar.apply_authoritative_network_input(_movement_input(47, false, true)) + assert((avatar.get("_network_axis") as Vector2).length_squared() > 0.0) + + # Recovery itself also consumes incoming movement rather than remembering a + # held axis which could resume on the exact frame recovery presentation ends. + avatar.set_water_recovery_active(true) + avatar.apply_authoritative_network_input(_movement_input(48, false, true)) + assert((avatar.get("_network_axis") as Vector2) == Vector2.ZERO) + avatar.set_water_recovery_active(false) + avatar.apply_authoritative_network_input(_movement_input(42, false, false)) + avatar.apply_authoritative_network_input(_movement_input(49, false, true)) + assert((avatar.get("_network_axis") as Vector2).length_squared() > 0.0) + func _network_snapshot( position: Vector3, diff --git a/tests/on_screen_keyboard_validation.gd b/tests/on_screen_keyboard_validation.gd index a685c65..e5b05d6 100644 --- a/tests/on_screen_keyboard_validation.gd +++ b/tests/on_screen_keyboard_validation.gd @@ -307,13 +307,13 @@ func _validate_native_keyboard() -> void: await process_frame edit.grab_focus() assert(keyboard.is_open()) - var focus_exit_count: int = 0 + var focus_exit_state: Dictionary = {"count": 0} edit.focus_exited.connect(func() -> void: - focus_exit_count += 1 + focus_exit_state["count"] = int(focus_exit_state["count"]) + 1 ) assert(keyboard.request_for_control(edit)) assert(edit.has_focus()) - assert(focus_exit_count == 0) + assert(int(focus_exit_state["count"]) == 0) assert(not edit.virtual_keyboard_enabled) assert(not edit.virtual_keyboard_show_on_focus) assert(keyboard.shown_controls == [edit]) diff --git a/tests/shoreline_ribbon_validation.gd b/tests/shoreline_ribbon_validation.gd index e78acb0..e502d70 100644 --- a/tests/shoreline_ribbon_validation.gd +++ b/tests/shoreline_ribbon_validation.gd @@ -19,7 +19,7 @@ func _run() -> void: assert(not vertices.is_empty()) assert(vertices.size() % 2 == 0) assert(indices.size() == vertices.size() * 3) - var point_count := vertices.size() / 2 + var point_count: int = int(float(vertices.size()) / 2.0) var land_edge := PackedVector2Array() var water_edge := PackedVector2Array() for index: int in point_count: diff --git a/tests/surface_drawing_validation.gd b/tests/surface_drawing_validation.gd index ac48a67..50ec1ba 100644 --- a/tests/surface_drawing_validation.gd +++ b/tests/surface_drawing_validation.gd @@ -40,13 +40,16 @@ func _validate_palette() -> void: func _validate_protocol_bounds() -> void: - assert(SurfaceDrawingProtocol.GRID_WIDTH == 16) - assert(SurfaceDrawingProtocol.GRID_HEIGHT == 16) - assert(SurfaceDrawingProtocol.GRID_SIZES == [16, 32, 64, 128]) - assert(is_equal_approx(SurfaceDrawingProtocol.CELL_SIZE, 0.075)) + var grid_width: int = SurfaceDrawingProtocol.GRID_WIDTH + var grid_height: int = SurfaceDrawingProtocol.GRID_HEIGHT + var grid_sizes: Variant = SurfaceDrawingProtocol.GRID_SIZES + var cell_size: float = SurfaceDrawingProtocol.CELL_SIZE + assert(grid_width == 16) + assert(grid_height == 16) + assert(grid_sizes == [16, 32, 64, 128]) + assert(is_equal_approx(cell_size, 0.075)) assert(is_equal_approx( - float(SurfaceDrawingProtocol.GRID_WIDTH) - * SurfaceDrawingProtocol.CELL_SIZE, + float(grid_width) * cell_size, 1.2, )) var canvas_request: Dictionary = { @@ -157,14 +160,14 @@ func _validate_grid_snapping() -> void: "creator_fingerprint": FINGERPRINT_A, "cells": [], } - var snapped: Dictionary = SurfaceDrawingPlacement.resolve( + var snap_result: Dictionary = SurfaceDrawingPlacement.resolve( Vector3(1.08, 0.02, 0.08), Vector3.UP, Vector3.RIGHT, [anchor], ) - assert(bool(snapped["snapped"])) - var snapped_origin: Vector3 = snapped["origin"] + assert(bool(snap_result["snapped"])) + var snapped_origin: Vector3 = snap_result["origin"] assert(snapped_origin.is_equal_approx(Vector3(1.2, 0.0, 0.0))) var unsnapped: Dictionary = SurfaceDrawingPlacement.resolve( Vector3(0.6, 0.0, 0.0), diff --git a/tests/terrain_chunk_generator_validation.gd b/tests/terrain_chunk_generator_validation.gd index ec736d9..0576d81 100644 --- a/tests/terrain_chunk_generator_validation.gd +++ b/tests/terrain_chunk_generator_validation.gd @@ -1878,7 +1878,7 @@ func _all_neighbor_edges_match(generator: TerrainChunkGenerator) -> bool: for index: int in generator._placements.size(): var coordinate := Vector2i( index % generator.grid_size.x, - index / generator.grid_size.x, + int(float(index) / float(generator.grid_size.x)), ) var current: TerrainChunkVariant = generator._placements[index] if coordinate.x > 0: @@ -1918,8 +1918,8 @@ func _validate_layout_rules(generator: TerrainChunkGenerator) -> void: "All walkable generated terrain must remain connected to spawn.", ) var center := Vector2i( - generator.grid_size.x / 2, - generator.grid_size.y / 2, + int(float(generator.grid_size.x) / 2.0), + int(float(generator.grid_size.y) / 2.0), ) var safe_spawn_neighbors := 0 for edge_value: int in TerrainChunkTopology.Edge.values(): @@ -1943,7 +1943,7 @@ func _validate_layout_rules(generator: TerrainChunkGenerator) -> void: var placement := generator._placements[index] var coordinate := Vector2i( index % generator.grid_size.x, - index / generator.grid_size.x, + int(float(index) / float(generator.grid_size.x)), ) if "coast" in placement.definition.tags: _check( diff --git a/tests/unified_inventory_validation.gd b/tests/unified_inventory_validation.gd index 6928969..a313cd7 100644 --- a/tests/unified_inventory_validation.gd +++ b/tests/unified_inventory_validation.gd @@ -19,15 +19,15 @@ func _initialize() -> void: func _run() -> void: - var root := Node.new() - get_root().add_child(root) + var scene_root := Node.new() + get_root().add_child(scene_root) var bag := PlayerBag.new() var catches := FishInventory.new() var capacity := PlayerCoolerCapacity.new() var layout := PlayerInventoryLayout.new() var hotbar := PlayerHotbar.new() for node: Node in [bag, catches, capacity, layout, hotbar]: - root.add_child(node) + scene_root.add_child(node) bag.setup(ItemCatalogResource) layout.setup(bag, catches, ItemCatalogResource, capacity) bag.set_inventory_layout(layout) @@ -109,7 +109,7 @@ func _run() -> void: assert(layout.get_storage_count() == 1) assert(layout.get_storage_capacity() == 9) var inventory_grid := GeneralInventoryGrid.new() - root.add_child(inventory_grid) + scene_root.add_child(inventory_grid) inventory_grid.set_slot_presentation(Vector2(78.0, 78.0), 10) inventory_grid.setup( layout, @@ -143,7 +143,7 @@ func _run() -> void: ) staged_slot.set_staged(false) var quality_slot := GeneralInventorySlot.new() - root.add_child(quality_slot) + scene_root.add_child(quality_slot) quality_slot.set_presentation_size(Vector2(78.0, 78.0)) quality_slot.configure( 0, @@ -181,7 +181,7 @@ func _run() -> void: distinct_quality_colors[quality_color] = true assert(distinct_quality_colors.size() == FishQuality.TIER_COUNT) var sale_tray_slot := ShopSaleTraySlot.new() - root.add_child(sale_tray_slot) + scene_root.add_child(sale_tray_slot) sale_tray_slot.configure( "catch:%s" % fish_catch.catch_id, fish_catch.fish.display_texture, @@ -207,7 +207,7 @@ func _run() -> void: expected_tray_quality_color.a = 0.96 assert(sale_tray_style.bg_color == expected_tray_quality_color) var wallet := PlayerWallet.new() - root.add_child(wallet) + scene_root.add_child(wallet) assert(wallet.restore_balance(15000)) assert(layout.get_next_backpack_cost() == 1500) assert(layout.purchase_backpack(wallet)) @@ -223,7 +223,7 @@ func _run() -> void: assert(inventory_grid.get_slots().size() == 36) assert(layout.get_next_backpack_cost() == -1) var storage_grid := GeneralInventoryGrid.new() - root.add_child(storage_grid) + scene_root.add_child(storage_grid) storage_grid.setup( layout, bag, @@ -240,7 +240,7 @@ func _run() -> void: == "locked storage slot 10" ) var storage_wallet := PlayerWallet.new() - root.add_child(storage_wallet) + scene_root.add_child(storage_wallet) assert(storage_wallet.restore_balance(9500)) for expected_capacity: int in [18, 27, 36, 45, 54, 63, 72]: assert(capacity.purchase(storage_wallet)) @@ -266,8 +266,8 @@ func _run() -> void: var network_sale := NetworkSaleService.new() var session := NetworkSession.new() - root.add_child(session) - root.add_child(network_sale) + scene_root.add_child(session) + scene_root.add_child(network_sale) network_sale.set("_session", session) network_sale.set("_item_catalog", ItemCatalogResource) network_sale.set("_fish_catalog", FishCatalogResource) @@ -284,5 +284,5 @@ func _run() -> void: assert((result.get("items", []) as Array).size() == 1) print("Unified inventory validation: PASS") - root.free() + scene_root.free() quit() diff --git a/tests/world_layout_validation.gd b/tests/world_layout_validation.gd index e6eec39..6c7f728 100644 --- a/tests/world_layout_validation.gd +++ b/tests/world_layout_validation.gd @@ -68,9 +68,9 @@ func _validate_setup_page() -> void: page.call("_set_world_layout", WorldLayout.STARTER_ISLAND) assert(not (page.get_node("%SeedSection") as Control).visible) var request: Dictionary = {} - page.start_requested.connect(func(layout: StringName, seed: int) -> void: + page.start_requested.connect(func(layout: StringName, world_seed: int) -> void: request["layout"] = layout - request["seed"] = seed + request["seed"] = world_seed ) page.call("_request_start") assert(request.get("layout") == WorldLayout.STARTER_ISLAND) diff --git a/tests/world_spawn_protocol_validation.gd b/tests/world_spawn_protocol_validation.gd index e300808..779818e 100644 --- a/tests/world_spawn_protocol_validation.gd +++ b/tests/world_spawn_protocol_validation.gd @@ -20,12 +20,21 @@ func _initialize() -> void: func _run() -> void: - assert(NetworkProtocol.PROTOCOL_VERSION == 12) - assert( + var protocol_version: int = NetworkProtocol.PROTOCOL_VERSION + var world_spawn_capability: String = ( NetworkWorldSpawnProtocol.CAPABILITY - == NetworkProtocol.WORLD_SPAWN_CAPABILITY ) - assert(NetworkWorldSpawnProtocol.SNAPSHOT_ENTITIES_PER_ENVELOPE <= 4) + var protocol_world_spawn_capability: String = ( + NetworkProtocol.WORLD_SPAWN_CAPABILITY + ) + var snapshot_entities_per_envelope: int = ( + NetworkWorldSpawnProtocol.SNAPSHOT_ENTITIES_PER_ENVELOPE + ) + assert(protocol_version == 12) + assert( + world_spawn_capability == protocol_world_spawn_capability + ) + assert(snapshot_entities_per_envelope <= 4) _validate_catalog_statuses() await _validate_billboard_presentation() _validate_envelopes() diff --git a/tests/world_time_validation.gd b/tests/world_time_validation.gd index 3caea2b..2ccd2ab 100644 --- a/tests/world_time_validation.gd +++ b/tests/world_time_validation.gd @@ -42,9 +42,15 @@ func _run() -> void: func _validate_clock_boundaries_and_duration() -> void: - assert(WorldTimeServiceType.REAL_SECONDS_PER_CYCLE == 86400.0) + var real_seconds_per_cycle: float = ( + WorldTimeServiceType.REAL_SECONDS_PER_CYCLE + ) + var hours_per_real_second: float = ( + WorldTimeServiceType.HOURS_PER_REAL_SECOND + ) + assert(real_seconds_per_cycle == 86400.0) assert(is_equal_approx( - WorldTimeServiceType.HOURS_PER_REAL_SECOND, + hours_per_real_second, 1.0 / 3600.0, )) assert(is_equal_approx( diff --git a/tests/world_weather_validation.gd b/tests/world_weather_validation.gd index 7bc5114..57be98d 100644 --- a/tests/world_weather_validation.gd +++ b/tests/world_weather_validation.gd @@ -382,11 +382,13 @@ func _validate_weather_presentation() -> void: "res://world/environment/local_storm_cloud.gdshader" ) assert("cull_disabled" in cloud_shader.code) - assert(LocalStormCloudLayer.WRAP_RADIUS >= 315.0) - assert(is_equal_approx(LocalStormCloudLayer.MAXIMUM_OPACITY, 1.0)) + var cloud_wrap_radius: float = LocalStormCloudLayer.WRAP_RADIUS + var cloud_maximum_opacity: float = LocalStormCloudLayer.MAXIMUM_OPACITY + var cloud_distance_fade_end: float = LocalStormCloudLayer.DISTANCE_FADE_END + assert(cloud_wrap_radius >= 315.0) + assert(is_equal_approx(cloud_maximum_opacity, 1.0)) assert( - LocalStormCloudLayer.DISTANCE_FADE_END - < LocalStormCloudLayer.WRAP_RADIUS + cloud_distance_fade_end < cloud_wrap_radius ) assert(is_zero_approx(float(storm_clouds.call("get_storm_amount")))) visuals.apply_weather_immediately( @@ -524,10 +526,18 @@ func _validate_weather_presentation() -> void: assert(rain.emitting) assert(rain.amount_ratio > 0.99) assert(rain.amount == WorldTimeVisualControllerType.RAIN_PARTICLE_AMOUNT) + var rain_emitter_offset: Vector3 = ( + WorldTimeVisualControllerType.RAIN_EMITTER_OFFSET + ) + var rain_supported_canopy_height: float = ( + WorldTimeVisualControllerType.RAIN_SUPPORTED_CANOPY_HEIGHT + ) + var rain_canopy_clearance: float = ( + WorldTimeVisualControllerType.RAIN_CANOPY_CLEARANCE + ) assert( - WorldTimeVisualControllerType.RAIN_EMITTER_OFFSET.y - >= WorldTimeVisualControllerType.RAIN_SUPPORTED_CANOPY_HEIGHT - + WorldTimeVisualControllerType.RAIN_CANOPY_CLEARANCE + rain_emitter_offset.y + >= rain_supported_canopy_height + rain_canopy_clearance ) assert(is_equal_approx( rain.lifetime, diff --git a/ui/chat_ui.gd b/ui/chat_ui.gd index 761ae4f..d310def 100644 --- a/ui/chat_ui.gd +++ b/ui/chat_ui.gd @@ -1463,8 +1463,8 @@ func is_mobile_mode() -> bool: return _mobile_mode -func set_hud_hidden(hidden: bool) -> void: - _hud_hidden = hidden +func set_hud_hidden(is_hidden: bool) -> void: + _hud_hidden = is_hidden _refresh_visibility() diff --git a/ui/components/locked_content_presentation.gd b/ui/components/locked_content_presentation.gd index 1342054..65ff816 100644 --- a/ui/components/locked_content_presentation.gd +++ b/ui/components/locked_content_presentation.gd @@ -53,7 +53,9 @@ static func make_icon_texture( Image.FORMAT_RGBA8, ) canvas.fill(Color.TRANSPARENT) - var inset := Vector2i.ONE * ((canvas_size - icon_size) / 2) + var inset := Vector2i.ONE * floori( + float(canvas_size - icon_size) / 2.0 + ) canvas.blend_rect( image, Rect2i(Vector2i.ZERO, Vector2i(icon_size, icon_size)), diff --git a/ui/components/logbook_portrait.gd b/ui/components/logbook_portrait.gd index fb5fa4a..8bee8e7 100644 --- a/ui/components/logbook_portrait.gd +++ b/ui/components/logbook_portrait.gd @@ -93,10 +93,16 @@ static func uniform_footprint_size( maximum_size.x / visible_size.x, maximum_size.y / visible_size.y, ) - var scale: float = minf(area_scale, fit_scale) + var footprint_scale: float = minf(area_scale, fit_scale) return Vector2( - minf(maximum_size.x, maxf(1.0, roundf(visible_size.x * scale))), - minf(maximum_size.y, maxf(1.0, roundf(visible_size.y * scale))), + minf( + maximum_size.x, + maxf(1.0, roundf(visible_size.x * footprint_scale)), + ), + minf( + maximum_size.y, + maxf(1.0, roundf(visible_size.y * footprint_scale)), + ), ) diff --git a/ui/components/organizer_tab.gd b/ui/components/organizer_tab.gd index bb2f777..5046e59 100644 --- a/ui/components/organizer_tab.gd +++ b/ui/components/organizer_tab.gd @@ -91,14 +91,14 @@ func _initialize_motion() -> void: _set_visual_y(_target_y()) -func _on_toggled(is_pressed: bool) -> void: - if _selected and not is_pressed: +func _on_toggled(is_button_pressed: bool) -> void: + if _selected and not is_button_pressed: # A selected organizer tab is a page marker, not a collapsible toggle. # Restore without another signal before any lower-state frame is drawn. set_pressed_no_signal(true) refresh_state(false) return - _selected = is_pressed + _selected = is_button_pressed refresh_state() diff --git a/ui/components/shop_sale_tray_slot.gd b/ui/components/shop_sale_tray_slot.gd index 0ac87bf..a45e28d 100644 --- a/ui/components/shop_sale_tray_slot.gd +++ b/ui/components/shop_sale_tray_slot.gd @@ -65,14 +65,14 @@ func _apply_style() -> void: func configure( key: String, - icon: Texture2D, + icon_texture: Texture2D, label: String, quantity: int = 1, quality: int = -1, ) -> void: entry_key = key _quality_tier = quality - _icon.texture = icon + _icon.texture = icon_texture _quantity.text = "×%d" % quantity if quantity > 1 else "" tooltip_text = "%s · select to remove" % label accessibility_name = tooltip_text diff --git a/ui/controller_focus_presentation.gd b/ui/controller_focus_presentation.gd index cb41c31..7ef7383 100644 --- a/ui/controller_focus_presentation.gd +++ b/ui/controller_focus_presentation.gd @@ -565,7 +565,7 @@ func _popup_item_height(popup: PopupMenu, index: int) -> float: popup.get_theme_font_size(&"font_size") ) var icon: Texture2D = popup.get_item_icon(index) - var icon_height: float = icon.get_height() if icon != null else 0.0 + var icon_height: float = float(icon.get_height()) if icon != null else 0.0 return maxf(font_height, icon_height) + vertical_separation diff --git a/ui/fishing_shop.gd b/ui/fishing_shop.gd index 7cc8fa8..26ce681 100644 --- a/ui/fishing_shop.gd +++ b/ui/fishing_shop.gd @@ -69,7 +69,9 @@ const CURRENCY_ICON: Texture2D = preload( signal menu_visibility_changed(is_open: bool) signal menu_exit_started +@warning_ignore("unused_signal") signal sell_fish_requested +@warning_ignore("unused_signal") signal shop_cooler_return_requested signal shop_cooler_confirmation_cancel_requested @@ -395,9 +397,11 @@ func _select_shop_section(section_index: int, focus_content: bool) -> void: _controller_zone = ControllerZone.CONTENT if section_index == int(_shop_section): if section_index != ShopSection.SELL_FISH: - var showing_upgrades := section_index == ShopSection.UPGRADES - _upgrades_content.visible = showing_upgrades - _supplies_content.visible = not showing_upgrades + var showing_current_upgrades: bool = ( + section_index == ShopSection.UPGRADES + ) + _upgrades_content.visible = showing_current_upgrades + _supplies_content.visible = not showing_current_upgrades _update_shop_tab_selection() if focus_content: _focus_shop_section() @@ -470,12 +474,12 @@ func _apply_shop_controller_zone_focus_modes() -> void: func _set_descendant_button_focus_mode( root_control: Control, - focus_mode: Control.FocusMode, + requested_focus_mode: Control.FocusMode, ) -> void: for child: Node in root_control.find_children("*", "BaseButton", true, false): var button := child as BaseButton if button != null: - button.focus_mode = focus_mode + button.focus_mode = requested_focus_mode func _update_shop_tab_selection() -> void: @@ -704,14 +708,14 @@ func deactivate_shop_cooler_page() -> void: func _set_shop_panel_background_pointer_blocking(blocking: bool) -> void: - var mouse_filter := ( + var panel_mouse_filter := ( Control.MOUSE_FILTER_STOP if blocking else Control.MOUSE_FILTER_IGNORE ) - _shop_panel.mouse_filter = mouse_filter - _shop_panel_margin.mouse_filter = mouse_filter - _shop_panel_layout.mouse_filter = mouse_filter + _shop_panel.mouse_filter = panel_mouse_filter + _shop_panel_margin.mouse_filter = panel_mouse_filter + _shop_panel_layout.mouse_filter = panel_mouse_filter func set_shop_cooler_modal_open(is_open: bool) -> void: diff --git a/ui/game_ui.gd b/ui/game_ui.gd index 55a07eb..0141b0c 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -204,6 +204,7 @@ var _hotbar: PlayerHotbarType var _item_catalog: ItemCatalogType var _player_menu_open: bool = false var _gameplay_ui_enabled: bool = false +var _gameplay_input_locked: bool = false var _gameplay_hud_hidden: bool = false var _fishing_spot: FishingSpotType var _system_menu_open: bool = false @@ -631,6 +632,9 @@ func _prioritize_surface_drawing_pointer_input() -> void: func _input(event: InputEvent) -> void: + if _gameplay_input_locked: + get_viewport().set_input_as_handled() + return if _social_prompt_open and event.is_action_pressed("ui_cancel"): _decline_social_prompt() get_viewport().set_input_as_handled() @@ -681,6 +685,7 @@ func _input(event: InputEvent) -> void: return var can_open: bool = ( _gameplay_ui_enabled + and not _gameplay_input_locked and not _system_menu_open and not _player_menu_open and not _shop_open @@ -719,6 +724,7 @@ func _input(event: InputEvent) -> void: func _reconcile_radial_menu_input() -> void: var blocked: bool = ( not _gameplay_ui_enabled + or _gameplay_input_locked or _system_menu_open or _player_menu_open or _shop_open @@ -769,6 +775,7 @@ func _close_quick_radial_menu() -> void: func _can_use_character_call() -> bool: return ( _gameplay_ui_enabled + and not _gameplay_input_locked and not _system_menu_open and not _player_menu_open and not _shop_open @@ -840,6 +847,7 @@ func _handle_controller_chat_controls(event: InputEvent) -> bool: button_event == null or not button_event.pressed or not _gameplay_ui_enabled + or _gameplay_input_locked or _system_menu_open or _player_menu_open or _shop_open @@ -1051,6 +1059,7 @@ func _mapped_virtual_mouse_stick() -> Vector2: func _can_start_virtual_mouse() -> bool: return ( _gameplay_ui_enabled + and not _gameplay_input_locked and not _showcase_active and not _system_menu_open and not _player_menu_open @@ -1430,6 +1439,7 @@ func _can_surface_drawing_be_active() -> bool: return ( _surface_drawing_hotbar_selected and _gameplay_ui_enabled + and not _gameplay_input_locked and not _system_menu_open and not _player_menu_open and not _shop_open @@ -1838,12 +1848,38 @@ func set_gameplay_ui_enabled(enabled: bool) -> void: _hotbar_ui.set_gameplay_input_enabled(false) else: _refresh_hotbar_visibility() - _hotbar_ui.set_gameplay_input_enabled(true) + _hotbar_ui.set_gameplay_input_enabled( + not _gameplay_input_locked + ) _refresh_fishing_panel_visibility() call_deferred("_start_next_experience_animation") _refresh_surface_drawing_activation() +func set_gameplay_input_locked(locked: bool) -> void: + if _gameplay_input_locked == locked: + return + _gameplay_input_locked = locked + if locked: + _end_virtual_mouse() + _close_radial_menus() + _hotbar_ui.set_drag_enabled(false) + if _surface_drawing != null: + _surface_drawing.deactivate() + _refresh_rv_storage_button_visibility() + _refresh_hotbar_visibility() + _refresh_chat_availability() + _hotbar_ui.set_gameplay_input_enabled( + _gameplay_ui_enabled + and not _gameplay_input_locked + and not _system_menu_open + and not _player_menu_open + and not _shop_open + and not _storage_open + ) + _refresh_surface_drawing_activation() + + func set_gameplay_hud_hidden(hidden: bool) -> void: _gameplay_hud_hidden = hidden if _quick_radial_menu != null: @@ -1862,6 +1898,7 @@ func is_gameplay_hud_hidden() -> bool: func _can_toggle_gameplay_hud() -> bool: return ( _gameplay_ui_enabled + and not _gameplay_input_locked and not _system_menu_open and not _player_menu_open and not _shop_open @@ -1903,6 +1940,7 @@ func set_system_menu_open(is_open: bool) -> void: _refresh_hotbar_visibility() _hotbar_ui.set_gameplay_input_enabled( _gameplay_ui_enabled + and not _gameplay_input_locked and not is_open and not _player_menu_open and not _shop_open @@ -1995,6 +2033,7 @@ func _refresh_rv_storage_button_visibility() -> void: _rv_storage_button.visible = ( _rv_storage_button_requested_visible and _gameplay_ui_enabled + and not _gameplay_input_locked and not _gameplay_hud_hidden and not _system_menu_open and not _player_menu_open @@ -2799,7 +2838,9 @@ func _on_player_menu_visibility_changed(is_open: bool) -> void: _hotbar_ui.set_player_menu_context(false) _refresh_chat_availability() _hotbar_ui.set_gameplay_input_enabled( - _gameplay_ui_enabled and not is_open + _gameplay_ui_enabled + and not _gameplay_input_locked + and not is_open ) if not is_open: _hotbar_ui.set_drag_enabled(false) @@ -2844,6 +2885,7 @@ func _on_shop_visibility_changed(is_open: bool) -> void: _refresh_hotbar_visibility() _hotbar_ui.set_gameplay_input_enabled( _gameplay_ui_enabled + and not _gameplay_input_locked and not is_open and not _system_menu_open and not _player_menu_open @@ -2865,6 +2907,7 @@ func _on_storage_visibility_changed(is_open: bool) -> void: _refresh_hotbar_visibility() _hotbar_ui.set_gameplay_input_enabled( _gameplay_ui_enabled + and not _gameplay_input_locked and not is_open and not _system_menu_open and not _player_menu_open @@ -2977,6 +3020,7 @@ func _on_hotbar_presentation_transition_finished( ) -> void: _hotbar_ui.set_drag_enabled( presentation_visible + and not _gameplay_input_locked and _player_menu_open and _player_menu_hotbar_visible and not _system_menu_open @@ -2986,6 +3030,7 @@ func _on_hotbar_presentation_transition_finished( func _refresh_hotbar_visibility() -> void: _hotbar_ui.set_presentation_visible( _gameplay_ui_enabled + and not _gameplay_input_locked and ( not _gameplay_hud_hidden or (_player_menu_open and _player_menu_hotbar_visible) @@ -3027,6 +3072,7 @@ func _refresh_chat_availability() -> void: return var chat_available := ( _gameplay_ui_enabled + and not _gameplay_input_locked and not _system_menu_open and not _player_menu_open and not _shop_open diff --git a/ui/interface_font_controller.gd b/ui/interface_font_controller.gd index a27e56a..5e55ada 100644 --- a/ui/interface_font_controller.gd +++ b/ui/interface_font_controller.gd @@ -100,7 +100,11 @@ func popup_file_dialog(dialog: FileDialog) -> void: # clamp guarantees the embedded window cannot escape the physical screen. dialog.size = available_size if dialog.get_viewport().gui_embed_subwindows: - dialog.position = (host_size - available_size) / 2 + var centered_position := Vector2i( + floori(float(host_size.x - available_size.x) / 2.0), + floori(float(host_size.y - available_size.y) / 2.0), + ) + dialog.position = centered_position _finalize_compact_file_dialog.call_deferred( dialog, host_size, available_size ) diff --git a/ui/mail_page.gd b/ui/mail_page.gd index 0bfef1f..62bcdbb 100644 --- a/ui/mail_page.gd +++ b/ui/mail_page.gd @@ -329,9 +329,9 @@ func _configure_compose_controller_navigation() -> void: and _attachment_amount.is_visible_in_tree() ) var amount_left: Control = _amount_minus if amount_visible else _compose_cancel - var amount_middle: Control = ( - _attachment_amount if amount_visible else _compose_cancel - ) + var amount_middle: Control = _compose_cancel + if amount_visible: + amount_middle = _attachment_amount var amount_right: Control = _amount_plus if amount_visible else _send_button _set_compose_neighbors( _greeting, _greeting, _recipient, _greeting, _body diff --git a/ui/network/join_game_page.gd b/ui/network/join_game_page.gd index 93ba793..6546517 100644 --- a/ui/network/join_game_page.gd +++ b/ui/network/join_game_page.gd @@ -394,7 +394,10 @@ func _set_mode(mode: Mode, clear_connection_error: bool = true) -> void: _discovery_refresh_timer.stop() if not is_visible_in_tree(): return - _defer_focus_control(_address if mode == Mode.DIRECT else _server_list) + var initial_focus: Control = _server_list + if mode == Mode.DIRECT: + initial_focus = _address + _defer_focus_control(initial_focus) func _default_mode_status(mode: Mode) -> String: @@ -688,9 +691,10 @@ func _restore_entry_selection_and_focus( _selected_entry = null _server_list.deselect_all() _refresh() - _defer_focus_control( - _server_list if selected else _current_mode_button() - ) + var restored_focus: Control = _current_mode_button() + if selected: + restored_focus = _server_list + _defer_focus_control(restored_focus) func _current_mode_button() -> Button: @@ -1203,10 +1207,10 @@ func _recover_controller_focus( ) -> void: if not is_visible_in_tree(): return - var owner: Control = get_viewport().gui_get_focus_owner() - if owner != null and owner in controls: + var focus_owner: Control = get_viewport().gui_get_focus_owner() + if focus_owner != null and focus_owner in controls: return - if owner != null and _delete_confirmation.is_ancestor_of(owner): + if focus_owner != null and _delete_confirmation.is_ancestor_of(focus_owner): return if fallback != null: _defer_focus_control(fallback) @@ -1539,7 +1543,9 @@ func _on_back_pressed() -> void: if _name_entry_active: _clear_edit_state() _refresh() - var content: Control = _address if _mode == Mode.DIRECT else _server_list + var content: Control = _server_list + if _mode == Mode.DIRECT: + content = _address _defer_focus_control(content) return if ( diff --git a/ui/new_game_setup_page.gd b/ui/new_game_setup_page.gd index 68e2aa8..989880b 100644 --- a/ui/new_game_setup_page.gd +++ b/ui/new_game_setup_page.gd @@ -167,8 +167,8 @@ func _on_seed_submitted(_text: String) -> void: func _request_start() -> void: - var seed: int = get_selected_world_seed() - if _world_layout == WorldLayoutType.GENERATED and seed == 0: + var world_seed: int = get_selected_world_seed() + if _world_layout == WorldLayoutType.GENERATED and world_seed == 0: _status.text = ( "enter some text or a whole number from 1 to %d." % SaveManagerType.MAX_WORLD_SEED @@ -176,10 +176,10 @@ func _request_start() -> void: _seed_edit.grab_focus() _seed_edit.select_all() return - if seed == 0: - seed = SaveManagerType.roll_world_seed() + if world_seed == 0: + world_seed = SaveManagerType.roll_world_seed() _start_button.disabled = true - start_requested.emit(_world_layout, seed) + start_requested.emit(_world_layout, world_seed) func _refresh_presentation() -> void: @@ -226,33 +226,43 @@ func _refresh_start_state() -> void: func _configure_controller_focus() -> void: var generated: bool = _world_layout == WorldLayoutType.GENERATED var custom: bool = generated and _seed_mode == SeedMode.CUSTOM + var generated_button_bottom: Control = _start_button + var starter_button_bottom: Control = _back_button + var random_seed_button_bottom: Control = _start_button + var custom_seed_button_bottom: Control = _back_button + if generated: + generated_button_bottom = _random_seed_button + starter_button_bottom = _custom_seed_button + if custom: + random_seed_button_bottom = _seed_edit + custom_seed_button_bottom = _seed_edit _set_neighbors( _generated_button, _generated_button, _starter_button, _generated_button, - _random_seed_button if generated else _start_button, + generated_button_bottom, ) _set_neighbors( _starter_button, _generated_button, _starter_button, _starter_button, - _custom_seed_button if generated else _back_button, + starter_button_bottom, ) _set_neighbors( _random_seed_button, _random_seed_button, _custom_seed_button, _generated_button, - _seed_edit if custom else _start_button, + random_seed_button_bottom, ) _set_neighbors( _custom_seed_button, _random_seed_button, _custom_seed_button, _starter_button, - _seed_edit if custom else _back_button, + custom_seed_button_bottom, ) _set_neighbors( _seed_edit, @@ -261,13 +271,11 @@ func _configure_controller_focus() -> void: _custom_seed_button, _start_button, ) - var action_top: Control = ( - _seed_edit - if custom - else _random_seed_button - if generated - else _generated_button - ) + var action_top: Control = _generated_button + if generated: + action_top = _random_seed_button + if custom: + action_top = _seed_edit _set_neighbors( _start_button, _start_button, diff --git a/ui/player_menu.gd b/ui/player_menu.gd index 95263f5..0545dd4 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -710,7 +710,7 @@ func _handle_controller_ownership_input(event: InputEvent) -> bool: if not _is_inventory_section(_current_section): return _handle_active_page_controller_input(event) var button_event := event as InputEventJoypadButton - var accept_event: bool = ( + var accept_button_event: bool = ( button_event != null and _event_matches_controller_role( event, @@ -719,7 +719,7 @@ func _handle_controller_ownership_input(event: InputEvent) -> bool: ) ) var accept_pressed: bool = ( - accept_event + accept_button_event and button_event.pressed ) var cancel_pressed: bool = ( @@ -777,7 +777,7 @@ func _handle_controller_ownership_input(event: InputEvent) -> bool: if accept_pressed: _confirm_controller_storage_placement() return true - if accept_event: + if accept_button_event: return true if ( event.is_action_pressed("ui_down") @@ -810,7 +810,7 @@ func _handle_controller_ownership_input(event: InputEvent) -> bool: ): _cancel_active_item_move() return _try_enter_notepad_controller_ownership() - if _current_section == Section.TACKLE_BOX and accept_event: + if _current_section == Section.TACKLE_BOX and accept_button_event: # Bait and lures are unlock collections, not movable inventory slots. # Controller Y owns their contextual equip/notepad action; A is # intentionally inert and consumed so it cannot activate the Button. @@ -831,7 +831,7 @@ func _handle_controller_ownership_input(event: InputEvent) -> bool: if _current_section == Section.COOLER: _enter_inventory_sort_zone() return true - if accept_event: + if accept_button_event: if button_event.pressed: _controller_accept_held = true _controller_accept_hold_elapsed = 0.0 @@ -986,11 +986,14 @@ func _controller_focus_is_on_last_inventory_row() -> bool: var slot := focus_owner as GeneralInventorySlotType if slot == null: return false - return ( - slot.slot_index / BAG_STORAGE_COLUMNS - >= (_inventory_layout.get_inventory_capacity() - 1) - / BAG_STORAGE_COLUMNS + var focused_row: int = int( + float(slot.slot_index) / float(BAG_STORAGE_COLUMNS) ) + var final_row: int = int( + float(_inventory_layout.get_inventory_capacity() - 1) + / float(BAG_STORAGE_COLUMNS) + ) + return focused_row >= final_row return false @@ -1139,10 +1142,13 @@ func _controller_storage_focus_is_on_last_row() -> bool: var slot := get_viewport().gui_get_focus_owner() as BagStorageSlotType if slot == null: return false - return ( - slot.storage_slot_index / BAG_STORAGE_COLUMNS - >= (_bag_slot_nodes.size() - 1) / BAG_STORAGE_COLUMNS + var focused_row: int = int( + float(slot.storage_slot_index) / float(BAG_STORAGE_COLUMNS) ) + var final_row: int = int( + float(_bag_slot_nodes.size() - 1) / float(BAG_STORAGE_COLUMNS) + ) + return focused_row >= final_row func _find_controller_hotbar_assignment( @@ -4575,11 +4581,6 @@ func _refresh_economy_summary() -> void: if not is_node_ready(): return var balance: int = _wallet.get_balance() if _wallet != null else 0 - var held_total: int = ( - _inventory.get_total_sale_value() - if _inventory != null - else 0 - ) _notepad_wallet_value.set_amount(balance) _notepad_capacity_value.text = "%d / %d" % [ _inventory.get_all_catches().size() if _inventory != null else 0, diff --git a/ui/profile_page.gd b/ui/profile_page.gd index ece0673..910333b 100644 --- a/ui/profile_page.gd +++ b/ui/profile_page.gd @@ -1501,10 +1501,10 @@ func _resize_feature_preview_image(image: Image, max_dimension: int) -> void: var largest_dimension := maxi(source_size.x, source_size.y) if largest_dimension <= max_dimension: return - var scale := float(max_dimension) / float(largest_dimension) + var resize_scale := float(max_dimension) / float(largest_dimension) image.resize( - maxi(roundi(float(source_size.x) * scale), 1), - maxi(roundi(float(source_size.y) * scale), 1), + maxi(roundi(float(source_size.x) * resize_scale), 1), + maxi(roundi(float(source_size.y) * resize_scale), 1), Image.INTERPOLATE_NEAREST, ) diff --git a/ui/quick_radial_menu.gd b/ui/quick_radial_menu.gd index 05ce0a3..45d2632 100644 --- a/ui/quick_radial_menu.gd +++ b/ui/quick_radial_menu.gd @@ -18,8 +18,8 @@ const SECTOR_COUNT: int = 3 var _hud_hidden: bool = false -func set_hud_hidden(hidden: bool) -> void: - _hud_hidden = hidden +func set_hud_hidden(is_hidden: bool) -> void: + _hud_hidden = is_hidden _refresh_labels() diff --git a/ui/save_slots_page.gd b/ui/save_slots_page.gd index 74e3a7f..41b25b4 100644 --- a/ui/save_slots_page.gd +++ b/ui/save_slots_page.gd @@ -509,21 +509,21 @@ func _export_file_selected(path: String) -> void: func _request_create() -> void: if _create_button.disabled: return - var seed: int = _selected_world_seed() - if _world_layout == WorldLayoutType.GENERATED and seed == 0: + var world_seed: int = _selected_world_seed() + if _world_layout == WorldLayoutType.GENERATED and world_seed == 0: _status.text = "enter text or a whole number from 1 to %d." % SaveManagerType.MAX_WORLD_SEED _seed_edit.grab_focus() _seed_edit.select_all() return - if seed == 0: - seed = SaveManagerType.roll_world_seed() + if world_seed == 0: + world_seed = SaveManagerType.roll_world_seed() _busy = true _status.text = "creating save slot..." _refresh_action_state() create_requested.emit( PlayerSaveSlotCatalog.normalized_name(_new_slot_name.text), _world_layout, - seed, + world_seed, _duplicate_source_slot_id, ) @@ -656,13 +656,27 @@ func _configure_controller_focus() -> void: _new_slot_tab.focus_neighbor_bottom = _new_slot_tab.get_path_to(_new_slot_name) var generated: bool = _world_layout == WorldLayoutType.GENERATED var custom: bool = generated and _seed_mode == SeedMode.CUSTOM + var generated_button_bottom: Control = _create_button + var starter_button_bottom: Control = _back_button + var random_seed_button_bottom: Control = _create_button + var custom_seed_button_bottom: Control = _back_button + if generated: + generated_button_bottom = _random_seed_button + starter_button_bottom = _custom_seed_button + if custom: + random_seed_button_bottom = _seed_edit + custom_seed_button_bottom = _seed_edit _set_neighbors(_new_slot_name, _new_slot_name, _new_slot_name, _new_slot_tab, _generated_button) - _set_neighbors(_generated_button, _generated_button, _starter_button, _new_slot_name, _random_seed_button if generated else _create_button) - _set_neighbors(_starter_button, _generated_button, _starter_button, _new_slot_name, _custom_seed_button if generated else _back_button) - _set_neighbors(_random_seed_button, _random_seed_button, _custom_seed_button, _generated_button, _seed_edit if custom else _create_button) - _set_neighbors(_custom_seed_button, _random_seed_button, _custom_seed_button, _starter_button, _seed_edit if custom else _back_button) + _set_neighbors(_generated_button, _generated_button, _starter_button, _new_slot_name, generated_button_bottom) + _set_neighbors(_starter_button, _generated_button, _starter_button, _new_slot_name, starter_button_bottom) + _set_neighbors(_random_seed_button, _random_seed_button, _custom_seed_button, _generated_button, random_seed_button_bottom) + _set_neighbors(_custom_seed_button, _random_seed_button, _custom_seed_button, _starter_button, custom_seed_button_bottom) _set_neighbors(_seed_edit, _seed_edit, _seed_edit, _custom_seed_button, _create_button) - var action_top: Control = _seed_edit if custom else _random_seed_button if generated else _generated_button + var action_top: Control = _generated_button + if generated: + action_top = _random_seed_button + if custom: + action_top = _seed_edit _set_neighbors(_create_button, _create_button, _back_button, action_top, _create_button) _set_neighbors(_back_button, _create_button, _back_button, action_top, _back_button) diff --git a/ui/title_screen.gd b/ui/title_screen.gd index 0a80f63..114ee9e 100644 --- a/ui/title_screen.gd +++ b/ui/title_screen.gd @@ -57,6 +57,7 @@ const INTRO_MENU_REVEAL_DURATION: float = 0.30 const INTRO_LOGO_CENTER_Y_RATIO: float = 0.43 signal new_game_requested(world_layout: StringName, world_seed: int) +@warning_ignore("unused_signal") signal continue_game_requested signal slot_play_requested(slot_id: String) signal new_slot_requested( @@ -89,7 +90,6 @@ enum ConfirmationAction { @onready var _title_presentation_scale_root: Control = ( %TitlePresentationScaleRoot ) -@onready var _background: ColorRect = %Background @onready var _title_logo: TextureRect = %TitleLogo @onready var _playtest_label: Label = %PlaytestLabel @onready var _presentation_center: CenterContainer = %Center @@ -1081,7 +1081,6 @@ func _on_confirmation_accepted() -> void: or _confirmation_action == ConfirmationAction.NONE ): return - var action: ConfirmationAction = _confirmation_action _confirmation_page.lock_interaction() _action_in_progress = true if not _save_manager.delete_progression_save(): diff --git a/world/generation/generated_world_region.gd b/world/generation/generated_world_region.gd index 61484fd..e567369 100644 --- a/world/generation/generated_world_region.gd +++ b/world/generation/generated_world_region.gd @@ -1,3 +1,5 @@ +# Generated terrain initialization is runtime-only, not editor-time tooling. +@warning_ignore("missing_tool") class_name GeneratedWorldRegion extends WorldRegion @@ -18,6 +20,9 @@ const RVUpgradeInteractionType = preload( const PrecipitationOcclusionType = preload( "res://world/environment/precipitation_occlusion.gd" ) +const FOLIAGE_WIND_SHADER: Shader = preload( + "res://world/materials/foliage_wind.gdshader" +) const WATER_BODY_SCENE: PackedScene = preload("res://world/water_body.tscn") const SALT_WATER_MATERIAL: Material = preload( "res://world/materials/stylized_water.tres" @@ -74,10 +79,27 @@ const PROCEDURAL_PROP_GROUPS: Array[StringName] = [ &"grass_detail", &"sand_tree", ] +const FOLIAGE_MATERIAL_NAMES: Array[StringName] = [ + &"leaf", + &"leaf_light", + &"leaf_dark", + &"pine", + &"leaf_variant_light", + &"leaf_variant_mid", + &"leaf_variant_dark", +] +# Retain the exact generated material override while foliage wind is active so +# profile changes restore both the selected palette and authored surface state. +const FOLIAGE_WIND_SOURCE_MATERIALS_META: StringName = ( + &"foliage_wind_source_materials" +) @export var initial_seed := PlayerSaveManager.DEFAULT_WORLD_SEED @export var prop_catalog: TerrainPropCatalog @export var biome_catalog: TerrainBiomeCatalog +@export_group("Foliage Wind") +@export_range(0.0, 0.4, 0.005) var foliage_wind_strength: float = 0.36 +@export_range(0.0, 4.0, 0.05) var foliage_wind_speed: float = 0.9 @onready var _generator: TerrainChunkGenerator = %TerrainChunkGenerator @onready var _player_spawn: Marker3D = %PlayerSpawn @@ -114,6 +136,7 @@ var _placed_group_coordinates: Dictionary[StringName, Array] = {} var _biome_assignments: Dictionary[Vector2i, StringName] = {} var _water_recovery_triangles_by_coordinate: Dictionary[Vector2i, Array] = {} var _home_accessible_elevations_by_coordinate: Dictionary[Vector2i, Array] = {} +var _foliage_wind_enabled: bool = false func _ready() -> void: @@ -122,18 +145,19 @@ func _ready() -> void: _validate_biome_catalog() _configure_static_water() _build_shoreline_reference() + _set_foliage_wind_enabled(not _light_performance_profile) -func generate_world(seed: int) -> bool: - if seed <= 0 or seed > PlayerSaveManager.MAX_WORLD_SEED: +func generate_world(world_seed: int) -> bool: + if world_seed <= 0 or world_seed > PlayerSaveManager.MAX_WORLD_SEED: return false if ( - seed == _current_seed + world_seed == _current_seed and _generator.get_generated_chunks_root() != null ): return true - _current_seed = seed - _generator.generation_seed = seed + _current_seed = world_seed + _generator.generation_seed = world_seed return _generator.generate() @@ -191,6 +215,7 @@ func get_biome_at(coordinate: Vector2i) -> StringName: func set_light_performance_profile(enabled: bool) -> void: _light_performance_profile = enabled + _set_foliage_wind_enabled(not enabled) _apply_water_materials() @@ -224,8 +249,8 @@ func get_spawn_surface_triangles( return triangles -func is_home_exterior_position_accessible(position: Vector3) -> bool: - var local_position := _generator.to_local(position) +func is_home_exterior_position_accessible(world_position: Vector3) -> bool: + var local_position := _generator.to_local(world_position) if local_position.y <= HOME_GROUND_HEIGHT_TOLERANCE: return true var coordinate := _generator.coordinate_for_local_position(local_position) @@ -310,12 +335,12 @@ func _on_generation_completed(summary: Dictionary) -> void: group_counts[group_key] = 0 _placed_group_coordinates[group_key] = [] for record: Dictionary in records: - var position: Vector3 = record.get("position", Vector3.ZERO) + var record_position: Vector3 = record.get("position", Vector3.ZERO) var tags: PackedStringArray = record.get( "tags", PackedStringArray() ) if "spawn" in tags: - spawn_position = position + spawn_position = record_position continue var coordinate: Vector2i = record.get( "coordinate", @@ -418,6 +443,7 @@ func _on_generation_completed(summary: Dictionary) -> void: # generated world is assembled. Mark the final decoration tree once every # prop and material variant is in place so rain canopy occlusion persists. _mark_precipitation_occluders() + _set_foliage_wind_enabled(_foliage_wind_enabled) world_generated.emit(_current_seed, summary) @@ -520,6 +546,127 @@ func _mark_precipitation_occluders() -> void: PrecipitationOcclusionType.mark_tree_meshes(prop) +func _set_foliage_wind_enabled(enabled: bool) -> void: + _foliage_wind_enabled = enabled + for prop: Node in _decorations.get_children(): + _apply_foliage_wind_to_node(prop, enabled) + + +func _apply_foliage_wind_to_node(root_node: Node, enabled: bool) -> void: + var mesh_instance := root_node as MeshInstance3D + if mesh_instance != null: + if enabled: + _apply_foliage_wind_to_mesh(mesh_instance) + else: + _remove_foliage_wind_from_mesh(mesh_instance) + for child: Node in root_node.get_children(): + _apply_foliage_wind_to_node(child, enabled) + + +func _remove_foliage_wind_from_mesh(mesh_instance: MeshInstance3D) -> void: + if mesh_instance.mesh == null: + return + var source_materials: Dictionary = mesh_instance.get_meta( + FOLIAGE_WIND_SOURCE_MATERIALS_META, + {}, + ) + for surface_index: int in mesh_instance.mesh.get_surface_count(): + var override_material: Material = ( + mesh_instance.get_surface_override_material(surface_index) + ) + var shader_material := override_material as ShaderMaterial + if ( + shader_material != null + and shader_material.shader == FOLIAGE_WIND_SHADER + ): + var source_material: Material = source_materials.get( + surface_index, + null, + ) as Material + mesh_instance.set_surface_override_material( + surface_index, + source_material, + ) + source_materials.erase(surface_index) + if source_materials.is_empty(): + mesh_instance.remove_meta(FOLIAGE_WIND_SOURCE_MATERIALS_META) + else: + mesh_instance.set_meta( + FOLIAGE_WIND_SOURCE_MATERIALS_META, + source_materials, + ) + + +func _apply_foliage_wind_to_mesh(mesh_instance: MeshInstance3D) -> void: + if mesh_instance.mesh == null: + return + var source_materials: Dictionary = mesh_instance.get_meta( + FOLIAGE_WIND_SOURCE_MATERIALS_META, + {}, + ) + var bounds: AABB = mesh_instance.get_aabb() + var mesh_global_scale: Vector3 = mesh_instance.global_basis.get_scale().abs() + var horizontal_scale: float = maxf( + (mesh_global_scale.x + mesh_global_scale.z) * 0.5, + 0.001, + ) + var local_strength: float = foliage_wind_strength / horizontal_scale + var phase: float = fposmod( + mesh_instance.global_position.x * 0.73 + + mesh_instance.global_position.z * 0.41, + TAU, + ) + for surface_index: int in mesh_instance.mesh.get_surface_count(): + var source_material: Material = mesh_instance.get_active_material( + surface_index + ) + if not source_material is StandardMaterial3D: + continue + if not FOLIAGE_MATERIAL_NAMES.has( + StringName(source_material.resource_name) + ): + continue + if not source_materials.has(surface_index): + source_materials[surface_index] = ( + mesh_instance.get_surface_override_material(surface_index) + ) + var source_standard := source_material as StandardMaterial3D + var wind_material := ShaderMaterial.new() + wind_material.shader = FOLIAGE_WIND_SHADER + wind_material.set_shader_parameter( + "albedo_color", + source_standard.albedo_color, + ) + wind_material.set_shader_parameter( + "material_roughness", + source_standard.roughness, + ) + wind_material.set_shader_parameter( + "material_metallic", + source_standard.metallic, + ) + wind_material.set_shader_parameter("local_min_y", bounds.position.y) + wind_material.set_shader_parameter( + "local_height", + maxf(bounds.size.y, 0.001), + ) + wind_material.set_shader_parameter( + "local_wind_strength", + local_strength, + ) + wind_material.set_shader_parameter("wind_speed", foliage_wind_speed) + wind_material.set_shader_parameter("wind_phase", phase) + mesh_instance.set_surface_override_material( + surface_index, + wind_material, + ) + if not source_materials.is_empty(): + mesh_instance.set_meta( + FOLIAGE_WIND_SOURCE_MATERIALS_META, + source_materials, + ) + + func _assign_biomes( records: Array[Dictionary], summary: Dictionary, @@ -1056,12 +1203,12 @@ func _prop_yaw( ) -func _nearest_ocean_direction(position: Vector3) -> Vector2: +func _nearest_ocean_direction(world_position: Vector3) -> Vector2: var half_extents := get_playable_half_extents() - var distance_x := half_extents.x - absf(position.x) - var distance_z := half_extents.y - absf(position.z) - var direction_x := Vector2.RIGHT if position.x >= 0.0 else Vector2.LEFT - var direction_z := Vector2.DOWN if position.z >= 0.0 else Vector2.UP + var distance_x := half_extents.x - absf(world_position.x) + var distance_z := half_extents.y - absf(world_position.z) + var direction_x := Vector2.RIGHT if world_position.x >= 0.0 else Vector2.LEFT + var direction_z := Vector2.DOWN if world_position.z >= 0.0 else Vector2.UP if absf(distance_x - distance_z) <= _generator.catalog.chunk_size * 0.35: return (direction_x + direction_z).normalized() return direction_x if distance_x < distance_z else direction_z @@ -1193,8 +1340,8 @@ func _spawn_distance_eligible_definitions( ) -> Array[TerrainPropDefinition]: var result: Array[TerrainPropDefinition] = [] var center := Vector2i( - _generator.grid_size.x / 2, - _generator.grid_size.y / 2, + int(float(_generator.grid_size.x) / 2.0), + int(float(_generator.grid_size.y) / 2.0), ) var distance := ( absi(coordinate.x - center.x) @@ -1233,7 +1380,7 @@ func _record_prop_group_coordinate( func _has_preferred_prop_near( definition: TerrainPropDefinition, - position: Vector3, + world_position: Vector3, ) -> bool: for index: int in _placed_prop_positions.size(): if ( @@ -1242,7 +1389,7 @@ func _has_preferred_prop_near( ): continue var other := _placed_prop_positions[index] - if Vector2(position.x - other.x, position.z - other.z).length() <= ( + if Vector2(world_position.x - other.x, world_position.z - other.z).length() <= ( definition.preferred_nearby_radius ): return true @@ -1323,12 +1470,12 @@ func _biome_group_key( return StringName("%s:%s" % [biome_id, group]) -func _has_prop_clearance(position: Vector3, radius: float) -> bool: +func _has_prop_clearance(world_position: Vector3, radius: float) -> bool: for index: int in _placed_prop_positions.size(): var other := _placed_prop_positions[index] var distance := Vector2( - position.x - other.x, - position.z - other.z, + world_position.x - other.x, + world_position.z - other.z, ).length() if distance < radius + _placed_prop_clearance_radii[index]: return false @@ -1584,12 +1731,12 @@ func _safe_recovery_surface_position(candidate: Vector3) -> Variant: func _water_recovery_surface_triangles_near( - position: Vector3, + world_position: Vector3, ) -> Array[PackedVector3Array]: var result: Array[PackedVector3Array] = [] if _generator.catalog == null or _generator.catalog.chunk_size <= 0.0: return result - var local_position := _generator.to_local(position) + var local_position := _generator.to_local(world_position) var half_grid := Vector2( float(_generator.grid_size.x - 1) * 0.5, float(_generator.grid_size.y - 1) * 0.5, @@ -1662,12 +1809,12 @@ func _surface_triangles_for_coordinate( func _surface_height_at( - position: Vector3, + world_position: Vector3, triangles: Array[PackedVector3Array], ) -> float: var highest := -INF - var segment_start := position + Vector3.UP * 100.0 - var segment_end := position + Vector3.DOWN * 100.0 + var segment_start := world_position + Vector3.UP * 100.0 + var segment_end := world_position + Vector3.DOWN * 100.0 for triangle: PackedVector3Array in triangles: if triangle.size() != 3: continue @@ -1684,13 +1831,13 @@ func _surface_height_at( func _surface_supports_prop_footprint( - position: Vector3, + world_position: Vector3, surface_height: float, radius: float, triangles: Array[PackedVector3Array], ) -> bool: for direction: Vector2 in PROP_SURFACE_SAMPLE_DIRECTIONS: - var sample_position := position + Vector3( + var sample_position := world_position + Vector3( direction.x * radius, 0.0, direction.y * radius, diff --git a/world/generation/terrain_chunk_generator.gd b/world/generation/terrain_chunk_generator.gd index 64a8bd2..381b919 100644 --- a/world/generation/terrain_chunk_generator.gd +++ b/world/generation/terrain_chunk_generator.gd @@ -282,11 +282,13 @@ func _resolved_layout_validation_error( if counts.get(required_id, 0) <= 0: return "required chunk %s is missing." % required_id if force_center_chunk_id != &"": + @warning_ignore("integer_division") var center := Vector2i(grid_size.x / 2, grid_size.y / 2) var center_index := center.y * grid_size.x + center.x if layout[center_index].definition.stable_id != force_center_chunk_id: return "the forced center chunk is missing from the center cell." for index: int in layout.size(): + @warning_ignore("integer_division") var coordinate := Vector2i(index % grid_size.x, index / grid_size.x) var current := layout[index] if not _variant_respects_ocean_boundary(current, coordinate): @@ -956,6 +958,7 @@ func _candidate_has_domain_support( index: int, domains: Dictionary, ) -> bool: + @warning_ignore("integer_division") var coordinate := Vector2i(index % grid_size.x, index / grid_size.x) var possible_required_neighbors := 0 for edge_value: int in TerrainChunkTopology.Edge.values(): @@ -1015,6 +1018,7 @@ func _placed_neighbor_requirements_have_domain_support( var placement := _placements[index] if placement == null or placement.definition.minimum_required_neighbors <= 0: continue + @warning_ignore("integer_division") var coordinate := Vector2i(index % grid_size.x, index / grid_size.x) var possible_neighbors := 0 for edge_value: int in TerrainChunkTopology.Edge.values(): @@ -1205,6 +1209,7 @@ func _candidate_dynamic_cell_rules_are_satisfied( ) -> bool: if not _definition_has_capacity(candidate.definition): return false + @warning_ignore("integer_division") var center_index := (grid_size.y / 2) * grid_size.x + grid_size.x / 2 if ( index != center_index @@ -1288,12 +1293,15 @@ func _candidate_can_occupy_cell( candidate: TerrainChunkVariant, index: int, ) -> bool: + @warning_ignore("integer_division") var coordinate := Vector2i(index % grid_size.x, index / grid_size.x) + @warning_ignore("integer_division") + var center := Vector2i(grid_size.x / 2, grid_size.y / 2) if ( _cell_requires_reserved_grass(index) and candidate.definition.stable_id != elevated_cliff_base_chunk_id and not ( - coordinate == Vector2i(grid_size.x / 2, grid_size.y / 2) + coordinate == center and force_center_chunk_id != &"" and candidate.definition.stable_id == force_center_chunk_id and "grass" in candidate.definition.tags @@ -1301,7 +1309,6 @@ func _candidate_can_occupy_cell( ) ): return false - var center := Vector2i(grid_size.x / 2, grid_size.y / 2) if ( coordinate == center and force_center_chunk_id != &"" @@ -1353,6 +1360,7 @@ func _prepare_elevated_feature_region() -> bool: "Elevated cliff feature requires at least an 11x11 terrain grid." ) return false + @warning_ignore("integer_division") var forced_center := Vector2i(grid_size.x / 2, grid_size.y / 2) var candidates: Array[Vector2i] = [] for row: int in range( @@ -1668,6 +1676,7 @@ func _lake_feature_candidates( var result: Array[Dictionary] = [] if footprint_size.x < 2 or footprint_size.y < 2: return result + @warning_ignore("integer_division") var center := Vector2i(grid_size.x / 2, grid_size.y / 2) for row: int in range( LAKE_FEATURE_CLEARANCE + 1, @@ -1961,6 +1970,7 @@ func _river_feature_candidate_is_valid( or outlet_coordinates.size() != 2 ): return false + @warning_ignore("integer_division") var center := Vector2i(grid_size.x / 2, grid_size.y / 2) var source_lookup: Dictionary[Vector2i, bool] = {} var source_exit_lookup: Dictionary[Vector2i, bool] = {} @@ -2527,8 +2537,10 @@ func _build_grid_solver_caches() -> void: _neighbor_indices.resize(_placements.size()) _static_cell_candidate_masks.resize(_placements.size()) _static_cell_candidate_masks.fill(0) + @warning_ignore("integer_division") var center := Vector2i(grid_size.x / 2, grid_size.y / 2) for index: int in _placements.size(): + @warning_ignore("integer_division") var coordinate := Vector2i(index % grid_size.x, index / grid_size.x) var neighbors := PackedInt32Array([-1, -1, -1, -1]) for edge_value: int in TerrainChunkTopology.Edge.values(): @@ -2596,6 +2608,7 @@ func _apply_grass_sand_smoothing() -> bool: return false var candidates: Array[Dictionary] = [] + @warning_ignore("integer_division") var center := Vector2i(grid_size.x / 2, grid_size.y / 2) for index: int in _placements.size(): var placement := _placements[index] @@ -2604,6 +2617,7 @@ func _apply_grass_sand_smoothing() -> bool: smoothing_sand_chunk_id, ]: continue + @warning_ignore("integer_division") var coordinate := Vector2i(index % grid_size.x, index / grid_size.x) if ( _coordinate_is_on_boundary(coordinate) @@ -2646,6 +2660,7 @@ func _apply_grass_sand_smoothing() -> bool: if replacement_count >= maximum_smoothing_placements: break var index := int(candidate["index"]) + @warning_ignore("integer_division") var coordinate := Vector2i(index % grid_size.x, index / grid_size.x) var diagonal := candidate["variant"] as TerrainChunkVariant if ( @@ -3260,6 +3275,7 @@ func _primary_elevated_top_coordinate() -> Vector2i: if _coordinate_has_primary_elevated_top(_elevated_feature_center): return _elevated_feature_center for index: int in _placements.size(): + @warning_ignore("integer_division") var coordinate := Vector2i(index % grid_size.x, index / grid_size.x) if not _coordinate_has_primary_elevated_top(coordinate): continue @@ -3635,11 +3651,11 @@ func _weighted_candidate_order( candidate.definition, ): weight *= long_repeat_weight_multiplier + @warning_ignore("integer_division") + var coordinate := Vector2i(index % grid_size.x, index / grid_size.x) if ( candidate.definition.prefers_map_boundary - and _coordinate_is_on_boundary( - Vector2i(index % grid_size.x, index / grid_size.x) - ) + and _coordinate_is_on_boundary(coordinate) ): weight *= boundary_preference_multiplier remaining.append(candidate) @@ -3669,6 +3685,7 @@ func _adjacent_definition_repeat_count( index: int, definition: TerrainChunkDefinition, ) -> int: + @warning_ignore("integer_division") var coordinate := Vector2i(index % grid_size.x, index / grid_size.x) var count := 0 for edge_value: int in TerrainChunkTopology.Edge.values(): @@ -3690,6 +3707,7 @@ func _preferred_neighbor_count( index: int, definition: TerrainChunkDefinition, ) -> int: + @warning_ignore("integer_division") var coordinate := Vector2i(index % grid_size.x, index / grid_size.x) var count := 0 for edge_value: int in TerrainChunkTopology.Edge.values(): @@ -3711,6 +3729,7 @@ func _long_definition_run_count( index: int, definition: TerrainChunkDefinition, ) -> int: + @warning_ignore("integer_division") var coordinate := Vector2i(index % grid_size.x, index / grid_size.x) var count := 0 for axis: Vector2i in [Vector2i.RIGHT, Vector2i.DOWN]: @@ -3762,6 +3781,7 @@ func _neighbor_requirement_validation_error( var placement := layout[index] if placement == null or placement.definition.minimum_required_neighbors <= 0: continue + @warning_ignore("integer_division") var coordinate := Vector2i(index % grid_size.x, index / grid_size.x) var matching_neighbors := 0 for edge_value: int in TerrainChunkTopology.Edge.values(): @@ -3809,6 +3829,7 @@ func _walkable_connectivity_validation_error( return "" var start_index := walkable_indices[0] if force_center_chunk_id != &"": + @warning_ignore("integer_division") var center := Vector2i(grid_size.x / 2, grid_size.y / 2) var center_index := center.y * grid_size.x + center.x if ( @@ -3822,6 +3843,7 @@ func _walkable_connectivity_validation_error( while pending_cursor < pending.size(): var index := pending[pending_cursor] pending_cursor += 1 + @warning_ignore("integer_division") var coordinate := Vector2i(index % grid_size.x, index / grid_size.x) var current := layout[index] for edge_value: int in TerrainChunkTopology.Edge.values(): @@ -3892,6 +3914,7 @@ func _least_repeated_equivalent_rotation( definition: TerrainChunkDefinition, rotations: PackedInt32Array, ) -> int: + @warning_ignore("integer_division") var coordinate := Vector2i(index % grid_size.x, index / grid_size.x) var best_score := 3 var best_rotations := PackedInt32Array() @@ -3944,6 +3967,7 @@ func _build_solution_root() -> Node3D: var variant := _placements[index] if variant == null: continue + @warning_ignore("integer_division") var coordinate := Vector2i(index % grid_size.x, index / grid_size.x) var chunk_root := _instantiate_chunk_root(variant.definition) if chunk_root == null: @@ -4325,6 +4349,7 @@ func _count_adjacent_repeat_edges() -> int: var current := _placements[index] if current == null: continue + @warning_ignore("integer_division") var coordinate := Vector2i(index % grid_size.x, index / grid_size.x) if coordinate.x > 0: var west := _placements[index - 1] @@ -4383,6 +4408,7 @@ func placement_records() -> Array[Dictionary]: var variant: TerrainChunkVariant = _placements[index] if variant == null: continue + @warning_ignore("integer_division") var coordinate := Vector2i(index % grid_size.x, index / grid_size.x) result.append({ "coordinate": coordinate, @@ -4414,7 +4440,7 @@ func chunk_position(coordinate: Vector2i) -> Vector3: ) -func coordinate_for_local_position(position: Vector3) -> Vector2i: +func coordinate_for_local_position(local_position: Vector3) -> Vector2i: if catalog == null or catalog.chunk_size <= 0.0: return Vector2i(-1, -1) var half_grid := Vector2( @@ -4422,8 +4448,8 @@ func coordinate_for_local_position(position: Vector3) -> Vector2i: float(grid_size.y - 1) * 0.5, ) return Vector2i( - roundi(position.x / catalog.chunk_size + half_grid.x), - roundi(position.z / catalog.chunk_size + half_grid.y), + roundi(local_position.x / catalog.chunk_size + half_grid.x), + roundi(local_position.z / catalog.chunk_size + half_grid.y), ) diff --git a/world/test_world.gd b/world/test_world.gd index 2e25206..9d81d4d 100644 --- a/world/test_world.gd +++ b/world/test_world.gd @@ -196,36 +196,36 @@ func get_saltwater_surface_height() -> float: ) -func is_home_exterior_position_accessible(position: Vector3) -> bool: +func is_home_exterior_position_accessible(world_position: Vector3) -> bool: return ( _active_region != null - and _active_region.is_home_exterior_position_accessible(position) + and _active_region.is_home_exterior_position_accessible(world_position) ) -func activate_world(layout: StringName, seed: int) -> bool: +func activate_world(layout: StringName, world_seed: int) -> bool: if ( not WorldLayoutType.is_valid(layout) - or seed <= 0 - or seed > PlayerSaveManager.MAX_WORLD_SEED + or world_seed <= 0 + or world_seed > PlayerSaveManager.MAX_WORLD_SEED ): return false if _active_region == null or _active_region.region_id != layout: - if not _replace_active_region(layout, seed): + if not _replace_active_region(layout, world_seed): return false _world_layout = layout - _world_seed = seed + _world_seed = world_seed if layout == WorldLayoutType.GENERATED: if not _active_region.has_method("generate_world"): return false - if not bool(_active_region.call("generate_world", seed)): + if not bool(_active_region.call("generate_world", world_seed)): return false _configure_world_coverage() return true -func generate_world(seed: int) -> bool: - return activate_world(WorldLayoutType.GENERATED, seed) +func generate_world(world_seed: int) -> bool: + return activate_world(WorldLayoutType.GENERATED, world_seed) func get_world_layout() -> StringName: @@ -293,7 +293,7 @@ func _find_active_region() -> WorldRegion: return null -func _replace_active_region(layout: StringName, seed: int) -> bool: +func _replace_active_region(layout: StringName, world_seed: int) -> bool: var region_scene: PackedScene = ( GENERATED_REGION_SCENE if layout == WorldLayoutType.GENERATED @@ -303,7 +303,7 @@ func _replace_active_region(layout: StringName, seed: int) -> bool: if replacement == null: return false if layout == WorldLayoutType.GENERATED: - replacement.set("initial_seed", seed) + replacement.set("initial_seed", world_seed) if _active_region != null: _regions_root.remove_child(_active_region) _active_region.free() @@ -345,8 +345,8 @@ func _configure_world_coverage() -> void: coverage_shape.size = Vector3(half.x * 2.0, 4.0, half.y * 2.0) -func _set_bound(body: Node3D, position: Vector3, size: Vector3) -> void: - body.position = position +func _set_bound(body: Node3D, world_position: Vector3, size: Vector3) -> void: + body.position = world_position var collision := body.get_node("Shape") as CollisionShape3D var shape := collision.shape as BoxShape3D if shape != null: diff --git a/world/water_recovery_controller.gd b/world/water_recovery_controller.gd index 0d0c5aa..4e190d8 100644 --- a/world/water_recovery_controller.gd +++ b/world/water_recovery_controller.gd @@ -78,6 +78,11 @@ func update_world_context( safe_points: Array[SafeRespawnPoint], recovery_position_resolver: Callable = Callable(), ) -> void: + # A world change replaces the trigger set and recovery resolver. Never let + # an in-flight recovery retain its old-world input lock or fade callback. + # The caller may be changing worlds while gameplay remains active, so this + # variant restores the control state that recovery itself captured. + _abort_active_recovery(true) for water_trigger: PlayerWaterTrigger in _water_triggers: if ( water_trigger != null @@ -117,7 +122,15 @@ func _process(delta: float) -> void: func set_recovery_enabled(enabled: bool) -> void: + if _recovery_enabled == enabled: + return _recovery_enabled = enabled + if not enabled: + # Gameplay/session teardown can happen while the screen is fading. The + # normal finish callback would then never be allowed to leave the fishing + # input gate, which makes the next session appear unable to fish. Preserve + # the caller's disabled controls while releasing recovery-owned state. + _abort_active_recovery(false) func is_recovery_active() -> bool: @@ -125,15 +138,7 @@ func is_recovery_active() -> bool: func _exit_tree() -> void: - _generation += 1 - if _screen_fade != null and is_instance_valid(_screen_fade): - _screen_fade.reset_immediately() - if _player != null and is_instance_valid(_player): - _player.restore_gameplay_orientation_after_recovery() - _player.set_water_recovery_active(false) - _player.set_movement_enabled(_prior_movement_enabled) - _player.set_camera_input_enabled(_prior_camera_input_enabled) - state = RecoveryState.IDLE + _abort_active_recovery(true) func _on_recovery_requested( @@ -225,3 +230,26 @@ func _finish_recovery() -> void: state = RecoveryState.IDLE _bob_elapsed = 0.0 recovery_finished.emit() + + +func _abort_active_recovery(restore_controls: bool) -> void: + if state == RecoveryState.IDLE: + return + # Invalidate every in-flight ScreenFade completion before returning control. + # This makes cancellation safe whether recovery was bobbing, fading out, or + # fading in. + _generation += 1 + if _screen_fade != null and is_instance_valid(_screen_fade): + _screen_fade.reset_immediately() + if _player != null and is_instance_valid(_player): + _player.restore_gameplay_orientation_after_recovery() + _player.set_water_recovery_active(false) + _player.velocity = Vector3.ZERO + if restore_controls: + _player.set_movement_enabled(_prior_movement_enabled) + _player.set_camera_input_enabled(_prior_camera_input_enabled) + if _fishing_spot != null and is_instance_valid(_fishing_spot): + _fishing_spot.end_water_recovery() + state = RecoveryState.IDLE + _bob_elapsed = 0.0 + recovery_finished.emit() diff --git a/world/world_time_service.gd b/world/world_time_service.gd index b521c7f..56ef0d8 100644 --- a/world/world_time_service.gd +++ b/world/world_time_service.gd @@ -381,8 +381,8 @@ func _set_remote_datetime(time_hours: float, date_id: String) -> void: "year": int(date_id.substr(0, 4)), "month": int(date_id.substr(5, 2)), "day": int(date_id.substr(8, 2)), - "hour": total_seconds / 3600, - "minute": (total_seconds / 60) % 60, + "hour": int(float(total_seconds) / 3600.0), + "minute": int(float(total_seconds) / 60.0) % 60, "second": total_seconds % 60, } _set_calendar_date_id(date_id) @@ -432,11 +432,14 @@ static func _is_leap_year(year: int) -> bool: static func _weekday_for_date(year: int, month: int, day: int) -> int: var adjusted_year: int = year - 1 if month < 3 else year + var leap_years: int = int(float(adjusted_year) / 4.0) + var skipped_centuries: int = int(float(adjusted_year) / 100.0) + var restored_quadricentennials: int = int(float(adjusted_year) / 400.0) return posmod( adjusted_year - + adjusted_year / 4 - - adjusted_year / 100 - + adjusted_year / 400 + + leap_years + - skipped_centuries + + restored_quadricentennials + WEEKDAY_MONTH_OFFSETS[month - 1] + day, 7,