diff --git a/collection/collection_log.gd b/collection/collection_log.gd index da36eb8..59d33d1 100644 --- a/collection/collection_log.gd +++ b/collection/collection_log.gd @@ -6,9 +6,11 @@ signal fish_quality_discovered(fish_id: StringName, quality: int) signal collection_changed const FishQualityType = preload("res://fish/fish_quality.gd") +const MAX_CATCH_COUNT: int = 1000000000 var _discovered: Dictionary[StringName, bool] = {} var _quality_masks: Dictionary[StringName, int] = {} +var _catch_counts: Dictionary[StringName, int] = {} func has_discovered(fish_id: StringName) -> bool: @@ -41,6 +43,26 @@ func mark_quality_discovered(fish_id: StringName, quality: int) -> void: collection_changed.emit() +func record_catch(fish_id: StringName, quality: int) -> void: + if fish_id.is_empty() or not FishQualityType.is_valid(quality): + return + var species_was_discovered: bool = has_discovered(fish_id) + if not species_was_discovered: + _discovered[fish_id] = true + var previous_mask: int = _quality_masks.get(fish_id, 0) + var next_mask: int = previous_mask | FishQualityType.bit_for(quality) + _quality_masks[fish_id] = next_mask + _catch_counts[fish_id] = mini( + int(_catch_counts.get(fish_id, 0)) + 1, + MAX_CATCH_COUNT, + ) + if not species_was_discovered: + fish_discovered.emit(fish_id) + if next_mask != previous_mask: + fish_quality_discovered.emit(fish_id, quality) + collection_changed.emit() + + func get_discovered_ids() -> Array[StringName]: var discovered_ids: Array[StringName] = [] for fish_id: StringName in _discovered: @@ -65,6 +87,14 @@ func get_discovered_quality_masks() -> Dictionary[StringName, int]: return _quality_masks.duplicate() +func get_catch_count(fish_id: StringName) -> int: + return _catch_counts.get(fish_id, 0) if has_discovered(fish_id) else 0 + + +func get_catch_counts() -> Dictionary[StringName, int]: + return _catch_counts.duplicate() + + func has_mastered(fish_id: StringName) -> bool: return get_quality_mask(fish_id) == FishQualityType.ALL_TIERS_MASK @@ -77,6 +107,22 @@ func replace_discovered_ids(fish_ids: Array[StringName]) -> bool: func replace_discovery_state( fish_ids: Array[StringName], quality_masks: Dictionary[StringName, int], +) -> bool: + var preserved_counts: Dictionary[StringName, int] = {} + for fish_id: StringName in fish_ids: + if _catch_counts.has(fish_id): + preserved_counts[fish_id] = _catch_counts[fish_id] + return replace_collection_state( + fish_ids, + quality_masks, + preserved_counts, + ) + + +func replace_collection_state( + fish_ids: Array[StringName], + quality_masks: Dictionary[StringName, int], + catch_counts: Dictionary[StringName, int], ) -> bool: var replacement: Dictionary[StringName, bool] = {} for fish_id: StringName in fish_ids: @@ -95,7 +141,19 @@ func replace_discovery_state( return false if mask != 0: replacement_masks[fish_id] = mask + var replacement_counts: Dictionary[StringName, int] = {} + for fish_id: StringName in catch_counts: + var count: int = catch_counts[fish_id] + if ( + fish_id.is_empty() + or not replacement.has(fish_id) + or count <= 0 + or count > MAX_CATCH_COUNT + ): + return false + replacement_counts[fish_id] = count _discovered = replacement _quality_masks = replacement_masks + _catch_counts = replacement_counts collection_changed.emit() return true diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 6e19e1c..9fbd5b9 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -46,6 +46,16 @@ traffic or become a gameplay authority. Its base URL comes from `network/discovery/base_url`, with `NETFISHING_DISCOVERY_URL` available as a development/deployment override. +Friendships are local identity relationships shared across save slots. A live, +authenticated gameplay session is required to exchange the directional +capabilities that establish a friendship. Blocking an identity removes that +friendship; unblocking does not recreate it. Discovery may publish opt-in, +short-lived friend presence and deliver invitations only while both games are +online. It stores hashed capability identifiers rather than identity +fingerprints or a complete friend graph, keeps no durable social records, and +provides no offline delivery. Direct joins still use the existing verified +public-room and ENet connection path. + The host is authoritative. Clients submit requests or evidence; the host derives trusted context from registered peers, authoritative regions, and server-owned state before mutating inventory, wallet, progression, or shared @@ -68,8 +78,12 @@ release version is not a reason to change the protocol number. `PlayerDataRoot` selects and validates a portable data root. Stores receive paths from that owner rather than inventing unrelated locations. Progression is -written by `PlayerSaveManager`; device settings and social/identity stores have -separate formats and lifecycles. +written by `PlayerSaveManager` and indexed as named slots by +`PlayerSaveSlotCatalog`. Existing single-save installations are adopted as the +first slot in place. Device settings, appearance, and social/identity stores +remain shared across slots and have separate formats and lifecycles. +Progression archive import and export belong to the title-screen Play page: +imports create a new slot, while exports copy only the selected progression. Save migrations are sequential and explicit. Existing catches and ownership are keyed by stable IDs so authored metadata can evolve without rewriting diff --git a/docs/README-PLAYTEST.txt b/docs/README-PLAYTEST.txt index ca32217..6b8fb4d 100644 --- a/docs/README-PLAYTEST.txt +++ b/docs/README-PLAYTEST.txt @@ -33,6 +33,7 @@ Rotate camera Hold right mouse, or use right stick Cycle active hotbar slot Mouse wheel or D-pad left/right Select hotbar slot 1 through 9 Zoom camera Shift + mouse wheel + (swap wheel controls in Settings > Controls) Primary tool / fishing Left mouse or right trigger Inventory and player pages Tab Game Menu / back Escape diff --git a/drawing/surface_drawing_protocol.gd b/drawing/surface_drawing_protocol.gd index 8fdb054..ff132f8 100644 --- a/drawing/surface_drawing_protocol.gd +++ b/drawing/surface_drawing_protocol.gd @@ -2,7 +2,7 @@ class_name SurfaceDrawingProtocol extends RefCounted const CAPABILITY: StringName = &"surface_drawing_v2" -const RELIABLE_CHANNEL: int = NetworkProtocol.ITEM_RELIABLE_CHANNEL +const RELIABLE_CHANNEL: int = NetworkProtocol.DRAWING_RELIABLE_CHANNEL const GRID_SIZES: Array[int] = [16, 32, 64, 128] const DEFAULT_GRID_SIZE: int = 16 const MAX_GRID_SIZE: int = 128 diff --git a/fishing/catch_controller.gd b/fishing/catch_controller.gd index 7e75370..12cc431 100644 --- a/fishing/catch_controller.gd +++ b/fishing/catch_controller.gd @@ -46,7 +46,7 @@ class Barrier: # Fight movement uses one shared baseline. Species and quality difficulty comes # from barrier placement and health, while reel upgrades affect only the # player's progress speed. -const CHASE_START_DELAY: float = 1.0 +const CHASE_START_DELAY: float = 1.5 const CHASE_START_OFFSET: float = 0.04 const CHASE_SPEED: float = 0.07 diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index a1c8ddf..0fdc5f7 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -68,6 +68,7 @@ signal showcase_changed( ) signal bite_activated signal bite_prompt_changed(is_visible: bool) +signal fishing_input_priority_changed(active: bool) signal ready_for_equipment_refresh enum FishingState { @@ -182,6 +183,7 @@ var _network_active_barrier_index: int = -1 var _bite_rng: RandomNumberGenerator = RandomNumberGenerator.new() var _pending_cleanup_message: String = "" var _bite_confirmation_pending: bool = false +var _fishing_input_priority_active: bool = false var _bite_confirmation_requested: bool = false @@ -458,6 +460,7 @@ func _secure_showcase_catch_for_recovery() -> void: func _exit_tree() -> void: _stop_fight_audio() _stop_reeling_audio() + _set_fishing_input_priority(false) _showcase_restore_generation += 1 if ( state == FishingState.SHOWING_CATCH @@ -777,6 +780,8 @@ func confirm_pending_bite() -> void: func _set_bite_confirmation_pending(is_pending: bool) -> void: + if is_pending: + _set_fishing_input_priority(true) if _bite_confirmation_pending == is_pending: if not is_pending: _bite_confirmation_requested = false @@ -786,6 +791,17 @@ func _set_bite_confirmation_pending(is_pending: bool) -> void: bite_prompt_changed.emit(is_pending) +func _set_fishing_input_priority(active: bool) -> void: + if _fishing_input_priority_active == active: + return + _fishing_input_priority_active = active + fishing_input_priority_changed.emit(active) + + +func is_fishing_input_priority_active() -> bool: + return _fishing_input_priority_active + + func is_returning() -> bool: return state == FishingState.RETURNING @@ -1126,6 +1142,7 @@ func _activate_bite(confirmation_override: bool = false) -> void: ): _cancel_attempt() return + _set_fishing_input_priority(true) if ( not confirmation_override and _active_lure_has_effect(&"deferred_fight") @@ -1346,7 +1363,7 @@ func _store_catch_progression(fish_catch: FishCatchType) -> void: ) ) _local_inventory.add_catch(fish_catch) - _local_collection_log.mark_quality_discovered( + _local_collection_log.record_catch( fish_catch.fish_id, fish_catch.quality, ) @@ -1414,6 +1431,7 @@ func _finalize_attempt_cleanup( cooldown_message: String, restore_movement: bool = true, ) -> void: + _set_bite_confirmation_pending(false) _showcase_restore_generation += 1 if _active_player != null: _active_player.set_fighting_visual(false) @@ -1458,6 +1476,7 @@ func _finalize_attempt_cleanup( _state_time_remaining = cooldown_duration _cooldown_status = cooldown_message status_changed.emit(_cooldown_status) + _set_fishing_input_priority(false) func _return_to_ready() -> void: @@ -1466,6 +1485,7 @@ func _return_to_ready() -> void: _cooldown_status = "" status_changed.emit("") ready_for_equipment_refresh.emit() + _set_fishing_input_priority(false) func _cancel_attempt() -> void: @@ -1775,6 +1795,7 @@ func _on_network_bite_started(_attempt_id: String) -> void: if state != FishingState.WAITING_FOR_BITE: return _stop_reeling_audio() + _set_fishing_input_priority(true) _set_bite_confirmation_pending(false) state = FishingState.FIGHTING if _active_player != null: diff --git a/fishing/remote_fishing_presentation.gd b/fishing/remote_fishing_presentation.gd index 06224de..380afdf 100644 --- a/fishing/remote_fishing_presentation.gd +++ b/fishing/remote_fishing_presentation.gd @@ -18,6 +18,8 @@ var _showcase_tween: Tween var _return_showcase_catch: FishCatch var _bobber_idle_elapsed: float = 0.0 var _bobber_base_scale: Vector3 = Vector3.ONE +var _attempt_id: String = "" +var _observer_fighting_active: bool = false func setup(owning_player: Player) -> void: @@ -43,7 +45,11 @@ func setup(owning_player: Player) -> void: cleanup() -func show_cast(origin: Vector3, target: Vector3) -> void: +func show_cast( + origin: Vector3, + target: Vector3, + attempt_id: String = "", +) -> void: if ( _owner == null or not origin.is_finite() @@ -52,6 +58,8 @@ func show_cast(origin: Vector3, target: Vector3) -> void: return _kill_cast_tween() _active = true + _attempt_id = attempt_id + _observer_fighting_active = false _bobber_base_scale = Vector3.ONE * _owner.get_character_visual_scale() _target = origin _pending_target = target @@ -86,10 +94,48 @@ func update_bobber(world_position: Vector3) -> void: _redraw_line() +func synchronize_active( + attempt_id: String, + world_position: Vector3, + fighting: bool, +) -> void: + if ( + _owner == null + or attempt_id.is_empty() + or not world_position.is_finite() + ): + return + if _active and not _attempt_id.is_empty() and _attempt_id != attempt_id: + cleanup() + if _attempt_id.is_empty(): + _attempt_id = attempt_id + if not _active: + _active = true + _bobber_base_scale = Vector3.ONE * _owner.get_character_visual_scale() + _target = world_position + _pending_target = world_position + _bobber_idle_elapsed = 0.0 + _bobber.global_position = world_position + _bobber.scale = _bobber_base_scale + _bobber.visible = true + _line.visible = true + _owner.set_active_item_is_rod(true) + _owner.set_fishing_visual(true) + _redraw_line() + else: + update_bobber(world_position) + if fighting != _observer_fighting_active: + _observer_fighting_active = fighting + _owner.set_fighting_visual(fighting) + if not fighting: + _owner.set_fishing_visual(true) + + func show_bite() -> void: if not _active: return if _owner != null and is_instance_valid(_owner): + _observer_fighting_active = true _owner.set_fighting_visual(true) var tween: Tween = create_tween() tween.tween_property(_bobber, "scale", _bobber_base_scale * 0.7, 0.08) @@ -131,6 +177,8 @@ func cleanup() -> void: _kill_return_tween() _kill_showcase_tween() _active = false + _attempt_id = "" + _observer_fighting_active = false _pending_target = Vector3.ZERO _return_showcase_catch = null _bobber_idle_elapsed = 0.0 diff --git a/main/main.gd b/main/main.gd index 5e3a097..1724bd9 100644 --- a/main/main.gd +++ b/main/main.gd @@ -13,6 +13,9 @@ const WaterRecoveryControllerType = preload( const PlayerSaveManagerType = preload( "res://save/player_save_manager.gd" ) +const PlayerSaveSlotCatalogType = preload( + "res://save/player_save_slot_catalog.gd" +) const PlayerSettingsManagerType = preload( "res://settings/player_settings_manager.gd" ) @@ -131,6 +134,8 @@ const TIME_CROSSING_EPSILON_HOURS: float = 0.000001 const PLAYER_MENU_PATTERN_SCALE: float = 0.85 const PLAYER_MENU_PATTERN_SCROLL_VELOCITY := Vector2(-7.0, -5.0) const SHOP_PATTERN_SCALE: float = 1.75 +const DEDICATED_IDLE_PHYSICS_TICKS_PER_SECOND: int = 10 +const DEDICATED_ACTIVE_PHYSICS_TICKS_PER_SECOND: int = 30 @export var fish_catalog: FishPoolType @export var pelican_buyer_profile: FishBuyerProfileType @@ -234,6 +239,7 @@ const SHOP_PATTERN_SCALE: float = 1.75 @onready var _shop_backdrop: ColorRect = %ShopBackdrop var _gameplay_started: bool = false +var _save_slots := PlayerSaveSlotCatalogType.new() var _shop_interaction: FishingShopInteractionType var _storage_interaction: PlayerStorageInteractionType var _title_music_tween: Tween @@ -246,6 +252,10 @@ var _join_requested_from_pause: bool = false var _player_menu_backdrop_tween: Tween var _shop_backdrop_tween: Tween var _pending_join_endpoint: String = "" +var _social_prompt_queue: Array[Dictionary] = [] +var _active_social_prompt: Dictionary = {} +var _pending_social_invite_join: bool = false +var _pending_social_invite_gameplay: bool = false var _server_trust_dialog: ConfirmationDialog var _pending_trust_changed: bool = false var _identity_notice_dialog: AcceptDialog @@ -260,6 +270,7 @@ var _performance_profile: RuntimePerformanceProfileType var _pending_existing_root_path := "" var _local_recovery_attempt_id: String = "" var _dedicated_runtime: bool = false +var _dedicated_metrics_accumulator: float = 0.0 @onready var _shoreline_ambience: ShorelineAmbience = %ShorelineAmbience var _rain_ambience: RainAmbienceType @@ -269,6 +280,9 @@ func _ready() -> void: _performance_profile = RuntimePerformanceProfileType.from_environment() _dedicated_runtime = _is_dedicated_server_runtime() if _dedicated_runtime: + Engine.max_fps = 30 + Engine.physics_ticks_per_second = 30 + _disable_dedicated_presentation_tree() call_deferred("_start_dedicated_server") return _world_pixelation.set_light_performance_profile( @@ -382,6 +396,74 @@ func _is_dedicated_server_runtime() -> bool: return "--dedicated-server" in OS.get_cmdline_user_args() +func _disable_dedicated_presentation_tree() -> void: + for path: NodePath in [ + NodePath("TitleBackgroundLayer"), + NodePath("PlayerMenuBackdrop"), + NodePath("ShopBackdrop"), + NodePath("WorldPixelationPostprocess"), + NodePath("UIPresentation"), + NodePath("PixelationResetOverlay"), + NodePath("WorldTimeVisualController"), + NodePath("GatheringController"), + NodePath("FishingSpot"), + NodePath("WaterRecovery"), + NodePath("ControllerMappingManager"), + NodePath("KeyboardMouseMappingManager"), + ]: + var presentation_node: Node = get_node_or_null(path) + if presentation_node == null: + continue + presentation_node.process_mode = Node.PROCESS_MODE_DISABLED + if presentation_node is CanvasItem: + (presentation_node as CanvasItem).visible = false + elif presentation_node is CanvasLayer: + (presentation_node as CanvasLayer).visible = false + for audio_path: NodePath in [ + NodePath("TitleMusic"), + NodePath("NewGameMusic"), + NodePath("DuskMusic"), + NodePath("ShorelineAmbience"), + ]: + var audio_node: Node = get_node_or_null(audio_path) + if audio_node == null: + continue + if audio_node.has_method("stop"): + audio_node.call("stop") + audio_node.process_mode = Node.PROCESS_MODE_DISABLED + + +func _print_dedicated_metrics() -> void: + var metrics: Dictionary = { + "players": _network_session.get_player_count(), + "fps": Engine.get_frames_per_second(), + "frame_process_ms": Performance.get_monitor( + Performance.TIME_PROCESS + ) * 1000.0, + "frame_physics_ms": Performance.get_monitor( + Performance.TIME_PHYSICS_PROCESS + ) * 1000.0, + "memory_mib": Performance.get_monitor( + Performance.MEMORY_STATIC + ) / (1024.0 * 1024.0), + "nodes": int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT)), + "physics_active_objects": int(Performance.get_monitor( + Performance.PHYSICS_3D_ACTIVE_OBJECTS + )), + "physics_collision_pairs": int(Performance.get_monitor( + Performance.PHYSICS_3D_COLLISION_PAIRS + )), + "movement": _network_session.get_movement_metrics(), + } + if _network_fishing.has_method("get_network_metrics"): + metrics["fishing"] = _network_fishing.call("get_network_metrics") + if _network_world_spawns.has_method("get_network_metrics"): + metrics["gatherables"] = _network_world_spawns.call( + "get_network_metrics" + ) + print("NETfishing server metrics: %s" % JSON.stringify(metrics)) + + func _start_dedicated_server() -> void: var config: DedicatedServerConfigType = DedicatedServerConfigType.from_runtime() if not config.is_valid(): @@ -429,6 +511,7 @@ func _start_dedicated_server() -> void: ): _fail_dedicated_server("The configured world seed could not be generated.") return + _test_world.set_dedicated_simulation(true) if not _network_session.set_host_world( WorldLayoutType.GENERATED, config.world_seed, @@ -441,10 +524,17 @@ func _start_dedicated_server() -> void: return _initialize_application(true) _player.set_local_control(false) + _player.set_network_simulation_only(true) _player.visible = false _player.collision_layer = 0 _player.collision_mask = 0 _player.set_physics_process(false) + if not _network_session.peer_count_changed.is_connected( + _on_dedicated_peer_count_changed + ): + _network_session.peer_count_changed.connect( + _on_dedicated_peer_count_changed + ) if not _network_session.start_dedicated_host( config.port, config.max_players, @@ -456,6 +546,10 @@ func _start_dedicated_server() -> void: if not _network_session.set_host_open(true): _fail_dedicated_server("Could not open the dedicated server.") return + _on_dedicated_peer_count_changed( + _network_session.get_player_count(), + config.max_players, + ) _player_jobs.begin_progression_session() if config.public_listing and not _discovery.set_discoverable(true): _network_session.disconnect_session("Discovery setup failed.") @@ -478,15 +572,34 @@ func _start_dedicated_server() -> void: ) +func _on_dedicated_peer_count_changed( + player_count: int, + _max_players: int, +) -> void: + if not _dedicated_runtime: + return + Engine.physics_ticks_per_second = ( + DEDICATED_ACTIVE_PHYSICS_TICKS_PER_SECOND + if player_count > 0 + else DEDICATED_IDLE_PHYSICS_TICKS_PER_SECOND + ) + + func _fail_dedicated_server(message: String) -> void: push_error("Dedicated server startup failed: %s" % message) get_tree().quit(1) func _configure_portable_stores() -> void: - _save_manager.configure_storage( - _data_root.path_for(&"player_save"), _data_root - ) + if _dedicated_runtime: + _save_manager.configure_storage( + _data_root.path_for(&"player_save"), _data_root + ) + elif not _save_slots.configure(_data_root, _save_manager): + push_error( + "Save-slot catalog setup failed: %s" + % _save_slots.get_error_message() + ) _network_profile.configure_storage( _data_root.path_for(&"network_profile"), _data_root ) @@ -523,7 +636,8 @@ func _initialize_application(dedicated: bool) -> void: _player_spawn_service.setup( _players_root, _player, - _test_world.get_player_spawn_transform() + _test_world.get_player_spawn_transform(), + dedicated, ) _network_session.setup( _network_profile, @@ -536,7 +650,7 @@ func _initialize_application(dedicated: bool) -> void: _host_bans, dedicated, ) - _discovery.setup(_network_session) + _discovery.setup(_network_session, _relationships) if not dedicated: _world_time_visuals.setup( _world_time, @@ -699,6 +813,7 @@ func _initialize_application(dedicated: bool) -> void: _save_manager, _network_item_use, _world_time, + not dedicated, ) _gathering_controller.setup( _player, @@ -747,7 +862,8 @@ func _initialize_application(dedicated: bool) -> void: _save_manager, item_catalog, fish_catalog, - _network_item_use + _network_item_use, + not dedicated, ) var sale_buyers: Array[FishBuyerProfileType] = [ pelican_buyer_profile, @@ -850,7 +966,6 @@ func _initialize_application(dedicated: bool) -> void: ) _game_ui.setup_data_and_identity( _data_root, - _save_manager, _identity_backups, _player_identity, _host_identity, @@ -860,6 +975,18 @@ func _initialize_application(dedicated: bool) -> void: _game_ui.setup_controller_mapping(_controller_mapping_manager) _ui_pixelation.setup_controller_mapping(_controller_mapping_manager) _game_ui.setup_keyboard_mouse_mapping(_keyboard_mouse_mapping_manager) + _network_player_list.friend_request_received.connect( + _on_friend_request_received + ) + _discovery.friend_invite_received.connect(_on_friend_invite_received) + _discovery.public_join_prepared.connect( + _on_social_public_join_prepared + ) + _discovery.public_join_status_changed.connect( + _on_social_public_join_status_changed + ) + _game_ui.social_prompt_accepted.connect(_on_social_prompt_accepted) + _game_ui.social_prompt_declined.connect(_on_social_prompt_declined) _data_root.conflict_detected.connect(_on_portable_conflict) if ( PortableFileGuard.has_syncthing_conflict( @@ -909,16 +1036,19 @@ func _initialize_application(dedicated: bool) -> void: _apply_runtime_settings(_settings_manager.current_settings) _set_gameplay_active(false) var title_screen: TitleScreenType = _game_ui.get_title_screen() - title_screen.new_game_requested.connect(_on_new_game_requested) - title_screen.continue_game_requested.connect(_on_continue_game_requested) + title_screen.slot_play_requested.connect(_on_slot_play_requested) + title_screen.new_slot_requested.connect(_on_new_slot_requested) title_screen.quit_requested.connect(_on_quit_requested) title_screen.setup( _save_manager, + _save_slots, _settings_manager, _network_session, _saved_servers, _server_trust, _discovery, + _data_root, + _interface_fonts, ) var pause_menu: PauseMenuType = _game_ui.get_pause_menu() pause_menu.setup( @@ -1454,8 +1584,15 @@ func _unhandled_input(event: InputEvent) -> void: get_viewport().set_input_as_handled() -func _process(_delta: float) -> void: +func _process(delta: float) -> void: if _dedicated_runtime: + _dedicated_metrics_accumulator += delta + if _dedicated_metrics_accumulator >= 60.0: + _dedicated_metrics_accumulator = fmod( + _dedicated_metrics_accumulator, + 60.0, + ) + _print_dedicated_metrics() return var show_shop_prompt := _can_show_shop_prompt() var shop_prompt_anchor := ( @@ -1500,7 +1637,8 @@ func _apply_runtime_settings(settings: PlayerSettingsType) -> void: _player.apply_camera_settings( settings.mouse_camera_sensitivity, settings.controller_camera_sensitivity, - settings.invert_camera_y + settings.invert_camera_y, + settings.swap_hotbar_camera_scroll, ) _fishing_spot.configure_accessibility_auto_click( settings.auto_click_enabled, @@ -1760,6 +1898,97 @@ func _on_continue_game_requested() -> void: _enter_gameplay() +func _on_slot_play_requested(slot_id: String) -> void: + if _gameplay_started or _quit_in_progress: + return + if not _save_slots.activate_slot(slot_id): + _game_ui.get_title_screen().report_network_error( + "The selected save slot could not be opened." + ) + return + if not _save_manager.load_player_data(): + _game_ui.get_title_screen().report_network_error( + "Failed to load that save slot. The original was preserved." + ) + return + var world_layout: StringName = _save_manager.get_world_layout() + var world_seed: int = _save_manager.get_world_seed() + if ( + not _apply_world(world_layout, world_seed, true) + or not _prepare_host_world(world_layout, world_seed) + ): + _game_ui.get_title_screen().report_network_error( + "The saved world could not be loaded. The save was preserved." + ) + return + if not _prepare_private_host(): + return + _save_slots.mark_played(slot_id) + _enter_gameplay() + + +func _on_new_slot_requested( + display_name: String, + world_layout: StringName, + world_seed: int, + duplicate_source_slot_id: String, +) -> void: + if _gameplay_started or _quit_in_progress: + return + var created: Dictionary = ( + _save_slots.duplicate_slot( + duplicate_source_slot_id, + display_name, + world_layout, + world_seed, + ) + if not duplicate_source_slot_id.is_empty() + else _save_slots.create_empty_slot(display_name) + ) + if not bool(created.get("ok", false)): + _game_ui.get_title_screen().report_network_error( + str(created.get("message", "The save slot could not be created.")) + ) + return + var slot_id: String = str(created.get("slot_id", "")) + if not _save_slots.activate_slot(slot_id): + _game_ui.get_title_screen().report_network_error( + "The new save slot could not be selected." + ) + return + if not duplicate_source_slot_id.is_empty(): + _on_slot_play_requested(slot_id) + return + _start_new_game_music() + if not _apply_world(world_layout, world_seed, true): + _show_title_music(true) + _game_ui.get_title_screen().report_network_error( + "Could not prepare that world. Try another seed or the starter island." + ) + return + if not _prepare_host_world(world_layout, world_seed): + _show_title_music(true) + _game_ui.get_title_screen().report_network_error( + "Could not prepare that world for hosting." + ) + return + if not _prepare_private_host(): + _show_title_music(true) + return + if ( + not _save_manager.delete_progression_save() + or not _save_manager.initialize_new_game(world_seed, world_layout) + ): + _network_session.disconnect_session("New Game setup failed.") + _show_title_music(true) + _game_ui.get_title_screen().report_network_error( + "Could not initialize local progression." + ) + return + _save_slots.mark_played(slot_id) + _enter_gameplay() + + func _prepare_private_host() -> bool: _join_requested_from_title = false _join_requested_from_pause = false @@ -1816,6 +2045,12 @@ func _on_return_to_title_requested() -> void: func _on_title_join_game_requested(endpoint: String) -> void: if _quit_in_progress or _gameplay_started: return + var active_slot: Dictionary = _save_slots.ensure_active_slot() + if not bool(active_slot.get("ok", false)): + _game_ui.get_title_screen().report_network_error( + str(active_slot.get("message", "Local progression is unavailable.")) + ) + return if _network_session.state != NetworkSessionType.State.INACTIVE: _network_session.disconnect_session("Preparing direct connection.") _join_requested_from_title = true @@ -1849,6 +2084,135 @@ func _on_pause_join_game_requested(endpoint: String) -> void: ) +func _on_friend_request_received( + request_id: String, + fingerprint: String, + display_name: String, +) -> void: + _queue_social_prompt({ + "kind": "friend_request", + "request_id": request_id, + "fingerprint": fingerprint, + "display_name": display_name, + }) + + +func _on_friend_invite_received( + fingerprint: String, + display_name: String, + room: Dictionary, +) -> void: + if ( + not _relationships.is_friend(fingerprint) + or _relationships.is_blocked(fingerprint) + ): + return + _queue_social_prompt({ + "kind": "friend_invite", + "fingerprint": fingerprint, + "display_name": display_name, + "room": room.duplicate(true), + }) + + +func _queue_social_prompt(details: Dictionary) -> void: + _social_prompt_queue.append(details.duplicate(true)) + _show_next_social_prompt() + + +func _show_next_social_prompt() -> void: + if ( + not _active_social_prompt.is_empty() + or _game_ui.is_social_prompt_open() + or _social_prompt_queue.is_empty() + ): + return + _active_social_prompt = _social_prompt_queue.pop_front() + var kind := str(_active_social_prompt.get("kind", "")) + var display_name := str( + _active_social_prompt.get("display_name", "Player") + ) + var shown: bool = false + if kind == "friend_request": + shown = _game_ui.show_social_prompt( + "%s wants to add you as a friend.\n" % display_name + + "Friendships are saved on this device.", + "accept", + "decline", + ) + elif kind == "friend_invite": + var room: Dictionary = _active_social_prompt.get("room", {}) + var room_name := str(room.get("room_name", "their room")) + shown = _game_ui.show_social_prompt( + "%s invited you to join %s." % [display_name, room_name], + "join", + "not now", + ) + if not shown: + _active_social_prompt.clear() + call_deferred("_show_next_social_prompt") + + +func _on_social_prompt_accepted() -> void: + var details: Dictionary = _active_social_prompt.duplicate(true) + _active_social_prompt.clear() + match str(details.get("kind", "")): + "friend_request": + _network_player_list.respond_friend_request( + str(details.get("request_id", "")), true + ) + "friend_invite": + var room: Dictionary = details.get("room", {}) + _pending_social_invite_join = true + _pending_social_invite_gameplay = _gameplay_started + if room.is_empty() or not _discovery.prepare_public_join(room): + _pending_social_invite_join = false + _report_social_join_error( + "This person needs to be online to do this." + ) + call_deferred("_show_next_social_prompt") + + +func _on_social_prompt_declined() -> void: + var details: Dictionary = _active_social_prompt.duplicate(true) + _active_social_prompt.clear() + if str(details.get("kind", "")) == "friend_request": + _network_player_list.respond_friend_request( + str(details.get("request_id", "")), false + ) + call_deferred("_show_next_social_prompt") + + +func _on_social_public_join_prepared(endpoint: String) -> void: + if not _pending_social_invite_join: + return + var from_gameplay := _pending_social_invite_gameplay + _pending_social_invite_join = false + _pending_social_invite_gameplay = false + if from_gameplay: + _on_pause_join_game_requested(endpoint) + else: + _on_title_join_game_requested(endpoint) + + +func _on_social_public_join_status_changed( + message: String, + is_error: bool, +) -> void: + if not _pending_social_invite_join or not is_error: + return + _pending_social_invite_join = false + _pending_social_invite_gameplay = false + _report_social_join_error(message) + + +func _report_social_join_error(message: String) -> void: + if _gameplay_started: + _game_ui.get_pause_menu().report_network_error(message) + else: + _game_ui.get_title_screen().report_network_error(message) + + func _on_network_join_authenticated() -> void: var connected_endpoint: ConnectionEndpoint = ( _network_session.get_current_endpoint() @@ -1880,6 +2244,7 @@ func _on_network_join_authenticated() -> void: ) _join_requested_from_title = false return + _save_slots.mark_played(_save_slots.get_active_slot_id()) if not _apply_joined_world(server_metadata): return _fade_out_title_music() @@ -2049,6 +2414,7 @@ func _apply_world( _player.velocity = Vector3.ZERO if _application_initialized: _refresh_active_world_bindings() + _network_world_spawns.refresh_world_context() if _application_initialized and not _dedicated_runtime: _water_recovery.update_world_context( spawn_transform, diff --git a/network/discovery_client.gd b/network/discovery_client.gd index bf15bbf..31b8054 100644 --- a/network/discovery_client.gd +++ b/network/discovery_client.gd @@ -9,6 +9,15 @@ signal host_state_changed(state: int) signal public_join_prepared(endpoint: String) signal public_join_status_changed(message: String, is_error: bool) signal public_join_state_changed(state: int) +signal friend_presence_updated(friends: Array[Dictionary]) +signal presence_sharing_changed(enabled: bool) +signal social_status_changed(message: String, is_error: bool) +signal friend_invite_received( + fingerprint: String, + display_name: String, + room: Dictionary, +) +signal friend_invite_finished(success: bool, message: String) const BASE_URL_SETTING: String = "network/discovery/base_url" const BASE_URL_ENVIRONMENT: String = "NETFISHING_DISCOVERY_URL" @@ -28,6 +37,7 @@ const TRAVERSAL_PACKET_PREFIX: String = "NETFISHING_TRAVERSAL_V1 " const UPNP_MAPPING_DURATION_SECONDS: int = 3600 const UPNP_RENEW_INTERVAL_SECONDS: float = 2700.0 const UPNP_RETRY_INTERVAL_SECONDS: float = 300.0 +const SOCIAL_POLL_INTERVAL_SECONDS: float = 5.0 enum HostState { UNAVAILABLE, @@ -55,6 +65,7 @@ enum HostRequestKind { } var _session: NetworkSession +var _relationships: PlayerRelationshipStore var _base_url: String = "" var _room_name: String = DEFAULT_ROOM_NAME var _room_name_uses_default: bool = true @@ -92,6 +103,18 @@ var _upnp_operation_is_renewal: bool = false var _upnp_renew_timer: Timer var _upnp: UPNP var _upnp_mapped_port: int = 0 +var _presence_sharing: bool = false +var _social_timer: Timer +var _presence_request: HTTPRequest +var _presence_query_request: HTTPRequest +var _invitation_poll_request: HTTPRequest +var _invitation_send_request: HTTPRequest +var _friend_presence: Dictionary[String, Dictionary] = {} +var _published_presence_tokens: PackedStringArray = PackedStringArray() +var _pending_presence_online: bool = false +var _pending_presence_tokens: PackedStringArray = PackedStringArray() +var _pending_invite_fingerprint: String = "" +var _joined_public_room_id: String = "" func _ready() -> void: @@ -147,16 +170,48 @@ func _ready() -> void: _join_probe_timer.one_shot = false add_child(_join_probe_timer) _join_probe_timer.timeout.connect(_send_pending_join_probe) + + _presence_request = _make_social_request( + "FriendPresencePublish", _on_presence_request_completed + ) + _presence_query_request = _make_social_request( + "FriendPresenceQuery", _on_presence_query_completed + ) + _invitation_poll_request = _make_social_request( + "FriendInvitationPoll", _on_invitation_poll_completed + ) + _invitation_send_request = _make_social_request( + "FriendInvitationSend", _on_invitation_send_completed + ) + _social_timer = Timer.new() + _social_timer.name = "FriendPresencePoll" + _social_timer.wait_time = SOCIAL_POLL_INTERVAL_SECONDS + _social_timer.one_shot = false + add_child(_social_timer) + _social_timer.timeout.connect(_refresh_social_state) _load_settings() _base_url = _configured_base_url() -func setup(session: NetworkSession) -> void: +func setup( + session: NetworkSession, + relationships: PlayerRelationshipStore = null, +) -> void: _session = session + _relationships = relationships if _room_name_uses_default: _room_name = _default_room_name(_session.get_local_display_name()) _save_settings() _session.set_session_display_name(_room_name) + if ( + _relationships != null + and not _relationships.relationship_changed.is_connected( + _on_social_relationship_changed + ) + ): + _relationships.relationship_changed.connect( + _on_social_relationship_changed + ) if not _session.state_changed.is_connected(_on_session_state_changed): _session.state_changed.connect(_on_session_state_changed) if not _session.peer_count_changed.is_connected(_on_peer_count_changed): @@ -173,6 +228,9 @@ func setup(session: NetworkSession) -> void: else: _set_host_state(HostState.CLOSED) _set_host_status("Open the game before listing it publicly.", false) + if is_configured() and _relationships != null: + _social_timer.start() + call_deferred("_refresh_social_state") func is_configured() -> bool: @@ -183,6 +241,112 @@ func get_base_url() -> String: return _base_url +func is_presence_sharing() -> bool: + return _presence_sharing + + +func set_presence_sharing(enabled: bool) -> bool: + if enabled and (not is_configured() or _relationships == null): + social_status_changed.emit( + "Friend presence is not configured in this build.", true + ) + return false + if _presence_sharing == enabled: + return true + _presence_sharing = enabled + _save_settings() + presence_sharing_changed.emit(enabled) + _refresh_social_state() + return true + + +func get_friend_presence() -> Array[Dictionary]: + var result: Array[Dictionary] = [] + if _relationships == null: + return result + for friend: Dictionary in _relationships.get_friends(): + var fingerprint := str(friend.get("fingerprint", "")) + var live: Dictionary = _friend_presence.get(fingerprint, {}) + result.append({ + "fingerprint": fingerprint, + "display_name": str( + friend.get("last_known_display_name", "Player") + ), + "online": bool(live.get("online", false)), + "room": ( + live.get("room", {}).duplicate(true) + if typeof(live.get("room", {})) == TYPE_DICTIONARY + else {} + ), + }) + result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + if bool(a["online"]) != bool(b["online"]): + return bool(a["online"]) + return str(a["display_name"]).naturalnocasecmp_to( + str(b["display_name"]) + ) < 0 + ) + return result + + +func request_friend_presence() -> bool: + if not is_configured() or _relationships == null: + friend_presence_updated.emit(get_friend_presence()) + return false + _refresh_social_state() + return true + + +func send_friend_invite(fingerprint: String) -> bool: + if ( + _relationships == null + or not _relationships.is_friend(fingerprint) + or _relationships.is_blocked(fingerprint) + or not is_configured() + ): + friend_invite_finished.emit( + false, "This person needs to be online to do this." + ) + return false + if ( + _session == null + or not _session.is_host() + or not _host_verified + or _lease_room_id.is_empty() + ): + friend_invite_finished.emit( + false, + "List your open room in discovery before inviting friends.", + ) + return false + if ( + not _pending_invite_fingerprint.is_empty() + or _invitation_send_request.get_http_client_status() + != HTTPClient.STATUS_DISCONNECTED + ): + return false + var friend: Dictionary = _relationships.get_friend_record(fingerprint) + var inbox_token := str(friend.get("remote_invite_token", "")) + if inbox_token.is_empty(): + friend_invite_finished.emit(false, "Friend invitation is unavailable.") + return false + var error := _post_social_json( + _invitation_send_request, + "/v1/invitations", + { + "inbox_token": inbox_token, + "room_id": _lease_room_id, + }, + ) + if error != OK: + friend_invite_finished.emit( + false, "Friend invitation could not be sent." + ) + return false + _pending_invite_fingerprint = fingerprint + return true + + func set_base_url_override(value: String) -> bool: var normalized: String = value.strip_edges() while normalized.ends_with("/"): @@ -345,7 +509,7 @@ func prepare_public_join(room: Dictionary) -> bool: func request_rooms() -> bool: if not is_configured(): - rooms_updated.emit([]) + rooms_updated.emit(_empty_rooms()) browse_status_changed.emit( "Public room discovery is not configured in this build.", true ) @@ -362,7 +526,7 @@ func request_rooms() -> bool: ] var error: Error = _browse_request.request(url) if error != OK: - rooms_updated.emit([]) + rooms_updated.emit(_empty_rooms()) browse_status_changed.emit("Could not request public rooms.", true) return false _browse_request_in_flight = true @@ -813,12 +977,12 @@ func _on_browse_request_completed( _browse_request_in_flight = false var response: Dictionary = _parse_response_dictionary(body) if result != HTTPRequest.RESULT_SUCCESS or response_code != HTTPClient.RESPONSE_OK: - rooms_updated.emit([]) + rooms_updated.emit(_empty_rooms()) browse_status_changed.emit(_request_failure(response), true) return var raw_rooms: Variant = response.get("rooms", []) if typeof(raw_rooms) != TYPE_ARRAY: - rooms_updated.emit([]) + rooms_updated.emit(_empty_rooms()) browse_status_changed.emit("Discovery returned an invalid room list.", true) return var rooms: Array[Dictionary] = [] @@ -873,6 +1037,309 @@ func _valid_json_integer(value: Variant, minimum: int, maximum: int) -> bool: ) +func _make_social_request(name: String, callback: Callable) -> HTTPRequest: + var request := HTTPRequest.new() + request.name = name + request.timeout = REQUEST_TIMEOUT_SECONDS + add_child(request) + request.request_completed.connect(callback) + return request + + +func _social_payload(fields: Dictionary = {}) -> Dictionary: + var payload: Dictionary = fields.duplicate(true) + payload["game_version"] = NetworkProtocol.game_version() + payload["protocol_version"] = NetworkProtocol.PROTOCOL_VERSION + return payload + + +func _post_social_json( + request: HTTPRequest, + path: String, + fields: Dictionary, +) -> Error: + return request.request( + _base_url + path, + PackedStringArray(["Content-Type: application/json"]), + HTTPClient.METHOD_POST, + JSON.stringify(_social_payload(fields)), + ) + + +func _refresh_social_state() -> void: + if not is_configured() or _relationships == null: + return + _publish_friend_presence() + _query_friend_presence() + _poll_friend_invitations() + + +func _publish_friend_presence() -> void: + if ( + _presence_request.get_http_client_status() + != HTTPClient.STATUS_DISCONNECTED + ): + return + var current_tokens := _friend_social_values( + "local_presence_write_token" + ) + var online := _presence_sharing and not current_tokens.is_empty() + var publish_online := online + var tokens := PackedStringArray() + if online: + # Revoke removed or blocked friendships before refreshing the remaining + # capabilities. This makes status disappear on the next request instead of + # waiting for the server's short presence TTL. + tokens = _presence_tokens_not_in( + _published_presence_tokens, current_tokens + ) + if not tokens.is_empty(): + publish_online = false + else: + tokens = current_tokens + else: + tokens = _published_presence_tokens + if tokens.is_empty(): + return + var error := _post_social_json( + _presence_request, + "/v1/presence", + { + "display_name": _session.get_local_display_name(), + "room_id": _current_presence_room_id(), + "online": publish_online, + "write_tokens": Array(tokens), + }, + ) + if error == OK: + _pending_presence_online = publish_online + _pending_presence_tokens = tokens.duplicate() + else: + social_status_changed.emit("Could not update friend presence.", true) + + +func _query_friend_presence() -> void: + if ( + _presence_query_request.get_http_client_status() + != HTTPClient.STATUS_DISCONNECTED + ): + return + var channels := _friend_social_values("remote_presence_channel") + if channels.is_empty(): + var had_presence := not _friend_presence.is_empty() + _friend_presence.clear() + if had_presence: + friend_presence_updated.emit(get_friend_presence()) + return + var error := _post_social_json( + _presence_query_request, + "/v1/presence/query", + {"channels": Array(channels)}, + ) + if error != OK: + social_status_changed.emit("Could not refresh friend status.", true) + + +func _poll_friend_invitations() -> void: + if ( + _invitation_poll_request.get_http_client_status() + != HTTPClient.STATUS_DISCONNECTED + ): + return + var tokens := _friend_social_values("local_invite_token") + if tokens.is_empty(): + return + var error := _post_social_json( + _invitation_poll_request, + "/v1/invitations/poll", + {"inbox_tokens": Array(tokens)}, + ) + if error != OK: + social_status_changed.emit("Could not check friend invitations.", true) + + +func _friend_social_values(key: String) -> PackedStringArray: + var result := PackedStringArray() + if _relationships == null: + return result + for friend: Dictionary in _relationships.get_friends(): + var value := str(friend.get(key, "")) + if not value.is_empty() and value not in result: + result.append(value) + return result + + +func _current_presence_room_id() -> String: + if _session == null: + return "" + if _session.is_host() and _host_verified: + return _lease_room_id + if _session.is_joined_client(): + return _joined_public_room_id + return "" + + +func _on_presence_request_completed( + result: int, + response_code: int, + _headers: PackedStringArray, + body: PackedByteArray, +) -> void: + var published_online := _pending_presence_online + var request_tokens := _pending_presence_tokens.duplicate() + _pending_presence_online = false + _pending_presence_tokens = PackedStringArray() + var response := _parse_response_dictionary(body) + if result != HTTPRequest.RESULT_SUCCESS or response_code != HTTPClient.RESPONSE_OK: + social_status_changed.emit(_request_failure(response), true) + return + if published_online: + for token: String in request_tokens: + if token not in _published_presence_tokens: + _published_presence_tokens.append(token) + else: + for token: String in request_tokens: + var index := _published_presence_tokens.find(token) + if index >= 0: + _published_presence_tokens.remove_at(index) + if _presence_publish_needs_follow_up(): + call_deferred("_publish_friend_presence") + + +func _presence_publish_needs_follow_up() -> bool: + var desired := ( + _friend_social_values("local_presence_write_token") + if _presence_sharing + else PackedStringArray() + ) + return ( + not _presence_tokens_not_in( + _published_presence_tokens, desired + ).is_empty() + or not _presence_tokens_not_in( + desired, _published_presence_tokens + ).is_empty() + ) + + +func _presence_tokens_not_in( + first: PackedStringArray, + second: PackedStringArray, +) -> PackedStringArray: + var result := PackedStringArray() + for token: String in first: + if token not in second: + result.append(token) + return result + + +func _on_presence_query_completed( + result: int, + response_code: int, + _headers: PackedStringArray, + body: PackedByteArray, +) -> void: + var response := _parse_response_dictionary(body) + if result != HTTPRequest.RESULT_SUCCESS or response_code != HTTPClient.RESPONSE_OK: + social_status_changed.emit(_request_failure(response), true) + return + var by_channel: Dictionary[String, String] = {} + for friend: Dictionary in _relationships.get_friends(): + by_channel[str(friend.get("remote_presence_channel", ""))] = str( + friend.get("fingerprint", "") + ) + var next_presence: Dictionary[String, Dictionary] = {} + var raw_presence: Variant = response.get("presence", []) + if typeof(raw_presence) == TYPE_ARRAY: + for value: Variant in raw_presence: + if typeof(value) != TYPE_DICTIONARY: + continue + var presence: Dictionary = value + var fingerprint := str( + by_channel.get(str(presence.get("channel", "")), "") + ) + if fingerprint.is_empty() or _relationships.is_blocked(fingerprint): + continue + var room: Dictionary = {} + var raw_room: Variant = presence.get("room", {}) + if typeof(raw_room) == TYPE_DICTIONARY and _valid_public_room(raw_room): + room = (raw_room as Dictionary).duplicate(true) + next_presence[fingerprint] = { + "online": true, + "room": room, + } + if next_presence == _friend_presence: + return + _friend_presence = next_presence + friend_presence_updated.emit(get_friend_presence()) + + +func _on_invitation_poll_completed( + result: int, + response_code: int, + _headers: PackedStringArray, + body: PackedByteArray, +) -> void: + var response := _parse_response_dictionary(body) + if result != HTTPRequest.RESULT_SUCCESS or response_code != HTTPClient.RESPONSE_OK: + return + var by_inbox: Dictionary[String, Dictionary] = {} + for friend: Dictionary in _relationships.get_friends(): + var token := str(friend.get("local_invite_token", "")) + if not token.is_empty(): + by_inbox[PlayerRelationshipStore.invite_inbox_id(token)] = friend + var invitations: Variant = response.get("invitations", []) + if typeof(invitations) != TYPE_ARRAY: + return + for value: Variant in invitations: + if typeof(value) != TYPE_DICTIONARY: + continue + var invitation: Dictionary = value + var friend: Dictionary = by_inbox.get( + str(invitation.get("inbox_id", "")), {} + ) + var raw_room: Variant = invitation.get("room", {}) + if friend.is_empty() or typeof(raw_room) != TYPE_DICTIONARY: + continue + var room := raw_room as Dictionary + if not _valid_public_room(room): + continue + var fingerprint := str(friend.get("fingerprint", "")) + if _relationships.is_blocked(fingerprint): + continue + friend_invite_received.emit( + fingerprint, + str(friend.get("last_known_display_name", "Player")), + room.duplicate(true), + ) + + +func _on_invitation_send_completed( + result: int, + response_code: int, + _headers: PackedStringArray, + body: PackedByteArray, +) -> void: + _pending_invite_fingerprint = "" + var response := _parse_response_dictionary(body) + var success := ( + result == HTTPRequest.RESULT_SUCCESS + and response_code == HTTPClient.RESPONSE_CREATED + ) + friend_invite_finished.emit( + success, + "Invitation sent." if success else _request_failure(response), + ) + + +func _on_social_relationship_changed(_fingerprint: String) -> void: + for fingerprint: String in _friend_presence.keys(): + if not _relationships.is_friend(fingerprint): + _friend_presence.erase(fingerprint) + friend_presence_updated.emit(get_friend_presence()) + call_deferred("_refresh_social_state") + + func _parse_response_dictionary(body: PackedByteArray) -> Dictionary: if body.is_empty(): return {} @@ -924,6 +1391,21 @@ func _discovery_version_mismatch_message(required_version: String) -> String: func _on_session_state_changed(state: NetworkSession.State) -> void: + if state == NetworkSession.State.JOINED_CLIENT: + if not _pending_join_room_id.is_empty(): + _joined_public_room_id = _pending_join_room_id + elif state in [ + NetworkSession.State.PRIVATE_HOST, + NetworkSession.State.OPEN_HOST, + NetworkSession.State.SERVER_LOST, + NetworkSession.State.CONNECTION_FAILED, + ]: + _joined_public_room_id = "" + elif ( + state == NetworkSession.State.INACTIVE + and not _preserve_pending_join_on_inactive + ): + _joined_public_room_id = "" if state == NetworkSession.State.CONNECTING and not _pending_join_token.is_empty(): _preserve_pending_join_on_inactive = false _set_public_join_state(PublicJoinState.CONNECTING) @@ -979,6 +1461,7 @@ func _on_session_state_changed(state: NetworkSession.State) -> void: _set_host_status("Open the game before listing it publicly.", false) else: _set_host_state(HostState.CLOSED) + call_deferred("_refresh_social_state") func _on_host_openness_changed(is_open: bool) -> void: @@ -1039,6 +1522,10 @@ func _set_public_join_state(state: PublicJoinState) -> void: public_join_state_changed.emit(int(state)) +func _empty_rooms() -> Array[Dictionary]: + return [] + + func _sanitize_room_name(value: String) -> String: var cleaned: String = value.strip_edges().replace("\n", " ").replace("\r", " ") cleaned = cleaned.replace("\t", " ") @@ -1065,7 +1552,7 @@ func _load_settings() -> void: ) if not saved_name.is_empty(): _room_name = saved_name - _room_name_uses_default = bool(config.get_value( + _room_name_uses_default = bool(config.get_value( "host", "room_name_uses_default", saved_name in [ @@ -1073,6 +1560,9 @@ func _load_settings() -> void: LEGACY_DEFAULT_ROOM_NAME, LEGACY_DEDICATED_DEFAULT_ROOM_NAME, ], + )) + _presence_sharing = bool(config.get_value( + "social", "share_presence", false )) @@ -1084,6 +1574,7 @@ func _save_settings() -> void: config.set_value( "host", "room_name_uses_default", _room_name_uses_default ) + config.set_value("social", "share_presence", _presence_sharing) var error: Error = config.save(SETTINGS_PATH) if error != OK: - push_warning("Could not save the local NETfishing room name.") + push_warning("Could not save local NETfishing discovery settings.") diff --git a/network/network_fish_showcase_protocol.gd b/network/network_fish_showcase_protocol.gd index d69e9dd..2ecd1ee 100644 --- a/network/network_fish_showcase_protocol.gd +++ b/network/network_fish_showcase_protocol.gd @@ -2,7 +2,7 @@ class_name NetworkFishShowcaseProtocol extends RefCounted const CAPABILITY: StringName = &"fish_showcase_v1" -const RELIABLE_CHANNEL: int = NetworkProtocol.ITEM_RELIABLE_CHANNEL +const RELIABLE_CHANNEL: int = NetworkProtocol.SHOWCASE_RELIABLE_CHANNEL const MAX_SESSION_ID_LENGTH: int = 96 const MAX_FISH_ID_LENGTH: int = 96 const MAX_WEIGHT_LB: float = 1000.0 diff --git a/network/network_fish_showcase_service.gd b/network/network_fish_showcase_service.gd index 068ee7d..d956240 100644 --- a/network/network_fish_showcase_service.gd +++ b/network/network_fish_showcase_service.gd @@ -7,6 +7,7 @@ const FishCatchType = preload("res://fish/fish_catch.gd") const FishQualityType = preload("res://fish/fish_quality.gd") const FishInventoryType = preload("res://inventory/fish_inventory.gd") const PlayerHotbarType = preload("res://inventory/player_hotbar.gd") +const LOCAL_STATE_RETRY_SECONDS: float = 0.75 var _session: NetworkSession var _spawn_service: PlayerSpawnService @@ -15,6 +16,8 @@ var _local_inventory: FishInventoryType var _local_hotbar: PlayerHotbarType var _states: Dictionary[int, Dictionary] = {} var _local_revision: int = 0 +var _local_acknowledged_revision: int = -1 +var _local_state_retry_elapsed: float = 0.0 var _local_visible: bool = false var _local_catch_id: StringName @@ -54,6 +57,30 @@ func setup( ) +func _process(delta: float) -> void: + if ( + _session == null + or not _session.is_joined_client() + or _local_revision <= 0 + or _local_revision <= _local_acknowledged_revision + ): + _local_state_retry_elapsed = 0.0 + return + _local_state_retry_elapsed += delta + if _local_state_retry_elapsed < LOCAL_STATE_RETRY_SECONDS: + return + var fish_catch: FishCatchType + if _local_visible: + fish_catch = _local_inventory.get_catch_by_id(_local_catch_id) + if fish_catch == null or not fish_catch.is_valid(): + _submit_local_state(null, false) + return + # A client can finish a generated-world transition on the same frame that + # its local showcase changes. Republish until the authoritative echo arrives + # so that one early application-level miss cannot leave held fish out of sync. + _submit_local_state(fish_catch, _local_visible) + + func toggle_selected_fish() -> bool: if _local_hotbar == null or _local_inventory == null: return false @@ -78,6 +105,7 @@ func get_local_showcase_catch_id() -> StringName: func _submit_local_state(fish_catch: FishCatchType, should_show: bool) -> void: _local_revision += 1 + _local_state_retry_elapsed = 0.0 var local_peer_id: int = ( _session.get_local_peer_id() if _session != null else 1 ) @@ -166,6 +194,11 @@ func receive_showcase_state(data: Dictionary) -> void: ): return var peer_id: int = int(data["owner_peer_id"]) + if peer_id == _session.get_local_peer_id(): + _local_acknowledged_revision = maxi( + _local_acknowledged_revision, + int(data["revision"]), + ) var previous: Dictionary = _states.get(peer_id, {}) if int(data["revision"]) <= int(previous.get("revision", -1)): return @@ -267,6 +300,8 @@ func _on_session_state_changed(state: NetworkSession.State) -> void: _states.clear() _local_visible = false _local_catch_id = StringName() + _local_acknowledged_revision = -1 + _local_state_retry_elapsed = 0.0 var local_avatar: Player = _spawn_service.get_avatar( _session.get_local_peer_id() ) diff --git a/network/network_fishing_protocol.gd b/network/network_fishing_protocol.gd index 8df59b7..7f15fdd 100644 --- a/network/network_fishing_protocol.gd +++ b/network/network_fishing_protocol.gd @@ -8,6 +8,8 @@ const MAX_REEL_SPEED: float = 10.0 const MAX_BARRIER_DAMAGE: int = 128 const INPUT_CHANNEL: int = 3 const SNAPSHOT_CHANNEL: int = 4 +const OBSERVER_SNAPSHOT_CHANNEL: int = 10 +const RELIABLE_CHANNEL: int = NetworkProtocol.FISHING_RELIABLE_CHANNEL const INPUT_RATE: float = 1.0 / 30.0 const SNAPSHOT_RATE: float = 1.0 / 20.0 @@ -35,6 +37,7 @@ static func validate_cast_request(data: Variant) -> String: "rarity_multipliers", "discovered_fish_ids", "capacity_available", + "movement_sequence", ]: if not payload.has(key): return "Malformed fishing request." @@ -51,11 +54,16 @@ static func validate_cast_request(data: Variant) -> String: or typeof(payload["rarity_multipliers"]) != TYPE_ARRAY or typeof(payload["discovered_fish_ids"]) != TYPE_ARRAY or typeof(payload["capacity_available"]) != TYPE_BOOL + or typeof(payload["movement_sequence"]) != TYPE_INT ): return "Malformed fishing request." - if payload.has("bait_id") and typeof(payload["bait_id"]) not in [TYPE_STRING, TYPE_STRING_NAME]: + if payload.has("bait_id") and typeof(payload["bait_id"]) not in [ + TYPE_STRING, TYPE_STRING_NAME + ]: return "Malformed fishing request." - if payload.has("lure_id") and typeof(payload["lure_id"]) not in [TYPE_STRING, TYPE_STRING_NAME]: + if payload.has("lure_id") and typeof(payload["lure_id"]) not in [ + TYPE_STRING, TYPE_STRING_NAME + ]: return "Malformed fishing request." var request_id: String = payload["request_id"] var session_id: String = payload["session_id"] @@ -92,6 +100,8 @@ static func validate_cast_request(data: Variant) -> String: or str(payload["rod_id"]).is_empty() or str(payload["rod_id"]).length() > 96 or str(payload.get("lure_id", "")).length() > 96 + or int(payload["movement_sequence"]) < 0 + or int(payload["movement_sequence"]) > 2147483647 ): return "Fishing request values are outside allowed limits." for value: Variant in rarity: diff --git a/network/network_fishing_service.gd b/network/network_fishing_service.gd index 95de131..8c9c798 100644 --- a/network/network_fishing_service.gd +++ b/network/network_fishing_service.gd @@ -21,6 +21,8 @@ const MAX_LEDGER_ENTRIES_PER_PEER: int = 64 const CAST_ORIGIN_TOLERANCE: float = 2.5 const CAPACITY_RESPONSE_TIMEOUT: float = 5.0 const MIN_CAST_INTERVAL: float = 0.25 +const OBSERVER_SNAPSHOT_INTERVAL: float = 0.1 +const OBSERVER_RELEVANCE_DISTANCE: float = 96.0 signal local_cast_accepted(attempt_id: String, target: Vector3) signal local_cast_rejected(message: String) @@ -51,7 +53,13 @@ var _last_input_time: Dictionary[int, float] = {} var _remote_presentations: Dictionary[int, RemoteFishingPresentation] = {} var _pending_local_bait_by_request: Dictionary[String, StringName] = {} var _snapshot_accumulator: float = 0.0 +var _observer_snapshot_accumulator: float = 0.0 var _local_input_sequence: int = 0 +var _presentation_enabled: bool = true +var _owner_snapshot_packets_sent: int = 0 +var _owner_snapshot_states_sent: int = 0 +var _observer_snapshot_packets_sent: int = 0 +var _observer_snapshot_states_sent: int = 0 func setup( @@ -67,6 +75,7 @@ func setup( item_catalog: ItemCatalog, fish_catalog: FishPoolType, item_use: NetworkItemUseService, + presentation_enabled: bool = true, ) -> void: _session = session _spawn_service = spawn_service @@ -80,6 +89,7 @@ func setup( _item_catalog = item_catalog _fish_catalog = fish_catalog _item_use = item_use + _presentation_enabled = presentation_enabled if not _session.peer_removed.is_connected(_on_peer_removed): _session.peer_removed.connect(_on_peer_removed) if not _session.state_changed.is_connected(_on_session_state_changed): @@ -130,6 +140,7 @@ func request_local_cast( "capacity_available": bool(evidence.get("capacity_available", false)), "bait_id": str(bait_id), "lure_id": str(lure_id), + "movement_sequence": _session.get_latest_movement_input_sequence(), } if _session.is_host(): _handle_cast_request(_session.get_local_peer_id(), data) @@ -213,15 +224,27 @@ func _process(delta: float) -> void: if now >= attempt.capacity_deadline: _cancel_attempt(peer_id, "Fishing attempt ended.") _snapshot_accumulator += delta + _observer_snapshot_accumulator += delta if _snapshot_accumulator >= NetworkFishingProtocol.SNAPSHOT_RATE: _snapshot_accumulator = fmod( _snapshot_accumulator, NetworkFishingProtocol.SNAPSHOT_RATE ) - _broadcast_snapshots() + _broadcast_owner_snapshots() + if _observer_snapshot_accumulator >= OBSERVER_SNAPSHOT_INTERVAL: + _observer_snapshot_accumulator = fmod( + _observer_snapshot_accumulator, + OBSERVER_SNAPSHOT_INTERVAL, + ) + _broadcast_observer_snapshots() -@rpc("any_peer", "call_remote", "reliable", 0) +@rpc( + "any_peer", + "call_remote", + "reliable", + NetworkFishingProtocol.RELIABLE_CHANNEL, +) func submit_cast_request(data: Dictionary) -> void: var sender_id: int = multiplayer.get_remote_sender_id() if not _session.is_host() or not _session.is_authenticated_peer(sender_id): @@ -268,7 +291,13 @@ func _handle_cast_request(peer_id: int, data: Dictionary) -> void: return var origin: Vector3 = NetworkFishingProtocol.array_to_vector3(data["origin"]) var target: Vector3 = NetworkFishingProtocol.array_to_vector3(data["target"]) - var authoritative_origin: Vector3 = avatar.get_cast_origin_position() + var movement_state: Dictionary = avatar.get_lag_compensated_movement_state( + int(data.get("movement_sequence", 0)) + ) + var authoritative_origin: Vector3 = movement_state.get( + "cast_origin", + avatar.get_cast_origin_position(), + ) if origin.distance_to(authoritative_origin) > CAST_ORIGIN_TOLERANCE: _record_and_reject(peer_id, request_id, "Cannot fish here.") return @@ -279,7 +308,10 @@ func _handle_cast_request(peer_id: int, data: Dictionary) -> void: _fishing_spot.maximum_cast_distance, float(data["charge"]) ) - var facing: Vector3 = avatar.get_facing_direction() + var facing: Vector3 = movement_state.get( + "facing", + avatar.get_facing_direction(), + ) facing.y = 0.0 if ( cast_offset.length() < _fishing_spot.minimum_cast_distance - 0.25 @@ -642,7 +674,12 @@ func _handle_fishing_input(peer_id: int, data: Dictionary) -> void: attempt.controller.handle_primary_pressed() -@rpc("any_peer", "call_remote", "reliable", 0) +@rpc( + "any_peer", + "call_remote", + "reliable", + NetworkFishingProtocol.RELIABLE_CHANNEL, +) func submit_cancel_request(attempt_id: String) -> void: var sender_id: int = multiplayer.get_remote_sender_id() var attempt: NetworkFishingAttempt = _attempts.get(sender_id) @@ -687,16 +724,59 @@ func _on_encounter_updated( }) -func _broadcast_snapshots() -> void: - var snapshots: Array[Dictionary] = [] +func _broadcast_owner_snapshots() -> void: for attempt: NetworkFishingAttempt in _attempts.values(): var snapshot: Dictionary = attempt.get_meta("snapshot", {}) - if not snapshot.is_empty(): - snapshots.append(snapshot) - if snapshots.is_empty(): + if snapshot.is_empty(): + continue + var owner_peer_id: int = attempt.owner_peer_id + if owner_peer_id == _session.get_local_peer_id(): + _apply_snapshots([snapshot]) + elif owner_peer_id > 1: + receive_fishing_snapshots.rpc_id(owner_peer_id, [snapshot]) + _owner_snapshot_packets_sent += 1 + _owner_snapshot_states_sent += 1 + + +func _broadcast_observer_snapshots() -> void: + if _attempts.is_empty(): return - _apply_snapshots(snapshots) - receive_fishing_snapshots.rpc(snapshots) + var observer_ids: Array[int] = _session.get_authenticated_peer_ids() + for observer_peer_id: int in observer_ids: + var observer_avatar: Player = _spawn_service.get_avatar(observer_peer_id) + if observer_avatar == null: + continue + var summaries: Array = [] + for attempt: NetworkFishingAttempt in _attempts.values(): + if attempt.owner_peer_id == observer_peer_id: + continue + var owner_avatar: Player = _spawn_service.get_avatar( + attempt.owner_peer_id + ) + if ( + owner_avatar == null + or observer_avatar.global_position.distance_squared_to( + owner_avatar.global_position + ) > OBSERVER_RELEVANCE_DISTANCE * OBSERVER_RELEVANCE_DISTANCE + ): + continue + summaries.append([ + attempt.attempt_id, + attempt.owner_peer_id, + attempt.bobber_position, + int(attempt.phase), + ]) + if summaries.is_empty(): + continue + if observer_peer_id == _session.get_local_peer_id(): + _apply_observer_snapshots(summaries) + elif observer_peer_id > 1: + receive_fishing_observer_snapshots.rpc_id( + observer_peer_id, + summaries, + ) + _observer_snapshot_packets_sent += 1 + _observer_snapshot_states_sent += summaries.size() @rpc("authority", "call_remote", "unreliable_ordered", 4) @@ -704,6 +784,55 @@ func receive_fishing_snapshots(snapshots: Array) -> void: _apply_snapshots(snapshots) +@rpc( + "authority", + "call_remote", + "unreliable_ordered", + NetworkFishingProtocol.OBSERVER_SNAPSHOT_CHANNEL, +) +func receive_fishing_observer_snapshots(summaries: Array) -> void: + _apply_observer_snapshots(summaries) + + +func _apply_observer_snapshots(summaries: Array) -> void: + if not _presentation_enabled or summaries.size() > 128: + return + for value: Variant in summaries: + if typeof(value) != TYPE_ARRAY: + continue + var fields: Array = value + if ( + fields.size() != 4 + or typeof(fields[0]) != TYPE_STRING + or str(fields[0]).is_empty() + or str(fields[0]).length() > NetworkFishingProtocol.MAX_ID_LENGTH + or typeof(fields[1]) != TYPE_INT + or int(fields[1]) <= 0 + or typeof(fields[2]) != TYPE_VECTOR3 + or typeof(fields[3]) != TYPE_INT + or not (fields[2] as Vector3).is_finite() + or int(fields[3]) not in [ + NetworkFishingAttempt.Phase.WAITING_FOR_BITE, + NetworkFishingAttempt.Phase.FIGHTING, + NetworkFishingAttempt.Phase.PENDING_CAPACITY, + ] + ): + continue + var owner_peer_id: int = int(fields[1]) + if owner_peer_id == _session.get_local_peer_id(): + continue + var presentation := _get_remote_presentation(owner_peer_id) + if presentation != null: + presentation.synchronize_active( + str(fields[0]), + fields[2], + int(fields[3]) in [ + NetworkFishingAttempt.Phase.FIGHTING, + NetworkFishingAttempt.Phase.PENDING_CAPACITY, + ], + ) + + func _apply_snapshots(snapshots: Array) -> void: var local_peer_id: int = _session.get_local_peer_id() for value: Variant in snapshots: @@ -810,7 +939,12 @@ func _on_attempt_caught(peer_id: int) -> void: receive_capacity_probe.rpc_id(peer_id, probe) -@rpc("authority", "call_remote", "reliable", 0) +@rpc( + "authority", + "call_remote", + "reliable", + NetworkFishingProtocol.RELIABLE_CHANNEL, +) func receive_capacity_probe(data: Dictionary) -> void: _handle_local_capacity_probe(data) @@ -844,7 +978,12 @@ func _handle_local_capacity_probe(data: Dictionary) -> void: ) -@rpc("any_peer", "call_remote", "reliable", 0) +@rpc( + "any_peer", + "call_remote", + "reliable", + NetworkFishingProtocol.RELIABLE_CHANNEL, +) func submit_capacity_response( attempt_id: String, capacity_nonce: String, @@ -902,7 +1041,12 @@ func _finalize_catch(attempt: NetworkFishingAttempt) -> void: _dispose_attempt(attempt.owner_peer_id) -@rpc("authority", "call_remote", "reliable", 0) +@rpc( + "authority", + "call_remote", + "reliable", + NetworkFishingProtocol.RELIABLE_CHANNEL, +) func receive_target_outcome(data: Dictionary) -> void: _apply_target_outcome(data) @@ -934,7 +1078,7 @@ func _apply_target_outcome(data: Dictionary) -> void: ) ) _local_inventory.add_catch(fish_catch) - _local_collection.mark_quality_discovered( + _local_collection.record_catch( fish_id, fish_catch.quality, ) @@ -970,7 +1114,12 @@ func _acknowledge_result(result_id: String, catch_id: StringName) -> void: acknowledge_fishing_result.rpc_id(1, result_id, str(catch_id)) -@rpc("any_peer", "call_remote", "reliable", 0) +@rpc( + "any_peer", + "call_remote", + "reliable", + NetworkFishingProtocol.RELIABLE_CHANNEL, +) func acknowledge_fishing_result(result_id: String, catch_id: String) -> void: var sender_id: int = multiplayer.get_remote_sender_id() if ( @@ -1027,7 +1176,12 @@ func _broadcast_public_outcome( receive_public_outcome.rpc(data) -@rpc("authority", "call_remote", "reliable", 0) +@rpc( + "authority", + "call_remote", + "reliable", + NetworkFishingProtocol.RELIABLE_CHANNEL, +) func receive_public_outcome(data: Dictionary) -> void: _apply_public_outcome(data) @@ -1079,7 +1233,12 @@ func _broadcast_cast_accepted(data: Dictionary) -> void: receive_cast_accepted.rpc(data) -@rpc("authority", "call_remote", "reliable", 0) +@rpc( + "authority", + "call_remote", + "reliable", + NetworkFishingProtocol.RELIABLE_CHANNEL, +) func receive_cast_accepted(data: Dictionary) -> void: _apply_cast_accepted(data) @@ -1114,10 +1273,19 @@ func _apply_cast_accepted(data: Dictionary) -> void: else: var presentation := _get_remote_presentation(peer_id) if presentation != null: - presentation.show_cast(origin, target) + presentation.show_cast( + origin, + target, + str(data["attempt_id"]), + ) -@rpc("authority", "call_remote", "reliable", 0) +@rpc( + "authority", + "call_remote", + "reliable", + NetworkFishingProtocol.RELIABLE_CHANNEL, +) func receive_bite_pending(data: Dictionary) -> void: _apply_bite_pending(data) @@ -1132,7 +1300,12 @@ func _apply_bite_pending(data: Dictionary) -> void: local_bite_pending.emit(str(data["attempt_id"])) -@rpc("authority", "call_remote", "reliable", 0) +@rpc( + "authority", + "call_remote", + "reliable", + NetworkFishingProtocol.RELIABLE_CHANNEL, +) func receive_bite_started(data: Dictionary) -> void: _apply_bite_started(data) @@ -1193,7 +1366,12 @@ func _send_cast_rejected( receive_cast_rejected.rpc_id(peer_id, request_id, message.left(128)) -@rpc("authority", "call_remote", "reliable", 0) +@rpc( + "authority", + "call_remote", + "reliable", + NetworkFishingProtocol.RELIABLE_CHANNEL, +) func receive_cast_rejected(request_id: String, message: String) -> void: _pending_local_bait_by_request.erase(request_id) local_cast_rejected.emit(message) @@ -1228,6 +1406,8 @@ func _resend_request_response(peer_id: int, response: Dictionary) -> void: func _get_remote_presentation( peer_id: int, ) -> RemoteFishingPresentation: + if not _presentation_enabled: + return null var existing: RemoteFishingPresentation = _remote_presentations.get(peer_id) if existing != null and is_instance_valid(existing): return existing @@ -1330,9 +1510,21 @@ func _clear_all() -> void: _last_input_time.clear() _pending_local_bait_by_request.clear() _snapshot_accumulator = 0.0 + _observer_snapshot_accumulator = 0.0 _local_input_sequence = 0 +func get_network_metrics() -> Dictionary: + return { + "active_attempts": _attempts.size(), + "remote_presentations": _remote_presentations.size(), + "owner_snapshot_packets_sent": _owner_snapshot_packets_sent, + "owner_snapshot_states_sent": _owner_snapshot_states_sent, + "observer_snapshot_packets_sent": _observer_snapshot_packets_sent, + "observer_snapshot_states_sent": _observer_snapshot_states_sent, + } + + func _bound_result_ledger() -> void: while _result_ledgers.size() > MAX_LEDGER_ENTRIES_PER_PEER: _result_ledgers.erase(_result_ledgers.keys().front()) diff --git a/network/network_item_use_service.gd b/network/network_item_use_service.gd index 7ae0f92..cf2d5f9 100644 --- a/network/network_item_use_service.gd +++ b/network/network_item_use_service.gd @@ -47,6 +47,7 @@ func setup( _session.peer_removed.connect(_on_peer_removed) _session.state_changed.connect(_on_session_state_changed) _session.peer_authenticated.connect(_on_peer_authenticated) + _spawn_service.avatar_spawned.connect(_on_avatar_spawned) func request_use(item_id: StringName) -> String: @@ -436,6 +437,12 @@ func _on_peer_authenticated(peer_id: int, _display_name: String) -> void: receive_equipped_state.rpc_id(peer_id, state) +func _on_avatar_spawned(peer_id: int, _avatar: Player) -> void: + var state: Dictionary = _equipped_states.get(peer_id, {}) + if not state.is_empty(): + _apply_equipped(state) + + func _on_peer_removed(peer_id: int) -> void: _requests.erase(peer_id) _pending_by_peer.erase(peer_id) diff --git a/network/network_player_list_service.gd b/network/network_player_list_service.gd index 2014665..677df05 100644 --- a/network/network_player_list_service.gd +++ b/network/network_player_list_service.gd @@ -3,6 +3,15 @@ extends Node signal entries_changed signal moderation_finished(success: bool, message: String) +signal friend_request_received( + request_id: String, + fingerprint: String, + display_name: String, +) +signal friend_action_finished(success: bool, message: String) + +const MAX_PENDING_FRIEND_REQUESTS := 16 +const FRIEND_REQUEST_ID_LENGTH := 32 var _session: NetworkSession var _relationships: PlayerRelationshipStore @@ -18,6 +27,9 @@ var _peer_fingerprints: Dictionary[int, String] = {} var _remote_bans: Array[Dictionary] = [] var _ban_snapshot_requested: bool = false var _ban_snapshot_loaded: bool = false +var _pending_outgoing_friends: Dictionary[String, Dictionary] = {} +var _pending_incoming_friends: Dictionary[String, Dictionary] = {} +var _host_friend_routes: Dictionary[String, Dictionary] = {} func setup( @@ -74,6 +86,18 @@ func get_entries() -> Array[PlayerListEntry]: entry.ping_to_host_ms = _session.get_peer_rtt_ms(peer_id) entry.muted = _relationships.is_muted(record.identity_fingerprint) entry.blocked = blocked + entry.is_friend = _relationships.is_friend(record.identity_fingerprint) + entry.can_request_friend = ( + not entry.is_local_player + and not entry.is_friend + and not entry.blocked + and _session.supports_server_capability( + NetworkProtocol.FRIENDS_CAPABILITY + ) + and _session.peer_supports_capability( + peer_id, NetworkProtocol.FRIENDS_CAPABILITY + ) + ) entry.can_kick = ( _session.can_local_moderate() and peer_id != local_id @@ -125,6 +149,10 @@ func get_relationships() -> Array[Dictionary]: return _relationships.get_records() +func get_friends() -> Array[Dictionary]: + return _relationships.get_friends() + + func get_bans() -> Array[Dictionary]: if _session.is_host(): return _bans.get_bans(_session.get_host_identity_fingerprint()) @@ -180,6 +208,160 @@ func set_blocked(fingerprint: String, display_name: String, value: bool) -> bool return _relationships.set_blocked(fingerprint, display_name, value) +func remove_friend(fingerprint: String, display_name: String) -> bool: + if fingerprint == _session.get_local_identity_fingerprint(): + return false + var ok := _relationships.remove_friend(fingerprint, display_name) + friend_action_finished.emit( + ok, + "Friend removed." if ok else "Friend could not be removed.", + ) + return ok + + +func send_friend_request( + peer_id: int, + fingerprint: String, + display_name: String, +) -> bool: + if ( + _pending_outgoing_friends.size() >= MAX_PENDING_FRIEND_REQUESTS + or fingerprint == _session.get_local_identity_fingerprint() + or _relationships.is_blocked(fingerprint) + or _relationships.is_friend(fingerprint) + or not _valid_friend_target(peer_id, fingerprint) + ): + friend_action_finished.emit( + false, "This person needs to be online to do this." + ) + return false + var local_capabilities: Dictionary = ( + _relationships.create_friend_capabilities() + ) + if local_capabilities.is_empty(): + friend_action_finished.emit(false, "Friend request could not be created.") + return false + var request_id := NetworkIdentityCrypto.secure_id(16) + _pending_outgoing_friends[request_id] = { + "target_peer_id": peer_id, + "target_fingerprint": fingerprint, + "target_display_name": display_name, + "local_capabilities": local_capabilities, + } + var public_capabilities := _public_friend_capabilities(local_capabilities) + if _session.is_host(): + _route_friend_request( + _session.get_local_peer_id(), peer_id, request_id, public_capabilities + ) + else: + request_friendship.rpc_id( + 1, peer_id, request_id, public_capabilities + ) + friend_action_finished.emit(true, "Friend request sent.") + return true + + +func respond_friend_request(request_id: String, accepted: bool) -> bool: + var pending: Dictionary = _pending_incoming_friends.get(request_id, {}) + if pending.is_empty(): + friend_action_finished.emit( + false, "This friend request is no longer available." + ) + return false + _pending_incoming_friends.erase(request_id) + var local_capabilities: Dictionary = {} + var public_capabilities: Dictionary = {} + var response_accepted := accepted + if accepted: + local_capabilities = _relationships.create_friend_capabilities() + response_accepted = ( + not local_capabilities.is_empty() + and _relationships.add_friend( + str(pending["requester_fingerprint"]), + str(pending["requester_display_name"]), + local_capabilities, + pending["remote_capabilities"], + ) + ) + if response_accepted: + public_capabilities = _public_friend_capabilities(local_capabilities) + if _session.is_host(): + _route_friend_response( + _session.get_local_peer_id(), + request_id, + response_accepted, + public_capabilities, + ) + else: + respond_friendship.rpc_id( + 1, request_id, response_accepted, public_capabilities + ) + friend_action_finished.emit( + response_accepted or not accepted, + "Friend added." + if response_accepted + else "Friend request declined." + if not accepted + else "Friend could not be saved.", + ) + return response_accepted or not accepted + + +@rpc("any_peer", "call_remote", "reliable", 0) +func request_friendship( + target_peer_id: int, + request_id: String, + public_capabilities: Dictionary, +) -> void: + _route_friend_request( + multiplayer.get_remote_sender_id(), + target_peer_id, + request_id, + public_capabilities, + ) + + +@rpc("authority", "call_remote", "reliable", 0) +func receive_friend_request( + request_id: String, + requester_fingerprint: String, + requester_display_name: String, + public_capabilities: Dictionary, +) -> void: + _receive_friend_request_local( + request_id, + requester_fingerprint, + requester_display_name, + public_capabilities, + ) + + +@rpc("any_peer", "call_remote", "reliable", 0) +func respond_friendship( + request_id: String, + accepted: bool, + public_capabilities: Dictionary, +) -> void: + _route_friend_response( + multiplayer.get_remote_sender_id(), + request_id, + accepted, + public_capabilities, + ) + + +@rpc("authority", "call_remote", "reliable", 0) +func receive_friend_result( + request_id: String, + accepted: bool, + public_capabilities: Dictionary, + message: String, +) -> void: + _receive_friend_result_local( + request_id, accepted, public_capabilities, message + ) + + func kick(peer_id: int, fingerprint: String, revision: int) -> bool: if _session.is_host(): return _kick_on_host(peer_id, fingerprint, revision, true) @@ -454,6 +636,32 @@ func _on_peer_removed(peer_id: int) -> void: for key: String in _host_block_pairs.keys(): if fingerprint in key.split(":"): _host_block_pairs.erase(key) + for request_id: String in _host_friend_routes.keys(): + var route: Dictionary = _host_friend_routes[request_id] + if int(route.get("target_peer_id", 0)) == peer_id: + _send_friend_result_to_peer( + int(route.get("requester_peer_id", 0)), + request_id, + false, + {}, + "This person needs to be online to do this.", + ) + if ( + int(route.get("target_peer_id", 0)) == peer_id + or int(route.get("requester_peer_id", 0)) == peer_id + ): + _host_friend_routes.erase(request_id) + for request_id: String in _pending_outgoing_friends.keys(): + var pending: Dictionary = _pending_outgoing_friends[request_id] + if str(pending.get("target_fingerprint", "")) == fingerprint: + _pending_outgoing_friends.erase(request_id) + friend_action_finished.emit( + false, "This person needs to be online to do this." + ) + for request_id: String in _pending_incoming_friends.keys(): + var pending: Dictionary = _pending_incoming_friends[request_id] + if str(pending.get("requester_fingerprint", "")) == fingerprint: + _pending_incoming_friends.erase(request_id) _changed() @@ -469,6 +677,9 @@ func _on_session_state_changed(state: NetworkSession.State) -> void: _remote_bans.clear() _ban_snapshot_requested = false _ban_snapshot_loaded = false + _pending_outgoing_friends.clear() + _pending_incoming_friends.clear() + _host_friend_routes.clear() elif state == NetworkSession.State.JOINED_CLIENT: _request_ban_snapshot() _changed() @@ -506,6 +717,245 @@ func _peer_for_fingerprint(fingerprint: String) -> int: return 0 +func _valid_friend_target(peer_id: int, fingerprint: String) -> bool: + if ( + not _session.is_gameplay_session_active() + or not _session.supports_server_capability( + NetworkProtocol.FRIENDS_CAPABILITY + ) + or not _session.peer_supports_capability( + peer_id, NetworkProtocol.FRIENDS_CAPABILITY + ) + ): + return false + var record: PeerRegistry.PeerRecord = _session.get_peer_record(peer_id) + return ( + record != null + and record.identity_authenticated + and record.identity_fingerprint == fingerprint + ) + + +func _route_friend_request( + requester_peer_id: int, + target_peer_id: int, + request_id: String, + public_capabilities: Dictionary, +) -> void: + if ( + not _session.is_host() + or not _valid_friend_request_id(request_id) + or not _valid_public_friend_capabilities(public_capabilities) + or not _session.is_authenticated_peer(requester_peer_id) + ): + return + var requester: PeerRegistry.PeerRecord = ( + _session.get_peer_record(requester_peer_id) + ) + var target: PeerRegistry.PeerRecord = _session.get_peer_record(target_peer_id) + var can_route := ( + requester != null + and target != null + and requester_peer_id != target_peer_id + and _valid_friend_target( + target_peer_id, target.identity_fingerprint + ) + and _session.peer_supports_capability( + requester_peer_id, NetworkProtocol.FRIENDS_CAPABILITY + ) + and not pair_is_blocked( + requester.identity_fingerprint, target.identity_fingerprint + ) + and _host_friend_routes.size() < ( + MAX_PENDING_FRIEND_REQUESTS * 8 + ) + ) + if not can_route: + _send_friend_result_to_peer( + requester_peer_id, + request_id, + false, + {}, + "This person needs to be online to do this.", + ) + return + var requester_pending_count := 0 + for route: Dictionary in _host_friend_routes.values(): + if int(route.get("requester_peer_id", 0)) == requester_peer_id: + requester_pending_count += 1 + if requester_pending_count >= MAX_PENDING_FRIEND_REQUESTS: + _send_friend_result_to_peer( + requester_peer_id, + request_id, + false, + {}, + "Too many friend requests are pending.", + ) + return + _host_friend_routes[request_id] = { + "requester_peer_id": requester_peer_id, + "target_peer_id": target_peer_id, + } + if target_peer_id == _session.get_local_peer_id(): + _receive_friend_request_local( + request_id, + requester.identity_fingerprint, + requester.display_name, + public_capabilities, + ) + else: + receive_friend_request.rpc_id( + target_peer_id, + request_id, + requester.identity_fingerprint, + requester.display_name, + public_capabilities, + ) + + +func _receive_friend_request_local( + request_id: String, + requester_fingerprint: String, + requester_display_name: String, + public_capabilities: Dictionary, +) -> void: + if ( + not _valid_friend_request_id(request_id) + or not _valid_public_friend_capabilities(public_capabilities) + or not NetworkIdentityCrypto.valid_fingerprint(requester_fingerprint) + or not NetworkProfilePreferences.is_valid_display_name( + requester_display_name + ) + or _pending_incoming_friends.size() + >= MAX_PENDING_FRIEND_REQUESTS + or _relationships.is_blocked(requester_fingerprint) + or _relationships.is_friend(requester_fingerprint) + ): + _decline_friend_request(request_id) + return + _pending_incoming_friends[request_id] = { + "requester_fingerprint": requester_fingerprint, + "requester_display_name": requester_display_name, + "remote_capabilities": public_capabilities.duplicate(true), + } + friend_request_received.emit( + request_id, requester_fingerprint, requester_display_name + ) + + +func _decline_friend_request(request_id: String) -> void: + if _session.is_host(): + _route_friend_response( + _session.get_local_peer_id(), request_id, false, {} + ) + elif _session.is_joined_client(): + respond_friendship.rpc_id(1, request_id, false, {}) + + +func _route_friend_response( + responder_peer_id: int, + request_id: String, + accepted: bool, + public_capabilities: Dictionary, +) -> void: + if not _session.is_host(): + return + var route: Dictionary = _host_friend_routes.get(request_id, {}) + if ( + route.is_empty() + or int(route.get("target_peer_id", 0)) != responder_peer_id + ): + return + _host_friend_routes.erase(request_id) + var response_accepted := ( + accepted and _valid_public_friend_capabilities(public_capabilities) + ) + _send_friend_result_to_peer( + int(route["requester_peer_id"]), + request_id, + response_accepted, + public_capabilities if response_accepted else {}, + "Friend request accepted." + if response_accepted + else "Friend request declined." + if not accepted + else "Friend request could not be completed.", + ) + + +func _send_friend_result_to_peer( + peer_id: int, + request_id: String, + accepted: bool, + public_capabilities: Dictionary, + message: String, +) -> void: + if peer_id == _session.get_local_peer_id(): + _receive_friend_result_local( + request_id, accepted, public_capabilities, message + ) + elif _session.is_authenticated_peer(peer_id): + receive_friend_result.rpc_id( + peer_id, + request_id, + accepted, + public_capabilities, + message.left(120), + ) + + +func _receive_friend_result_local( + request_id: String, + accepted: bool, + public_capabilities: Dictionary, + message: String, +) -> void: + var pending: Dictionary = _pending_outgoing_friends.get(request_id, {}) + if pending.is_empty(): + return + _pending_outgoing_friends.erase(request_id) + var success := false + if accepted and _valid_public_friend_capabilities(public_capabilities): + success = _relationships.add_friend( + str(pending["target_fingerprint"]), + str(pending["target_display_name"]), + pending["local_capabilities"], + public_capabilities, + ) + message = "Friend added." if success else "Friend could not be saved." + friend_action_finished.emit(success, message.left(120)) + + +func _public_friend_capabilities(capabilities: Dictionary) -> Dictionary: + return { + "presence_channel": str(capabilities.get("presence_channel", "")), + "invite_token": str(capabilities.get("invite_token", "")), + } + + +func _valid_public_friend_capabilities(capabilities: Dictionary) -> bool: + return ( + _valid_friend_hex(str(capabilities.get("presence_channel", ""))) + and _valid_friend_hex(str(capabilities.get("invite_token", ""))) + ) + + +func _valid_friend_request_id(request_id: String) -> bool: + return _valid_friend_hex(request_id, FRIEND_REQUEST_ID_LENGTH) + + +func _valid_friend_hex( + value: String, + expected_length: int = PlayerRelationshipStore.SOCIAL_TOKEN_LENGTH, +) -> bool: + if value.length() != expected_length: + return false + for character: String in value: + if character not in "0123456789abcdef": + return false + return true + + func _valid_moderation_target( peer_id: int, fingerprint: String, diff --git a/network/network_protocol.gd b/network/network_protocol.gd index 9a30a27..6bbbf9b 100644 --- a/network/network_protocol.gd +++ b/network/network_protocol.gd @@ -3,7 +3,7 @@ extends RefCounted const WorldLayoutType = preload("res://world/world_layout.gd") -const PROTOCOL_VERSION: int = 9 +const PROTOCOL_VERSION: int = 10 const GAME_BUILD: String = "prealpha" const MAX_GAME_VERSION_LENGTH: int = 64 const MAX_DISPLAY_NAME_LENGTH: int = 24 @@ -13,14 +13,24 @@ const MAX_PUBLIC_KEY_LENGTH: int = 8192 const MAX_SIGNATURE_LENGTH: int = 2048 # ENet channels: 0 reliable lifecycle, 1 movement input, 2 movement # snapshots, 3 fishing input, 4 fishing snapshots, 5 reliable sales, -# 6 reliable shop transactions, 7 reliable item/equipment/showcase/drawing, -# 8 reliable ordered session chat, 9 reliable private session mail. +# 6 reliable shop transactions, 7 reliable item/equipment, +# 8 reliable ordered session chat, 9 reliable private session mail, +# 10 observer-only fishing summaries, 11 reliable movement animation state, +# 12 reliable fish showcase state, 13 reliable world-spawn events, +# 14 reliable artwork state, 15 world-spawn snapshots, and 16 reliable +# fishing lifecycle. Bulk world/art payloads must not head-of-line block held +# items, fishing, or animation presentation. const SALE_RELIABLE_CHANNEL: int = 5 const SHOP_RELIABLE_CHANNEL: int = 6 const ITEM_RELIABLE_CHANNEL: int = 7 const CHAT_RELIABLE_CHANNEL: int = 8 const MAIL_RELIABLE_CHANNEL: int = 9 -const ENET_CHANNEL_COUNT: int = 10 +const MOVEMENT_ANIMATION_CHANNEL: int = 11 +const SHOWCASE_RELIABLE_CHANNEL: int = 12 +const WORLD_SPAWN_RELIABLE_CHANNEL: int = 13 +const DRAWING_RELIABLE_CHANNEL: int = 14 +const FISHING_RELIABLE_CHANNEL: int = 16 +const ENET_CHANNEL_COUNT: int = 17 const SURFACE_DRAWING_CAPABILITY: String = "surface_drawing_v2" const ART_SHOP_CAPABILITY: String = "art_shop_v1" const BACKPACK_SHOP_CAPABILITY: String = "backpack_shop_v1" @@ -32,6 +42,9 @@ const WORLD_SPAWN_CAPABILITY: String = "world_spawn_envelope_v1" const APPEARANCE_PREVIEW_CAPABILITY: String = "appearance_preview_v1" const WORLD_GENERATION_CAPABILITY: String = "world_generation_v1" const WORLD_LAYOUT_CAPABILITY: String = "world_layout_v1" +const FRIENDS_CAPABILITY: String = "friends_v1" +const MOVEMENT_RECONCILIATION_CAPABILITY: String = "movement_reconciliation_v2" +const FISHING_REPLICATION_CAPABILITY: String = "fishing_replication_v2" const DEFAULT_WORLD_SEED: int = 13001 const MAX_WORLD_SEED: int = 2147483646 @@ -186,6 +199,9 @@ static func make_client_hello( BACKPACK_SHOP_CAPABILITY, WORLD_GENERATION_CAPABILITY, WORLD_LAYOUT_CAPABILITY, + FRIENDS_CAPABILITY, + MOVEMENT_RECONCILIATION_CAPABILITY, + FISHING_REPLICATION_CAPABILITY, ]), "cosmetic_snapshot": cosmetic_snapshot, "identity_fingerprint": identity_fingerprint, @@ -323,6 +339,9 @@ static func make_server_hello( APPEARANCE_PREVIEW_CAPABILITY, WORLD_GENERATION_CAPABILITY, WORLD_LAYOUT_CAPABILITY, + FRIENDS_CAPABILITY, + MOVEMENT_RECONCILIATION_CAPABILITY, + FISHING_REPLICATION_CAPABILITY, "chat_v1", "mail_v1", "profile_v1", diff --git a/network/network_sale_service.gd b/network/network_sale_service.gd index 38af917..a80f221 100644 --- a/network/network_sale_service.gd +++ b/network/network_sale_service.gd @@ -14,6 +14,7 @@ const PlayerBagType = preload("res://inventory/player_bag.gd") const ItemResalePolicyType = preload("res://economy/item_resale_policy.gd") const MAX_LEDGER_ENTRIES_PER_PEER: int = 64 +const LOCAL_REQUEST_RETRY_SECONDS: float = 2.0 const PELICAN_BUYER_ID: StringName = &"pelicans" const MAIN_SHOP_BUYER_ID: StringName = &"main_fishing_shop" @@ -45,6 +46,8 @@ var _acknowledged_results: Dictionary[String, bool] = {} var _applied_results: Dictionary[String, bool] = {} var _received_results: Dictionary[String, bool] = {} var _pending_local_request_id: String = "" +var _pending_local_request: Dictionary = {} +var _local_request_retry_elapsed: float = 0.0 var _pending_local_catch_ids: Array[StringName] = [] var _pending_local_items: Array[Dictionary] = [] var _pending_local_buyer_id: StringName @@ -52,6 +55,25 @@ var _reservations: PlayerAssetReservationService var _inventory_layout: PlayerInventoryLayout +func _process(delta: float) -> void: + if ( + _pending_local_request.is_empty() + or _session == null + or not _session.is_joined_client() + ): + _local_request_retry_elapsed = 0.0 + return + _local_request_retry_elapsed += delta + if _local_request_retry_elapsed < LOCAL_REQUEST_RETRY_SECONDS: + return + _local_request_retry_elapsed = 0.0 + # The request ID is stable and the host ledger is idempotent, so an + # application-level retry cannot apply a sale twice. This covers the narrow + # transition where a generated client has authenticated but is only just + # resuming regular multiplayer polling. + submit_sale_request.rpc_id(1, _pending_local_request) + + func setup( session: NetworkSession, spawn_service: PlayerSpawnService, @@ -208,6 +230,8 @@ func request_local_mixed_sale( "items": item_evidence, } _pending_local_request_id = request_id + _pending_local_request = request.duplicate(true) + _local_request_retry_elapsed = 0.0 _pending_local_catch_ids = catch_ids.duplicate() _pending_local_items = item_evidence.duplicate(true) _pending_local_buyer_id = buyer.id @@ -595,6 +619,8 @@ func _finish_local_sale( payout: int, ) -> void: _pending_local_request_id = "" + _pending_local_request.clear() + _local_request_retry_elapsed = 0.0 _pending_local_catch_ids.clear() _pending_local_items.clear() _pending_local_buyer_id = StringName() @@ -730,6 +756,8 @@ func _clear_session_state() -> void: _applied_results.clear() _received_results.clear() _pending_local_request_id = "" + _pending_local_request.clear() + _local_request_retry_elapsed = 0.0 _pending_local_catch_ids.clear() _pending_local_items.clear() _pending_local_buyer_id = StringName() diff --git a/network/network_session.gd b/network/network_session.gd index 4d38629..11217d7 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -7,13 +7,50 @@ const DEFAULT_SESSION_MAX_PLAYERS: int = 8 const DEFAULT_TRANSPORT_MAX_CLIENTS: int = 31 const CONNECTION_TIMEOUT_SECONDS: float = 10.0 const AUTHENTICATION_TIMEOUT_SECONDS: float = 60.0 +const ENET_TIMEOUT_LIMIT: int = 32 +const ENET_TIMEOUT_MINIMUM_MS: int = 10000 +const ENET_TIMEOUT_MAXIMUM_MS: int = 120000 const INPUT_INTERVAL: float = 1.0 / 30.0 +const IDLE_INPUT_INTERVAL: float = 1.0 / 5.0 const SNAPSHOT_INTERVAL: float = 1.0 / 30.0 +const NEAR_REMOTE_SNAPSHOT_DIVISOR: int = 2 +const FAR_REMOTE_SNAPSHOT_DIVISOR: int = 6 +const DISTANT_REMOTE_SNAPSHOT_DIVISOR: int = 8 +const NEAR_REMOTE_DISTANCE: float = 48.0 +const FAR_REMOTE_DISTANCE: float = 96.0 const MOVEMENT_SNAPSHOT_BATCH_SIZE: int = 8 -const MOVEMENT_SNAPSHOT_FIELD_COUNT: int = 13 +const MOVEMENT_INPUT_FIELD_COUNT: int = 8 +const MOVEMENT_SNAPSHOT_FIELD_COUNT: int = 6 +const MOVEMENT_ANIMATION_FIELD_COUNT: int = 7 +const MOVEMENT_ANIMATION_ACTION_FIELD_COUNT: int = 5 +const MAX_PENDING_MOVEMENT_INPUTS: int = 96 +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 MOVEMENT_FLAG_JUMP: int = 1 << 0 +const MOVEMENT_FLAG_SPRINT: int = 1 << 1 +const MOVEMENT_FLAG_SNEAK: int = 1 << 2 +const MOVEMENT_FLAG_SLOW_WALK: int = 1 << 3 +const MOVEMENT_FLAG_SITTING: int = 1 << 4 +const MOVEMENT_FLAG_CASTING: int = 1 << 5 +const MOVEMENT_ALLOWED_FLAGS: int = ( + MOVEMENT_FLAG_JUMP + | MOVEMENT_FLAG_SPRINT + | MOVEMENT_FLAG_SNEAK + | MOVEMENT_FLAG_SLOW_WALK + | MOVEMENT_FLAG_SITTING + | MOVEMENT_FLAG_CASTING +) +const SNAPSHOT_FLAG_GROUNDED: int = 1 << 0 +const SNAPSHOT_FLAG_SITTING: int = 1 << 1 +const SNAPSHOT_FLAG_CASTING: int = 1 << 2 +const SNAPSHOT_ALLOWED_FLAGS: int = ( + SNAPSHOT_FLAG_GROUNDED + | SNAPSHOT_FLAG_SITTING + | SNAPSHOT_FLAG_CASTING +) + signal state_changed(state: State) signal status_message_changed(message: String) signal connection_error(message: String) @@ -80,7 +117,21 @@ var _client_nonce: String = "" var _current_route: ConnectionRoute var _input_sequence: int = 0 var _input_accumulator: float = 0.0 +var _idle_input_accumulator: float = 0.0 +var _last_input_state_hash: int = 0 +var _pending_movement_inputs: Array[Dictionary] = [] var _snapshot_accumulator: float = 0.0 +var _movement_snapshot_tick: 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] = {} +var _last_local_animation_action_signature: Array = [] +var _movement_inputs_sent: int = 0 +var _movement_inputs_received: int = 0 +var _movement_snapshot_packets_sent: int = 0 +var _movement_snapshot_states_sent: int = 0 +var _movement_animation_packets_sent: int = 0 +var _movement_animation_states_sent: int = 0 var _last_server_max_players: int = DEFAULT_SESSION_MAX_PLAYERS var _last_server_player_count: int = 0 var _last_server_display_name: String = "" @@ -278,6 +329,9 @@ func _register_player_host() -> void: NetworkProtocol.BACKPACK_SHOP_CAPABILITY, NetworkProtocol.WORLD_GENERATION_CAPABILITY, NetworkProtocol.WORLD_LAYOUT_CAPABILITY, + NetworkProtocol.FRIENDS_CAPABILITY, + NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY, + NetworkProtocol.FISHING_REPLICATION_CAPABILITY, ]), ) _registry.update_appearance(1, _local_appearance_snapshot) @@ -602,6 +656,9 @@ func supports_server_capability(capability: StringName) -> bool: NetworkProtocol.APPEARANCE_PREVIEW_CAPABILITY, NetworkProtocol.WORLD_GENERATION_CAPABILITY, NetworkProtocol.WORLD_LAYOUT_CAPABILITY, + NetworkProtocol.FRIENDS_CAPABILITY, + NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY, + NetworkProtocol.FISHING_REPLICATION_CAPABILITY, "chat_v1", "mail_v1", "profile_v1", @@ -649,6 +706,18 @@ func get_peer_rtt_ms(peer_id: int) -> int: return int(packet_peer.get_statistic(ENetPacketPeer.PEER_ROUND_TRIP_TIME)) +func get_movement_metrics() -> Dictionary: + return { + "inputs_sent": _movement_inputs_sent, + "inputs_received": _movement_inputs_received, + "snapshot_packets_sent": _movement_snapshot_packets_sent, + "snapshot_states_sent": _movement_snapshot_states_sent, + "animation_packets_sent": _movement_animation_packets_sent, + "animation_states_sent": _movement_animation_states_sent, + "pending_local_inputs": _pending_movement_inputs.size(), + } + + func kick_authenticated_peer( peer_id: int, fingerprint: String, @@ -813,6 +882,10 @@ func get_local_peer_id() -> int: return multiplayer.get_unique_id() if is_gameplay_session_active() else 0 +func get_latest_movement_input_sequence() -> int: + return _input_sequence if state == State.JOINED_CLIENT else 0 + + func _process(delta: float) -> void: var now: float = Time.get_ticks_msec() / 1000.0 if ( @@ -833,9 +906,13 @@ func _process(delta: float) -> void: return _input_accumulator += delta _snapshot_accumulator += delta + _animation_refresh_accumulator += delta if state == State.JOINED_CLIENT and _input_accumulator >= INPUT_INTERVAL: + var elapsed_input_time: float = _input_accumulator _input_accumulator = fmod(_input_accumulator, INPUT_INTERVAL) - _send_local_input() + _idle_input_accumulator += elapsed_input_time + _maybe_send_local_animation_action() + _maybe_send_local_input() if is_host() and _snapshot_accumulator >= SNAPSHOT_INTERVAL: _snapshot_accumulator = fmod(_snapshot_accumulator, SNAPSHOT_INTERVAL) _broadcast_movement_snapshots() @@ -873,6 +950,7 @@ func _on_peer_connected(peer_id: int) -> void: if state != State.OPEN_HOST: multiplayer.multiplayer_peer.disconnect_peer(peer_id) return + _configure_enet_peer_timeout(peer_id) _pending_authentication[peer_id] = ( Time.get_ticks_msec() / 1000.0 + AUTHENTICATION_TIMEOUT_SECONDS ) @@ -881,6 +959,7 @@ func _on_peer_connected(peer_id: int) -> void: func _on_connected_to_server() -> void: if state != State.CONNECTING: return + _configure_enet_peer_timeout(1) _set_state(State.AUTHENTICATING, "Authenticating...") _client_nonce = NetworkIdentityCrypto.secure_id(32) _client_identity_attempt = NetworkProtocol.make_identity_hello( @@ -892,6 +971,24 @@ func _on_connected_to_server() -> void: submit_identity_hello.rpc_id(1, _client_identity_attempt) +func _configure_enet_peer_timeout(peer_id: int) -> void: + var enet := multiplayer.multiplayer_peer as ENetMultiplayerPeer + if enet == null: + return + var packet_peer: ENetPacketPeer = enet.get_peer(peer_id) + if packet_peer == null: + return + # A deterministic generated world can temporarily occupy the client's main + # thread during the authenticated join transition. Keep that bounded load + # from looking like a dead connection while retaining normal ENet liveness + # checks once the client resumes polling. + packet_peer.set_timeout( + ENET_TIMEOUT_LIMIT, + ENET_TIMEOUT_MINIMUM_MS, + ENET_TIMEOUT_MAXIMUM_MS, + ) + + func _on_connection_failed() -> void: if state not in [ State.CONNECTING, @@ -931,6 +1028,8 @@ func _on_peer_disconnected(peer_id: int) -> void: _pending_identity_challenges.erase(peer_id) _authenticated_identity_cache.erase(peer_id) _operator_peer_ids.erase(peer_id) + _last_animation_state_by_peer.erase(peer_id) + _pending_animation_state_by_peer.erase(peer_id) var recovery_attempt: String = _recovery_attempts.get(peer_id, "") if not recovery_attempt.is_empty(): _recovery_attempts.erase(peer_id) @@ -1209,6 +1308,16 @@ func submit_client_hello(data: Dictionary) -> void: NetworkProtocol.RejectionCode.UNSUPPORTED_CLIENT, ) return + for required_capability: String in [ + NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY, + NetworkProtocol.FISHING_REPLICATION_CAPABILITY, + ]: + if required_capability not in client_capabilities: + _reject_peer( + sender_id, + NetworkProtocol.RejectionCode.UNSUPPORTED_CLIENT, + ) + return if ( _host_world_layout == WorldLayout.STARTER_ISLAND and NetworkProtocol.WORLD_LAYOUT_CAPABILITY not in client_capabilities @@ -1432,6 +1541,14 @@ func receive_server_hello(data: Dictionary) -> void: _teardown_peer() _fail("This server does not support generated worlds.") return + for required_capability: String in [ + NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY, + NetworkProtocol.FISHING_REPLICATION_CAPABILITY, + ]: + if required_capability not in _server_capabilities: + _teardown_peer() + _fail("This server is missing required network capabilities.") + return if ( received_world_layout == WorldLayout.STARTER_ISLAND and NetworkProtocol.WORLD_LAYOUT_CAPABILITY not in _server_capabilities @@ -1456,6 +1573,9 @@ func receive_server_hello(data: Dictionary) -> void: NetworkProtocol.BACKPACK_SHOP_CAPABILITY, NetworkProtocol.WORLD_GENERATION_CAPABILITY, NetworkProtocol.WORLD_LAYOUT_CAPABILITY, + NetworkProtocol.FRIENDS_CAPABILITY, + NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY, + NetworkProtocol.FISHING_REPLICATION_CAPABILITY, ]), ) _registry.update_appearance(local_peer_id, _local_appearance_snapshot) @@ -1501,6 +1621,8 @@ func receive_peer_despawn(peer_id: int) -> void: if state != State.JOINED_CLIENT: return _operator_peer_ids.erase(peer_id) + _last_animation_state_by_peer.erase(peer_id) + _pending_animation_state_by_peer.erase(peer_id) _registry.remove_peer(peer_id) _spawn_service.remove_peer(peer_id) peer_removed.emit(peer_id) @@ -1542,6 +1664,10 @@ func _apply_spawn_entry(entry: Dictionary) -> void: or typeof(entry.get("display_name")) != TYPE_STRING or typeof(entry.get("position")) != TYPE_ARRAY or typeof(entry.get("yaw")) not in [TYPE_FLOAT, TYPE_INT] + or typeof(entry.get("sitting")) != TYPE_BOOL + or not NetworkPlayerAnimationProtocol.validate_state( + entry.get("animation_state") + ) ): return if not _verify_spawn_identity(entry): @@ -1557,6 +1683,7 @@ func _apply_spawn_entry(entry: Dictionary) -> void: ) own_snapshot["position"] = own_position own_snapshot["visual_yaw"] = float(entry["yaw"]) + own_snapshot["sitting"] = bool(entry["sitting"]) own_avatar.apply_network_teleport(own_snapshot) return var peer_was_added: bool = false @@ -1603,6 +1730,18 @@ func _apply_spawn_entry(entry: Dictionary) -> void: var record := _registry.get_peer(peer_id) if avatar != null and record != null: avatar.apply_appearance_snapshot(record.appearance_snapshot) + # Lifecycle and animation updates use separate reliable ENet channels. + # Include the current authoritative state in the spawn record so a peer + # joining during an action presents it immediately, regardless of which + # channel arrives first. + avatar.apply_network_animation_state(entry["animation_state"]) + avatar.apply_network_sitting_state(bool(entry["sitting"])) + var pending_animation: Dictionary = ( + _pending_animation_state_by_peer.get(peer_id, {}) + ) + if not pending_animation.is_empty(): + avatar.apply_network_animation_state(pending_animation) + _pending_animation_state_by_peer.erase(peer_id) if peer_was_added and record != null: peer_authenticated.emit(peer_id, record.display_name) @@ -1627,6 +1766,15 @@ func _make_spawn_entry( transform: Transform3D, ) -> Dictionary: var record: PeerRegistry.PeerRecord = _registry.get_peer(peer_id) + var avatar: Player = _spawn_service.get_avatar(peer_id) + var animation_state: Dictionary = ( + avatar.make_network_animation_state() + if avatar != null + else NetworkPlayerAnimationProtocol.make_state( + NetworkPlayerAnimationProtocol.LOCOMOTION_IDLE, + true, + ) + ) return { "peer_id": peer_id, "profile_id": record.profile_id if record != null else "", @@ -1637,6 +1785,12 @@ func _make_spawn_entry( transform.origin.z, ], "yaw": transform.basis.get_euler().y, + "sitting": ( + avatar.get_network_sitting_state() + if avatar != null + else false + ), + "animation_state": animation_state, "appearance": ( record.appearance_snapshot.duplicate(true) if record != null @@ -1862,14 +2016,69 @@ func _fail_identity(message: String) -> void: _fail(message) +func _maybe_send_local_input() -> void: + var peer_id: int = multiplayer.get_unique_id() + var avatar: Player = _spawn_service.get_avatar(peer_id) + if avatar == null: + return + var state_hash: int = avatar.get_network_input_state_hash() + var state_changed: bool = state_hash != _last_input_state_hash + if ( + avatar.has_active_network_input() + or state_changed + or _idle_input_accumulator >= IDLE_INPUT_INTERVAL + ): + _send_local_input() + _last_input_state_hash = state_hash + _idle_input_accumulator = 0.0 + + func _send_local_input() -> void: var peer_id: int = multiplayer.get_unique_id() var avatar: Player = _spawn_service.get_avatar(peer_id) if avatar == null: return - _input_sequence += 1 + _input_sequence = ( + 1 + if _input_sequence >= MAX_MOVEMENT_INPUT_SEQUENCE + else _input_sequence + 1 + ) var input: Dictionary = avatar.capture_network_input(_input_sequence) - submit_movement_input.rpc_id(1, input) + var encoded: Array = _encode_movement_input(input) + if encoded.is_empty(): + return + _pending_movement_inputs.append(input.duplicate(true)) + while _pending_movement_inputs.size() > MAX_PENDING_MOVEMENT_INPUTS: + _pending_movement_inputs.pop_front() + _movement_inputs_sent += 1 + submit_movement_input.rpc_id(1, encoded) + + +func _maybe_send_local_animation_action() -> void: + var peer_id: int = multiplayer.get_unique_id() + 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", {}) + if not NetworkPlayerAnimationProtocol.validate_action_state(action): + return + var signature: Array = [ + str(action["id"]), + int(action["sequence"]), + bool(action.get("paused", false)), + avatar.get_network_sitting_state(), + ] + if signature == _last_local_animation_action_signature: + return + var encoded: Array = _encode_movement_animation_action( + action, + avatar.get_network_sitting_state(), + ) + if encoded.is_empty(): + return + _last_local_animation_action_signature = signature + submit_movement_animation_action.rpc_id(1, encoded) func submit_neutral_local_movement() -> void: @@ -1879,17 +2088,112 @@ func submit_neutral_local_movement() -> void: @rpc("any_peer", "call_remote", "unreliable_ordered", 1) -func submit_movement_input(data: Dictionary) -> void: +func submit_movement_input(encoded: Array) -> void: var sender_id: int = multiplayer.get_remote_sender_id() if not is_host() or not _registry.has_peer(sender_id): return var avatar: Player = _spawn_service.get_avatar(sender_id) - if avatar == null or not _is_valid_movement_input(data): + var data: Dictionary = _decode_movement_input(encoded) + if avatar == null or data.is_empty(): return - avatar.apply_authoritative_network_input(data) + _movement_inputs_received += 1 + avatar.apply_authoritative_network_input( + data, + Player.resolve_network_input_stale_timeout_seconds( + get_peer_rtt_ms(sender_id) + ), + ) -func _is_valid_movement_input(data: Dictionary) -> bool: +@rpc( + "any_peer", + "call_remote", + "reliable", + NetworkProtocol.MOVEMENT_ANIMATION_CHANNEL, +) +func submit_movement_animation_action(encoded: Array) -> void: + var sender_id: int = multiplayer.get_remote_sender_id() + 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(): + return + avatar.apply_authoritative_network_animation_action(state["action"]) + avatar.apply_authoritative_network_sitting_state(bool(state["sitting"])) + + +static func _encode_movement_input(data: Dictionary) -> Array: + if not _is_valid_movement_input(data): + return [] + var axis: Array = data["axis"] + var flags: int = 0 + if bool(data["jump"]): + flags |= MOVEMENT_FLAG_JUMP + if bool(data["sprint"]): + flags |= MOVEMENT_FLAG_SPRINT + if bool(data["sneak"]): + flags |= MOVEMENT_FLAG_SNEAK + if bool(data["slow_walk"]): + flags |= MOVEMENT_FLAG_SLOW_WALK + if bool(data["sitting"]): + flags |= MOVEMENT_FLAG_SITTING + if bool(data["casting"]): + flags |= MOVEMENT_FLAG_CASTING + var action: Dictionary = data["animation_action"] + return [ + int(data["sequence"]), + Vector2(float(axis[0]), float(axis[1])), + float(data["camera_yaw"]), + flags, + str(action["id"]), + int(action["sequence"]), + float(action["elapsed"]), + bool(action.get("paused", false)), + ] + + +static func _decode_movement_input(value: Variant) -> Dictionary: + if typeof(value) != TYPE_ARRAY: + return {} + var fields: Array = value + if ( + fields.size() != MOVEMENT_INPUT_FIELD_COUNT + or typeof(fields[0]) != TYPE_INT + or typeof(fields[1]) != TYPE_VECTOR2 + or typeof(fields[2]) not in [TYPE_FLOAT, TYPE_INT] + or typeof(fields[3]) != TYPE_INT + or typeof(fields[4]) not in [TYPE_STRING, TYPE_STRING_NAME] + or typeof(fields[5]) != TYPE_INT + or typeof(fields[6]) not in [TYPE_FLOAT, TYPE_INT] + or typeof(fields[7]) != TYPE_BOOL + ): + return {} + var axis: Vector2 = fields[1] + var flags: int = int(fields[3]) + if flags < 0 or flags & ~MOVEMENT_ALLOWED_FLAGS != 0: + return {} + var decoded: Dictionary = { + "sequence": int(fields[0]), + "axis": [axis.x, axis.y], + "camera_yaw": float(fields[2]), + "jump": bool(flags & MOVEMENT_FLAG_JUMP), + "sprint": bool(flags & MOVEMENT_FLAG_SPRINT), + "sneak": bool(flags & MOVEMENT_FLAG_SNEAK), + "slow_walk": bool(flags & MOVEMENT_FLAG_SLOW_WALK), + "sitting": bool(flags & MOVEMENT_FLAG_SITTING), + "casting": bool(flags & MOVEMENT_FLAG_CASTING), + "animation_action": NetworkPlayerAnimationProtocol.make_action_state( + StringName(str(fields[4])), + int(fields[5]), + float(fields[6]), + bool(fields[7]), + ), + } + return decoded if _is_valid_movement_input(decoded) else {} + + +static func _is_valid_movement_input(data: Dictionary) -> bool: if ( typeof(data.get("sequence")) != TYPE_INT or typeof(data.get("axis")) != TYPE_ARRAY @@ -1929,49 +2233,142 @@ func _is_valid_movement_input(data: Dictionary) -> bool: func _broadcast_movement_snapshots() -> void: - var snapshots: Array = [] - for peer_id: int in _registry.get_peer_ids(): - var avatar: Player = _spawn_service.get_avatar(peer_id) - if avatar == null: + _movement_snapshot_tick += 1 + var peer_ids: Array[int] = _registry.get_peer_ids() + for recipient_id: int in peer_ids: + # Peer 1 is the local listen-server player. Dedicated servers do not + # register a peer 1, so every remaining record is a remote recipient. + if recipient_id == 1: continue - var encoded: Array = _encode_movement_snapshot( - avatar.make_network_snapshot(peer_id) - ) - if not encoded.is_empty(): - snapshots.append(encoded) - # The compact v5 representation keeps a normal eight-player update near - # 1 KiB instead of the roughly 3.7 KiB dictionary representation. Rooms - # configured above the normal cap are divided into the same safe size. + var recipient_avatar: Player = _spawn_service.get_avatar(recipient_id) + if recipient_avatar == null: + continue + var snapshots: Array = [] + for subject_id: int in peer_ids: + var subject_avatar: Player = _spawn_service.get_avatar(subject_id) + if subject_avatar == null: + continue + if ( + subject_id != recipient_id + and not _should_send_remote_snapshot( + recipient_avatar.global_position, + subject_avatar.global_position, + ) + ): + continue + var encoded: Array = _encode_movement_snapshot( + subject_avatar.make_network_snapshot(subject_id) + ) + if not encoded.is_empty(): + snapshots.append(encoded) + _send_movement_snapshot_batches(recipient_id, snapshots) + _broadcast_movement_animation_updates(peer_ids) + + +func _should_send_remote_snapshot( + recipient_position: Vector3, + subject_position: Vector3, +) -> bool: + var distance_squared: float = recipient_position.distance_squared_to( + subject_position + ) + var divisor: int = DISTANT_REMOTE_SNAPSHOT_DIVISOR + if distance_squared <= NEAR_REMOTE_DISTANCE * NEAR_REMOTE_DISTANCE: + divisor = NEAR_REMOTE_SNAPSHOT_DIVISOR + elif distance_squared <= FAR_REMOTE_DISTANCE * FAR_REMOTE_DISTANCE: + divisor = FAR_REMOTE_SNAPSHOT_DIVISOR + return _movement_snapshot_tick % divisor == 0 + + +func _send_movement_snapshot_batches( + recipient_id: int, + snapshots: Array, +) -> void: for start_index: int in range( 0, snapshots.size(), MOVEMENT_SNAPSHOT_BATCH_SIZE, ): - receive_movement_snapshots.rpc( - snapshots.slice( - start_index, - mini( - start_index + MOVEMENT_SNAPSHOT_BATCH_SIZE, - snapshots.size(), - ), - ) + var batch: Array = snapshots.slice( + start_index, + mini( + start_index + MOVEMENT_SNAPSHOT_BATCH_SIZE, + snapshots.size(), + ), ) + receive_movement_snapshots.rpc_id(recipient_id, batch) + _movement_snapshot_packets_sent += 1 + _movement_snapshot_states_sent += batch.size() + + +func _broadcast_movement_animation_updates(peer_ids: Array[int]) -> void: + var refresh_all: bool = ( + _animation_refresh_accumulator >= ANIMATION_REFRESH_INTERVAL + ) + if refresh_all: + _animation_refresh_accumulator = fmod( + _animation_refresh_accumulator, + ANIMATION_REFRESH_INTERVAL, + ) + var updates: Array = [] + for subject_id: int in peer_ids: + var avatar: Player = _spawn_service.get_avatar(subject_id) + if avatar == null: + continue + var 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) + == _movement_animation_signature(previous) + ): + continue + _last_animation_state_by_peer[subject_id] = state.duplicate(true) + var encoded: Array = _encode_movement_animation(subject_id, state) + if not encoded.is_empty(): + updates.append(encoded) + if updates.is_empty(): + return + for recipient_id: int in peer_ids: + if recipient_id == 1: + continue + receive_movement_animations.rpc_id(recipient_id, updates) + _movement_animation_packets_sent += 1 + _movement_animation_states_sent += updates.size() + + +static func _movement_animation_signature(state: Dictionary) -> Array: + if not NetworkPlayerAnimationProtocol.validate_state(state): + return [] + var action: Dictionary = state["action"] + return [ + str(state["locomotion_id"]), + bool(state["grounded"]), + str(action["id"]), + int(action["sequence"]), + bool(action.get("paused", false)), + ] static func _encode_movement_snapshot(snapshot: Dictionary) -> Array: var position: Variant = snapshot.get("position") var snapshot_velocity: Variant = snapshot.get("velocity") - var animation_value: Variant = snapshot.get("animation_state") if ( typeof(position) != TYPE_ARRAY or position.size() != 3 or typeof(snapshot_velocity) != TYPE_ARRAY or snapshot_velocity.size() != 3 - or not NetworkPlayerAnimationProtocol.validate_state(animation_value) ): return [] - var animation: Dictionary = animation_value - var action: Dictionary = animation["action"] + var flags: int = 0 + if bool(snapshot.get("grounded", false)): + flags |= SNAPSHOT_FLAG_GROUNDED + if bool(snapshot.get("sitting", false)): + flags |= SNAPSHOT_FLAG_SITTING + if bool(snapshot.get("casting", false)): + flags |= SNAPSHOT_FLAG_CASTING return [ int(snapshot.get("peer_id", 0)), int(snapshot.get("acknowledged_input", 0)), @@ -1982,14 +2379,7 @@ static func _encode_movement_snapshot(snapshot: Dictionary) -> Array: float(snapshot_velocity[2]), ), float(snapshot.get("visual_yaw", 0.0)), - str(animation["locomotion_id"]), - bool(animation["grounded"]), - str(action["id"]), - int(action["sequence"]), - float(action["elapsed"]), - bool(action.get("paused", false)), - bool(snapshot.get("sitting", false)), - bool(snapshot.get("casting", false)), + flags, ] @@ -2004,18 +2394,22 @@ static func _decode_movement_snapshot(value: Variant) -> Dictionary: or typeof(fields[2]) != TYPE_VECTOR3 or typeof(fields[3]) != TYPE_VECTOR3 or typeof(fields[4]) not in [TYPE_FLOAT, TYPE_INT] - or typeof(fields[5]) not in [TYPE_STRING, TYPE_STRING_NAME] - or typeof(fields[6]) != TYPE_BOOL - or typeof(fields[7]) not in [TYPE_STRING, TYPE_STRING_NAME] - or typeof(fields[8]) != TYPE_INT - or typeof(fields[9]) not in [TYPE_FLOAT, TYPE_INT] - or typeof(fields[10]) != TYPE_BOOL - or typeof(fields[11]) != TYPE_BOOL - or typeof(fields[12]) != TYPE_BOOL + or typeof(fields[5]) != TYPE_INT ): return {} var position: Vector3 = fields[2] var snapshot_velocity: Vector3 = fields[3] + var flags: int = int(fields[5]) + if ( + int(fields[0]) <= 0 + or int(fields[1]) < 0 + or not position.is_finite() + or not snapshot_velocity.is_finite() + or not is_finite(float(fields[4])) + or flags < 0 + or flags & ~SNAPSHOT_ALLOWED_FLAGS != 0 + ): + return {} return { "peer_id": int(fields[0]), "acknowledged_input": int(fields[1]), @@ -2026,19 +2420,100 @@ static func _decode_movement_snapshot(value: Variant) -> Dictionary: snapshot_velocity.z, ], "visual_yaw": float(fields[4]), - "animation_state": NetworkPlayerAnimationProtocol.make_state( - StringName(str(fields[5])), - bool(fields[6]), - StringName(str(fields[7])), - int(fields[8]), - float(fields[9]), - bool(fields[10]), - ), - "sitting": bool(fields[11]), - "casting": bool(fields[12]), + "grounded": bool(flags & SNAPSHOT_FLAG_GROUNDED), + "sitting": bool(flags & SNAPSHOT_FLAG_SITTING), + "casting": bool(flags & SNAPSHOT_FLAG_CASTING), } +static func _encode_movement_animation( + peer_id: int, + state: Dictionary, +) -> Array: + if not NetworkPlayerAnimationProtocol.validate_state(state): + return [] + var action: Dictionary = state["action"] + return [ + peer_id, + str(state["locomotion_id"]), + bool(state["grounded"]), + str(action["id"]), + int(action["sequence"]), + float(action["elapsed"]), + bool(action.get("paused", false)), + ] + + +static func _decode_movement_animation(value: Variant) -> Dictionary: + if typeof(value) != TYPE_ARRAY: + return {} + var fields: Array = value + if ( + fields.size() != MOVEMENT_ANIMATION_FIELD_COUNT + or typeof(fields[0]) != TYPE_INT + or typeof(fields[1]) not in [TYPE_STRING, TYPE_STRING_NAME] + or typeof(fields[2]) != TYPE_BOOL + or typeof(fields[3]) not in [TYPE_STRING, TYPE_STRING_NAME] + or typeof(fields[4]) != TYPE_INT + or typeof(fields[5]) not in [TYPE_FLOAT, TYPE_INT] + or typeof(fields[6]) != TYPE_BOOL + ): + return {} + var state: Dictionary = NetworkPlayerAnimationProtocol.make_state( + StringName(str(fields[1])), + bool(fields[2]), + StringName(str(fields[3])), + int(fields[4]), + float(fields[5]), + bool(fields[6]), + ) + if ( + int(fields[0]) <= 0 + or not NetworkPlayerAnimationProtocol.validate_state(state) + ): + return {} + return {"peer_id": int(fields[0]), "state": state} + + +static func _encode_movement_animation_action( + action: Dictionary, + sitting: bool, +) -> Array: + if not NetworkPlayerAnimationProtocol.validate_action_state(action): + return [] + return [ + str(action["id"]), + int(action["sequence"]), + float(action["elapsed"]), + bool(action.get("paused", false)), + sitting, + ] + + +static func _decode_movement_animation_action(value: Variant) -> Dictionary: + if typeof(value) != TYPE_ARRAY: + return {} + var fields: Array = value + if ( + fields.size() != MOVEMENT_ANIMATION_ACTION_FIELD_COUNT + or typeof(fields[0]) not in [TYPE_STRING, TYPE_STRING_NAME] + or typeof(fields[1]) != TYPE_INT + or typeof(fields[2]) not in [TYPE_FLOAT, TYPE_INT] + or typeof(fields[3]) != TYPE_BOOL + or typeof(fields[4]) != TYPE_BOOL + ): + return {} + var action: Dictionary = NetworkPlayerAnimationProtocol.make_action_state( + StringName(str(fields[0])), + int(fields[1]), + float(fields[2]), + bool(fields[3]), + ) + if not NetworkPlayerAnimationProtocol.validate_action_state(action): + return {} + return {"action": action, "sitting": bool(fields[4])} + + @rpc("authority", "call_remote", "unreliable_ordered", 2) func receive_movement_snapshots(encoded_snapshots: Array) -> void: if state != State.JOINED_CLIENT: @@ -2058,11 +2533,13 @@ func receive_movement_snapshots(encoded_snapshots: Array) -> void: if avatar == null: continue if peer_id == local_peer_id: + _discard_acknowledged_movement_inputs( + int(snapshot.get("acknowledged_input", 0)) + ) avatar.apply_local_prediction_correction( snapshot, - _input_sequence, + _pending_movement_inputs, INPUT_INTERVAL, - estimated_transit_seconds, ) else: avatar.push_network_snapshot( @@ -2071,6 +2548,39 @@ func receive_movement_snapshots(encoded_snapshots: Array) -> void: ) +func _discard_acknowledged_movement_inputs(acknowledged_sequence: int) -> void: + while ( + not _pending_movement_inputs.is_empty() + and int(_pending_movement_inputs[0].get("sequence", 0)) + <= acknowledged_sequence + ): + _pending_movement_inputs.pop_front() + + +@rpc( + "authority", + "call_remote", + "reliable", + NetworkProtocol.MOVEMENT_ANIMATION_CHANNEL, +) +func receive_movement_animations(encoded_states: Array) -> void: + if state != State.JOINED_CLIENT: + return + for value: Variant in encoded_states: + var decoded: Dictionary = _decode_movement_animation(value) + if decoded.is_empty(): + continue + var avatar: Player = _spawn_service.get_avatar( + int(decoded["peer_id"]) + ) + if avatar == null: + _pending_animation_state_by_peer[int(decoded["peer_id"])] = ( + Dictionary(decoded["state"]).duplicate(true) + ) + continue + avatar.apply_network_animation_state(decoded["state"]) + + func _estimated_movement_transit_seconds() -> float: if state != State.JOINED_CLIENT: return -1.0 @@ -2346,7 +2856,15 @@ func _teardown_peer() -> void: _connection_deadline = 0.0 _input_sequence = 0 _input_accumulator = 0.0 + _idle_input_accumulator = 0.0 + _last_input_state_hash = 0 + _pending_movement_inputs.clear() _snapshot_accumulator = 0.0 + _movement_snapshot_tick = 0 + _animation_refresh_accumulator = 0.0 + _last_animation_state_by_peer.clear() + _pending_animation_state_by_peer.clear() + _last_local_animation_action_signature.clear() _server_capabilities = PackedStringArray() _server_identity_fingerprint = "" _server_identity_public_key = "" diff --git a/network/network_surface_drawing_service.gd b/network/network_surface_drawing_service.gd index 10348aa..4edc234 100644 --- a/network/network_surface_drawing_service.gd +++ b/network/network_surface_drawing_service.gd @@ -52,6 +52,7 @@ var _canvas_states: Dictionary[String, Dictionary] = {} var _canvas_nodes: Dictionary[String, SurfaceDrawingCanvas] = {} var _selected_canvas_id: String = "" var _hovered_canvas_id: String = "" +var _last_hovered_canvas_id: String = "" var _brush_preview: MultiMeshInstance3D var _brush_preview_multimesh: MultiMesh var _brush_preview_material: ShaderMaterial @@ -236,6 +237,7 @@ func activate( return _active = true _placing_grid = false + _last_hovered_canvas_id = "" _clear_stamp_mode() _eraser_mode = false _clear_armed_guide_action(false) @@ -267,6 +269,7 @@ func deactivate() -> void: _reset_stroke() _selected_canvas_id = "" _hovered_canvas_id = "" + _last_hovered_canvas_id = "" _aim_hit.clear() _hide_previews() _refresh_stencil_visibility() @@ -849,10 +852,22 @@ func select_saved_stamp(path: String) -> bool: func export_aimed_canvas() -> String: _update_aim() - if _hovered_canvas_id.is_empty(): + var canvas_id: String = ( + _hovered_canvas_id + if not _hovered_canvas_id.is_empty() + else _last_hovered_canvas_id + ) + var canvas: SurfaceDrawingCanvas = _canvas_nodes.get(canvas_id) + if ( + canvas_id.is_empty() + or canvas == null + or not is_instance_valid(canvas) + or canvas.is_hidden_by_relationship() + ): + _last_hovered_canvas_id = "" _emit_hud_state("aim at artwork before exporting") return "" - return export_canvas_png(_hovered_canvas_id) + return export_canvas_png(canvas_id) func export_canvas_png(canvas_id: String) -> String: @@ -1070,6 +1085,8 @@ func _update_aim() -> void: selected_layer = layer _selected_canvas_id = found_canvas_id _hovered_canvas_id = found_hovered_canvas_id + if not _hovered_canvas_id.is_empty(): + _last_hovered_canvas_id = _hovered_canvas_id if previous_canvas_id != _selected_canvas_id: _reset_stroke() _update_previews() @@ -2865,6 +2882,7 @@ func _clear_session_artwork_state() -> void: _canvas_sequence = 0 _selected_canvas_id = "" _hovered_canvas_id = "" + _last_hovered_canvas_id = "" _stroke_history_by_peer.clear() _cell_last_stroke.clear() _last_local_stroke_id = "" @@ -2900,6 +2918,8 @@ func _remove_canvas_state(canvas_id: String) -> void: _selected_canvas_id = "" if _hovered_canvas_id == canvas_id: _hovered_canvas_id = "" + if _last_hovered_canvas_id == canvas_id: + _last_hovered_canvas_id = "" func _emit_artwork_changed() -> void: diff --git a/network/network_transport.gd b/network/network_transport.gd index ce68172..5aed6ca 100644 --- a/network/network_transport.gd +++ b/network/network_transport.gd @@ -22,6 +22,25 @@ func connect_to_route(_route: ConnectionRoute) -> Error: func disconnect_transport() -> void: if _peer != null: + # Give ENet one poll cycle to emit its graceful disconnect packets before + # closing the socket. This keeps a host from retaining a departed player + # until the extended generated-world liveness timeout expires. + if ( + _peer is ENetMultiplayerPeer + and _peer.get_connection_status() + == MultiplayerPeer.CONNECTION_CONNECTED + ): + var enet_peer := _peer as ENetMultiplayerPeer + # A host explicitly notifies each still-live client before closing. + # Clients rely on ENetMultiplayerPeer.close(); their cached SceneTree + # peer list can briefly outlive the underlying ENet peer after a remote + # disconnect, making disconnect_peer() race and report an engine error. + if enet_peer.get_unique_id() == 1: + for peer_id: int in multiplayer.get_peers(): + if enet_peer.get_peer(peer_id) != null: + enet_peer.disconnect_peer(peer_id, false) + if enet_peer.host != null: + enet_peer.host.flush() _peer.close() _peer = null _route_description = "" diff --git a/network/network_world_spawn_protocol.gd b/network/network_world_spawn_protocol.gd index 4d0a55d..4bd15b0 100644 --- a/network/network_world_spawn_protocol.gd +++ b/network/network_world_spawn_protocol.gd @@ -3,8 +3,8 @@ extends RefCounted const CAPABILITY: StringName = &"world_spawn_envelope_v1" const ENVELOPE_VERSION: int = 1 -const RELIABLE_CHANNEL: int = NetworkProtocol.ITEM_RELIABLE_CHANNEL -const SNAPSHOT_CHANNEL: int = 4 +const RELIABLE_CHANNEL: int = NetworkProtocol.WORLD_SPAWN_RELIABLE_CHANNEL +const SNAPSHOT_CHANNEL: int = 15 const MAX_SESSION_ID_LENGTH: int = 96 const MAX_EVENT_ID_LENGTH: int = 48 const MAX_ENTITY_ID_LENGTH: int = 128 diff --git a/network/network_world_spawn_service.gd b/network/network_world_spawn_service.gd index db4afad..87814ee 100644 --- a/network/network_world_spawn_service.gd +++ b/network/network_world_spawn_service.gd @@ -16,7 +16,9 @@ const CalendarSeasonType = preload("res://world/calendar_season.gd") signal local_capture_received(fish_catch: FishCatch) signal local_interaction_finished(accepted: bool, message: String) -const SNAPSHOT_INTERVAL_SECONDS: float = 0.2 +const SIMULATION_INTERVAL_SECONDS: float = 1.0 / 15.0 +const SNAPSHOT_INTERVAL_SECONDS: float = 0.1 +const PLAYER_SPATIAL_CELL_SIZE: float = 8.0 const CAPACITY_RESPONSE_TIMEOUT_SECONDS: float = 3.0 const MAX_REQUEST_ID_LENGTH: int = 128 const MAX_LEDGER_ENTRIES: int = 96 @@ -49,8 +51,16 @@ var _pending_captures: Dictionary = {} var _received_results: Dictionary = {} var _showcase_deadlines: Dictionary = {} var _envelope_sequence: int = 0 +var _simulation_elapsed: float = 0.0 var _snapshot_elapsed: float = 0.0 var _population_session_id: String = "" +var _dirty_entity_ids: Dictionary[String, bool] = {} +var _moving_players_by_cell: Dictionary[Vector2i, Array] = {} +var _presentation_enabled: bool = true +var _spawn_events_sent: int = 0 +var _despawn_events_sent: int = 0 +var _snapshot_packets_sent: int = 0 +var _snapshot_states_sent: int = 0 var _rng := RandomNumberGenerator.new() @@ -68,6 +78,7 @@ func setup( save_manager: PlayerSaveManager, item_use: NetworkItemUseService, world_time: WorldTimeService = null, + presentation_enabled: bool = true, ) -> void: _session = session _spawn_service = spawn_service @@ -82,6 +93,7 @@ func setup( _save_manager = save_manager _item_use = item_use _world_time = world_time + _presentation_enabled = presentation_enabled _rng.randomize() _session.peer_authenticated.connect(_on_peer_authenticated) _session.peer_removed.connect(_on_peer_removed) @@ -136,6 +148,7 @@ func finish_local_interaction( "target_position": NetworkWorldSpawnProtocol.vector3_to_array( target_position ), + "movement_sequence": _session.get_latest_movement_input_sequence(), } if _session.is_host(): _handle_interaction_finish(_session.get_local_peer_id(), data) @@ -217,6 +230,13 @@ func get_entry_for_entity(entity_id: String) -> GatherableDataType: return state.get("data") as GatherableDataType +func refresh_world_context() -> void: + _clear_world() + _population_session_id = "" + if _session != null and _session.is_gameplay_session_active(): + _begin_population_if_ready.call_deferred() + + func _physics_process(delta: float) -> void: if ( _session == null @@ -225,11 +245,27 @@ func _physics_process(delta: float) -> void: ): return _begin_population_if_ready() + # There is no gameplay state to advance while a dedicated server has no + # spawned players. Pausing the roaming/respawn loop also prevents idle + # gatherables from producing a continuous stream of snapshots to nobody. + if _spawn_service == null or _spawn_service.get_peer_ids().is_empty(): + _simulation_elapsed = 0.0 + _snapshot_elapsed = 0.0 + return + _simulation_elapsed += delta + _snapshot_elapsed += delta + if _simulation_elapsed < SIMULATION_INTERVAL_SECONDS: + return + var simulation_delta: float = minf(_simulation_elapsed, 0.2) + _simulation_elapsed = fmod( + _simulation_elapsed, + SIMULATION_INTERVAL_SECONDS, + ) _update_pending_capture_timeouts() _update_respawns() _update_showcase_deadlines() - _update_host_entities(delta) - _snapshot_elapsed += delta + _rebuild_moving_player_spatial_index() + _update_host_entities(simulation_delta) if _snapshot_elapsed >= SNAPSHOT_INTERVAL_SECONDS: _snapshot_elapsed = fmod( _snapshot_elapsed, @@ -246,6 +282,7 @@ func _begin_population_if_ready() -> void: or _catalog == null or _world == null or _world_root == null + or not _world.is_world_ready() ): return var session_id: String = _session.get_session_id() @@ -273,7 +310,10 @@ func _cache_spawn_surface(entry: GatherableDataType) -> void: _world.get_gatherable_spawn_positions(entry.spawn_anchor_set_id) ) _spawn_anchor_positions[entry.type_id] = anchor_positions - if anchor_positions.is_empty(): + if ( + anchor_positions.is_empty() + and _world.get_world_layout() == WorldLayout.GENERATED + ): push_warning( "No gatherable spawn anchors were found for %s." % entry.type_id ) @@ -299,7 +339,10 @@ func _cache_spawn_surface(entry: GatherableDataType) -> void: _surface_triangles[entry.type_id] = triangles _surface_areas[entry.type_id] = areas _surface_total_areas[entry.type_id] = total_area - if triangles.is_empty(): + if ( + triangles.is_empty() + and _world.get_world_layout() == WorldLayout.GENERATED + ): push_warning( "No valid spawn surface was found for %s." % entry.type_id ) @@ -341,6 +384,7 @@ func _spawn_entity(entry: GatherableDataType) -> void: ) _apply_envelope(envelope) receive_world_envelope.rpc(envelope) + _spawn_events_sent += 1 func _update_host_entities(delta: float) -> void: @@ -392,6 +436,8 @@ func _update_host_entities(delta: float) -> void: minf(step / maxf(horizontal_delta.length(), 0.001), 1.0), ) state["yaw"] = atan2(-direction.x, -direction.z) + state["revision"] = int(state["revision"]) + 1 + _dirty_entity_ids[entity_id] = true state["position"] = position _entities[entity_id] = state if entry.can_be_scared() and _should_scare(entry, position, quality): @@ -405,6 +451,34 @@ func _should_scare( ) -> bool: var scare_radius: float = entry.get_scare_radius_for_quality(quality) var radius_squared: float = scare_radius * scare_radius + var center_cell := _player_spatial_cell(position) + var cell_radius: int = ceili(scare_radius / PLAYER_SPATIAL_CELL_SIZE) + for cell_x: int in range( + center_cell.x - cell_radius, + center_cell.x + cell_radius + 1, + ): + for cell_y: int in range( + center_cell.y - cell_radius, + center_cell.y + cell_radius + 1, + ): + for value: Variant in _moving_players_by_cell.get( + Vector2i(cell_x, cell_y), + [], + ): + var avatar := value as Player + if avatar == null: + continue + var player_delta := Vector2( + avatar.global_position.x - position.x, + avatar.global_position.z - position.z, + ) + if player_delta.length_squared() <= radius_squared: + return true + return false + + +func _rebuild_moving_player_spatial_index() -> void: + _moving_players_by_cell.clear() for peer_id: int in _spawn_service.get_peer_ids(): var avatar: Player = _spawn_service.get_avatar(peer_id) if ( @@ -413,13 +487,17 @@ func _should_scare( or avatar.is_sneaking() ): continue - var delta := Vector2( - avatar.global_position.x - position.x, - avatar.global_position.z - position.z, - ) - if delta.length_squared() <= radius_squared: - return true - return false + var cell: Vector2i = _player_spatial_cell(avatar.global_position) + var players: Array = _moving_players_by_cell.get(cell, []) + players.append(avatar) + _moving_players_by_cell[cell] = players + + +static func _player_spatial_cell(position: Vector3) -> Vector2i: + return Vector2i( + floori(position.x / PLAYER_SPATIAL_CELL_SIZE), + floori(position.z / PLAYER_SPATIAL_CELL_SIZE), + ) func _sample_surface_position( @@ -506,28 +584,69 @@ func _anchor_is_occupied(type_id: StringName, anchor: Vector3) -> bool: func _broadcast_entity_snapshots() -> void: - if _entities.is_empty(): + if _dirty_entity_ids.is_empty() or not _can_broadcast_snapshots(): return + var has_remote_recipients: bool = _has_authenticated_remote_peers() var snapshots: Array[Dictionary] = [] - for entity_id: String in _entities: - var state: Dictionary = _entities[entity_id] - state["revision"] = int(state["revision"]) + 1 - _entities[entity_id] = state - snapshots.append(_state_to_network(state)) - while not snapshots.is_empty(): + for entity_id: String in _dirty_entity_ids: + var state: Dictionary = _entities.get(entity_id, {}) + if not state.is_empty(): + snapshots.append(_state_to_network(state)) + _dirty_entity_ids.clear() + var maximum_chunk_size: int = ( + NetworkWorldSpawnProtocol.SNAPSHOT_ENTITIES_PER_ENVELOPE + ) + for start_index: int in range( + 0, + snapshots.size(), + maximum_chunk_size, + ): var chunk: Array[Dictionary] = [] - var chunk_size: int = mini( - NetworkWorldSpawnProtocol.SNAPSHOT_ENTITIES_PER_ENVELOPE, - snapshots.size(), - ) - for _index: int in chunk_size: - chunk.append(snapshots.pop_front()) + for value: Dictionary in snapshots.slice( + start_index, + mini(start_index + maximum_chunk_size, snapshots.size()), + ): + chunk.append(value) var envelope: Dictionary = _make_envelope( &"snapshot", {"entities": chunk}, ) _apply_envelope(envelope) + if not has_remote_recipients: + continue + # A peer-disconnected callback can resume validation/gameplay code and + # close the host while this physics update is still unwinding. Never send + # through the replacement OfflineMultiplayerPeer in that narrow window. + if not _can_broadcast_snapshots(): + return receive_world_snapshot_envelope.rpc(envelope) + _snapshot_packets_sent += 1 + _snapshot_states_sent += chunk.size() + + +func _has_authenticated_remote_peers() -> bool: + if _session == null: + return false + var local_peer_id: int = _session.get_local_peer_id() + for peer_id: int in _session.get_authenticated_peer_ids(): + if peer_id != local_peer_id: + return true + return false + + +func _can_broadcast_snapshots() -> bool: + if ( + _session == null + or not _session.is_host() + or not _session.is_gameplay_session_active() + ): + return false + var peer: MultiplayerPeer = multiplayer.multiplayer_peer + return ( + peer != null + and peer.get_connection_status() + == MultiplayerPeer.CONNECTION_CONNECTED + ) func _despawn_entity( @@ -541,6 +660,7 @@ func _despawn_entity( return var entry := state.get("data") as GatherableDataType _entities.erase(entity_id) + _dirty_entity_ids.erase(entity_id) var envelope: Dictionary = _make_envelope( &"despawn", { @@ -551,6 +671,7 @@ func _despawn_entity( ) _apply_envelope(envelope) receive_world_envelope.rpc(envelope) + _despawn_events_sent += 1 if schedule_respawn and entry != null: _respawns.append({ "type_id": entry.type_id, @@ -633,6 +754,7 @@ func _handle_interaction_finish(peer_id: int, data: Dictionary) -> void: var state: Dictionary = _entities.get(entity_id, {}) var entry := state.get("data") as GatherableDataType var avatar: Player = _spawn_service.get_avatar(peer_id) + var movement_sequence_value: Variant = data.get("movement_sequence", null) var error: String = "" if ( str(data.get("session_id", "")) != _session.get_session_id() @@ -640,6 +762,9 @@ func _handle_interaction_finish(peer_id: int, data: Dictionary) -> void: or request_id.length() > MAX_REQUEST_ID_LENGTH or str(charge.get("request_id", "")) != request_id or not target_position.is_finite() + or typeof(movement_sequence_value) != TYPE_INT + or int(movement_sequence_value) < 0 + or int(movement_sequence_value) > 2147483647 ): error = "The catch attempt was invalid." elif state.is_empty() or entry == null or bool(state.get("locked", false)): @@ -650,14 +775,28 @@ func _handle_interaction_finish(peer_id: int, data: Dictionary) -> void: error = "Equip the correct gathering tool." elif avatar == null: error = "The player is unavailable." - elif entry.requires_sneaking and not avatar.is_sneaking(): - error = "Sneak closer before using the tool." else: + var movement_state: Dictionary = ( + avatar.get_lag_compensated_movement_state( + int(movement_sequence_value) + ) + ) + if entry.requires_sneaking and not bool( + movement_state.get("sneaking", avatar.is_sneaking()) + ): + error = "Sneak closer before using the tool." + if not error.is_empty(): + _send_interaction_result(peer_id, request_id, false, error) + return var entity_position: Vector3 = state["position"] var target_distance: float = entity_position.distance_to(target_position) + var compensated_position: Vector3 = movement_state.get( + "position", + avatar.global_position, + ) var player_distance: float = Vector2( - avatar.global_position.x - entity_position.x, - avatar.global_position.z - entity_position.z, + compensated_position.x - entity_position.x, + compensated_position.z - entity_position.z, ).length() if target_distance > entry.capture_radius: error = "The gathering tool missed." @@ -903,7 +1042,7 @@ func _apply_capture_result(data: Dictionary) -> void: _local_collection, ) _local_inventory.add_catch(fish_catch) - _local_collection.mark_quality_discovered( + _local_collection.record_catch( fish_catch.fish_id, fish_catch.quality, ) @@ -1078,6 +1217,9 @@ func _apply_entity_state(value: Variant, immediate: bool) -> void: var position: Vector3 = NetworkWorldSpawnProtocol.array_to_vector3( state["position"] ) + if not _presentation_enabled: + _entity_revisions[entity_id] = revision + return var presentation := _presentations.get(entity_id) as WorldGatherableType if presentation == null or not is_instance_valid(presentation): presentation = WorldGatherableType.new() @@ -1127,6 +1269,8 @@ func _remove_presentation(entity_id: String, with_dust: bool) -> void: func _apply_showcase(payload: Dictionary) -> void: + if not _presentation_enabled: + return if ( typeof(payload.get("owner_peer_id")) != TYPE_INT or typeof(payload.get("visible")) != TYPE_BOOL @@ -1268,6 +1412,8 @@ func _clear_world() -> void: if presentation != null and is_instance_valid(presentation): presentation.queue_free() _entities.clear() + _dirty_entity_ids.clear() + _moving_players_by_cell.clear() _presentations.clear() _entity_revisions.clear() _surface_triangles.clear() @@ -1279,9 +1425,22 @@ func _clear_world() -> void: _charge_requests.clear() _pending_captures.clear() _showcase_deadlines.clear() + _simulation_elapsed = 0.0 _snapshot_elapsed = 0.0 +func get_network_metrics() -> Dictionary: + return { + "entities": _entities.size(), + "presentations": _presentations.size(), + "dirty_entities": _dirty_entity_ids.size(), + "spawn_events_sent": _spawn_events_sent, + "despawn_events_sent": _despawn_events_sent, + "snapshot_packets_sent": _snapshot_packets_sent, + "snapshot_states_sent": _snapshot_states_sent, + } + + func _bound(values: Dictionary) -> void: while values.size() > MAX_LEDGER_ENTRIES: values.erase(values.keys().front()) diff --git a/network/player_data_root.gd b/network/player_data_root.gd index 998e380..9416db3 100644 --- a/network/player_data_root.gd +++ b/network/player_data_root.gd @@ -189,6 +189,7 @@ func activate_process_root(path: String) -> bool: func path_for(store_owner: StringName) -> String: var relative: String = { &"player_save": "player/player_save.nfsave", + &"save_slots": "player/save_slots.json", &"network_profile": "player/network_profile.json", &"player_appearance": "player/player_appearance.json", &"saved_servers": "social/saved_servers.json", @@ -350,7 +351,7 @@ func _test_writable(path: String) -> bool: func _create_layout(path: String, id: String) -> bool: for relative: String in [ - "player", "social", "backups/saves", "backups/migrations", + "player", "player/saves", "social", "backups/saves", "backups/migrations", "backups/conflicts", "identity-backups", "progression-backups", ]: if DirAccess.make_dir_recursive_absolute(path.path_join(relative)) != OK: diff --git a/network/player_list_entry.gd b/network/player_list_entry.gd index 5aa9643..c75832a 100644 --- a/network/player_list_entry.gd +++ b/network/player_list_entry.gd @@ -12,6 +12,8 @@ var continuity_state := "" var ping_to_host_ms := -1 var muted := false var blocked := false +var is_friend := false +var can_request_friend := false var can_kick := false var can_ban := false var can_clear_art := false diff --git a/network/player_relationship_store.gd b/network/player_relationship_store.gd index 2ea4502..2452315 100644 --- a/network/player_relationship_store.gd +++ b/network/player_relationship_store.gd @@ -3,8 +3,12 @@ extends Node signal relationship_changed(fingerprint: String) -const FORMAT_VERSION := 1 +const FORMAT_VERSION := 2 const MAX_RECORDS := 500 +const MAX_FRIENDS := 200 +const SOCIAL_TOKEN_LENGTH := 64 +const PRESENCE_CHANNEL_DOMAIN := "NETFISHING_PRESENCE_V1:" +const INVITE_INBOX_DOMAIN := "NETFISHING_INVITE_V1:" var _records: Dictionary = {} var _loaded := false @@ -29,6 +33,11 @@ func is_blocked(fingerprint: String) -> bool: return bool(_records.get(fingerprint, {}).get("blocked", false)) +func is_friend(fingerprint: String) -> bool: + _ensure_loaded() + return bool(_records.get(fingerprint, {}).get("friend", false)) + + func set_muted(fingerprint: String, display_name: String, value: bool) -> bool: if not _valid_target(fingerprint, display_name): return false @@ -48,20 +57,101 @@ func set_blocked(fingerprint: String, display_name: String, value: bool) -> bool # fresh visibility boundary for future messages; already suppressed # messages retain their immutable local suppression flag. record["muted"] = value + if value: + _clear_friend_fields(record) return _commit(fingerprint, record) +func create_friend_capabilities() -> Dictionary: + var presence_write_token := NetworkIdentityCrypto.secure_id(32) + var invite_token := NetworkIdentityCrypto.secure_id(32) + if not _valid_social_token(presence_write_token) or not _valid_social_token(invite_token): + return {} + return { + "local_presence_write_token": presence_write_token, + "local_invite_token": invite_token, + "presence_channel": presence_channel_for_write_token( + presence_write_token + ), + "invite_token": invite_token, + } + + +func add_friend( + fingerprint: String, + display_name: String, + local_capabilities: Dictionary, + remote_capabilities: Dictionary, +) -> bool: + if ( + not _valid_target(fingerprint, display_name) + or is_blocked(fingerprint) + or not _valid_local_capabilities(local_capabilities) + or not _valid_public_capabilities(remote_capabilities) + ): + return false + _ensure_loaded() + if not is_friend(fingerprint) and get_friends().size() >= MAX_FRIENDS: + return false + var record := _record(fingerprint, display_name) + var now := int(Time.get_unix_time_from_system()) + record["friend"] = true + record["friend_since_unix"] = int(record.get("friend_since_unix", now)) + record["local_presence_write_token"] = str( + local_capabilities["local_presence_write_token"] + ) + record["local_invite_token"] = str( + local_capabilities["local_invite_token"] + ) + record["remote_presence_channel"] = str( + remote_capabilities["presence_channel"] + ) + record["remote_invite_token"] = str( + remote_capabilities["invite_token"] + ) + return _commit(fingerprint, record) + + +func remove_friend(fingerprint: String, display_name: String) -> bool: + if not _valid_target(fingerprint, display_name): + return false + _ensure_loaded() + var record := _record(fingerprint, display_name) + _clear_friend_fields(record) + return _commit(fingerprint, record) + + +func get_friend_record(fingerprint: String) -> Dictionary: + _ensure_loaded() + var record: Dictionary = _records.get(fingerprint, {}) + return record.duplicate(true) if bool(record.get("friend", false)) else {} + + +func get_friends() -> Array[Dictionary]: + _ensure_loaded() + var result: Array[Dictionary] = [] + for value: Dictionary in _records.values(): + if bool(value.get("friend", false)): + result.append(value.duplicate(true)) + _sort_records(result) + return result + + +static func presence_channel_for_write_token(write_token: String) -> String: + return (PRESENCE_CHANNEL_DOMAIN + write_token).sha256_text() + + +static func invite_inbox_id(invite_token: String) -> String: + return (INVITE_INBOX_DOMAIN + invite_token).sha256_text() + + func get_records() -> Array[Dictionary]: _ensure_loaded() var result: Array[Dictionary] = [] for value: Dictionary in _records.values(): if bool(value.get("muted", false)) or bool(value.get("blocked", false)): result.append(value.duplicate(true)) - result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: - return str(a.get("last_known_display_name", "")).naturalnocasecmp_to( - str(b.get("last_known_display_name", "")) - ) < 0 - ) + _sort_records(result) return result @@ -72,6 +162,7 @@ func _record(fingerprint: String, display_name: String) -> Dictionary: "created_unix": now, "muted": false, "blocked": false, + "friend": false, }) record["last_known_display_name"] = display_name record["updated_unix"] = now @@ -82,7 +173,11 @@ func _commit(fingerprint: String, record: Dictionary) -> bool: if _write_blocked: return false var previous: Dictionary = _records.duplicate(true) - if not bool(record["muted"]) and not bool(record["blocked"]): + if ( + not bool(record.get("muted", false)) + and not bool(record.get("blocked", false)) + and not bool(record.get("friend", false)) + ): _records.erase(fingerprint) else: _records[fingerprint] = record @@ -115,7 +210,8 @@ func _ensure_loaded() -> void: if json.parse(file.get_as_text()) != OK or typeof(json.data) != TYPE_DICTIONARY: return var data: Dictionary = json.data - if data.get("format_version") != FORMAT_VERSION: + var format_version: int = int(data.get("format_version", 0)) + if format_version not in [1, FORMAT_VERSION]: _write_blocked = true return for value: Variant in data.get("records", []): @@ -127,6 +223,13 @@ func _ensure_loaded() -> void: continue record["blocked"] = bool(record.get("blocked", false)) record["muted"] = bool(record.get("muted", false)) or record["blocked"] + record["friend"] = ( + bool(record.get("friend", false)) + and not record["blocked"] + and _valid_stored_friend_capabilities(record) + ) + if not record["friend"]: + _clear_friend_fields(record) _records[fingerprint] = record.duplicate(true) _expected_hash = PortableFileGuard.hash_file(_store_path) @@ -145,3 +248,69 @@ func _save() -> bool: if bool(result.get("ok", false)): _expected_hash = str(result["hash"]) return bool(result.get("ok", false)) + + +func _clear_friend_fields(record: Dictionary) -> void: + record["friend"] = false + for key: String in [ + "friend_since_unix", + "local_presence_write_token", + "local_invite_token", + "remote_presence_channel", + "remote_invite_token", + ]: + record.erase(key) + + +func _valid_local_capabilities(value: Dictionary) -> bool: + var write_token := str(value.get("local_presence_write_token", "")) + var invite_token := str(value.get("local_invite_token", "")) + return ( + _valid_social_token(write_token) + and _valid_social_token(invite_token) + and str(value.get("presence_channel", "")) + == presence_channel_for_write_token(write_token) + and str(value.get("invite_token", "")) == invite_token + ) + + +func _valid_public_capabilities(value: Dictionary) -> bool: + return ( + _valid_social_token(str(value.get("presence_channel", ""))) + and _valid_social_token(str(value.get("invite_token", ""))) + ) + + +func _valid_stored_friend_capabilities(record: Dictionary) -> bool: + var local: Dictionary = { + "local_presence_write_token": str( + record.get("local_presence_write_token", "") + ), + "local_invite_token": str(record.get("local_invite_token", "")), + "presence_channel": presence_channel_for_write_token(str( + record.get("local_presence_write_token", "") + )), + "invite_token": str(record.get("local_invite_token", "")), + } + var remote: Dictionary = { + "presence_channel": str(record.get("remote_presence_channel", "")), + "invite_token": str(record.get("remote_invite_token", "")), + } + return _valid_local_capabilities(local) and _valid_public_capabilities(remote) + + +func _valid_social_token(value: String) -> bool: + if value.length() != SOCIAL_TOKEN_LENGTH: + return false + for character: String in value: + if character not in "0123456789abcdef": + return false + return true + + +func _sort_records(records: Array[Dictionary]) -> void: + records.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + return str(a.get("last_known_display_name", "")).naturalnocasecmp_to( + str(b.get("last_known_display_name", "")) + ) < 0 + ) diff --git a/network/player_spawn_service.gd b/network/player_spawn_service.gd index 0b8179b..1d1c292 100644 --- a/network/player_spawn_service.gd +++ b/network/player_spawn_service.gd @@ -11,16 +11,19 @@ var _local_player: Player var _spawn_transform: Transform3D var _avatars: Dictionary[int, Player] = {} var _local_peer_id: int = 1 +var _dedicated_runtime: bool = false func setup( players_root: Node3D, local_player: Player, spawn_transform: Transform3D, + dedicated_runtime: bool = false, ) -> void: _players_root = players_root _local_player = local_player _spawn_transform = spawn_transform + _dedicated_runtime = dedicated_runtime func set_spawn_transform(spawn_transform: Transform3D) -> void: @@ -56,7 +59,10 @@ func spawn_remote_player( avatar.set_network_peer_id(peer_id) _players_root.add_child(avatar) avatar.global_transform = transform - avatar.configure_network_remote(authoritative_simulation) + avatar.configure_network_remote( + authoritative_simulation, + _dedicated_runtime and authoritative_simulation, + ) _avatars[peer_id] = avatar avatar_spawned.emit(peer_id, avatar) return avatar diff --git a/network/portable_data_migration.gd b/network/portable_data_migration.gd index a74db6d..430fba8 100644 --- a/network/portable_data_migration.gd +++ b/network/portable_data_migration.gd @@ -124,6 +124,7 @@ static func migrate_active_to( for relative: String in [ "player/player_save.nfsave", "player/player_save.json", + "player/save_slots.json", "player/network_profile.json", "player/player_appearance.json", "social/saved_servers.json", @@ -142,6 +143,16 @@ static func migrate_active_to( ): _remove_tree(staging) return {"ok": false, "message": "Migration validation failed for %s." % relative} + var source_slots: String = data_root.root_path.path_join("player/saves") + if ( + DirAccess.dir_exists_absolute(source_slots) + and not _copy_progression_slots( + source_slots, + staging.path_join("player/saves"), + ) + ): + _remove_tree(staging) + return {"ok": false, "message": "Migration validation failed for save slots."} if DirAccess.dir_exists_absolute(normalized): DirAccess.remove_absolute(normalized) if DirAccess.rename_absolute(staging, normalized) != OK: @@ -257,7 +268,7 @@ static func _copy_verified_owned_file( source: String, destination: String, ) -> Dictionary: - if source.get_file() != "player_save.nfsave": + if source.get_extension().to_lower() != "nfsave": return _copy_verified_json(source, destination) var decoded: Dictionary = ProgressionSaveCodec.read_local_save(source) if not bool(decoded.get("ok", false)): @@ -281,12 +292,42 @@ static func _valid_owned_data(filename: String, data: Dictionary) -> bool: if filename == "player_save.json": var version: int = int(data.get("save_version", -1)) return version >= 1 and version <= PlayerSaveManager.SAVE_VERSION + if filename == "save_slots.json": + return ( + int(data.get("format_version", -1)) == PlayerSaveSlotCatalog.FORMAT_VERSION + and typeof(data.get("active_slot_id")) == TYPE_STRING + and typeof(data.get("slots")) == TYPE_ARRAY + ) return ( filename not in LEGACY_FILES or int(data.get("format_version", -1)) == 1 ) +static func _copy_progression_slots(source: String, destination: String) -> bool: + if DirAccess.make_dir_recursive_absolute(destination) != OK: + return false + var access := DirAccess.open(source) + if access == null: + return false + access.list_dir_begin() + var filename: String = access.get_next() + while not filename.is_empty(): + if ( + not access.current_is_dir() + and filename.get_extension().to_lower() == "nfsave" + and not bool(_copy_verified_owned_file( + source.path_join(filename), + destination.path_join(filename), + ).get("ok", false)) + ): + access.list_dir_end() + return false + filename = access.get_next() + access.list_dir_end() + return true + + static func _copy_bytes(source: String, destination: String) -> bool: return _write_bytes(destination, PortableFileGuard.read_bytes(source)) diff --git a/player/player.gd b/player/player.gd index 6d237bf..6ec490f 100644 --- a/player/player.gd +++ b/player/player.gd @@ -139,6 +139,10 @@ const NETWORK_SNAPSHOT_JITTER_WEIGHT: float = 0.15 const NETWORK_REMOTE_SMOOTHING_RATE: float = 12.0 const NETWORK_REMOTE_JITTER_SMOOTHING_RATE: float = 6.0 const NETWORK_INPUT_STALE_TIMEOUT_SECONDS: float = 0.25 +const NETWORK_INPUT_STALE_TIMEOUT_MAX_SECONDS: float = 1.25 +const NETWORK_INPUT_STALE_RTT_MULTIPLIER: float = 1.5 +const NETWORK_MOVEMENT_HISTORY_SECONDS: float = 1.25 +const NETWORK_MAX_LAG_COMPENSATION_SECONDS: float = 0.75 const LOCAL_PREDICTION_EXTRAPOLATION_LIMIT_SECONDS: float = 0.25 const LOCAL_PREDICTION_FALLBACK_TRANSIT_RATIO: float = 0.5 const LOCAL_PREDICTION_CORRECTION_THRESHOLD: float = 0.12 @@ -417,6 +421,7 @@ class ShowcaseCameraSnapshot: @export var controller_camera_speed: float = 2.5 @export_range(0.0, 1.0, 0.01) var controller_camera_deadzone: float = 0.2 @export var invert_camera_y: bool = false +var _swap_hotbar_camera_scroll: bool = false @export_range(-89.0, 0.0, 0.5) var minimum_pitch_degrees: float = -65.0 @export_range(0.0, 89.0, 0.5) var maximum_pitch_degrees: float = 45.0 @export var minimum_zoom: float = 2.0 @@ -506,6 +511,9 @@ var _network_slow_walk: bool = false var _last_network_input_sequence: int = 0 var _network_input_age: float = 0.0 var _network_input_stale: bool = false +var _network_input_stale_timeout_seconds: float = ( + NETWORK_INPUT_STALE_TIMEOUT_SECONDS +) var _network_jump_intent_active: bool = false var _network_target_position: Vector3 var _network_target_velocity: Vector3 @@ -519,6 +527,9 @@ var _network_target_animation_action_paused: bool = false var _network_snapshot_ready: bool = false var _network_snapshot_age: float = 0.0 var _network_snapshot_jitter: float = 0.0 +var _network_simulation_only: bool = false +var _local_reconciliation_visual_offset: Vector3 = Vector3.ZERO +var _authoritative_movement_history: Array[Dictionary] = [] var _local_network_jump_intent_pending: bool = false var _local_network_jump_intent_sequence: int = -1 var _animation_action_id: StringName = &"" @@ -791,10 +802,16 @@ func _finish_successful_net_strike_pause() -> void: func _set_animation_action_paused(paused: bool) -> void: if _animation_action_id.is_empty() or _animation_action_paused == paused: return + var next_sequence: int = ( + 1 + if _animation_action_sequence + >= NetworkPlayerAnimationProtocol.MAX_ACTION_SEQUENCE + else _animation_action_sequence + 1 + ) _apply_animation_action_state( NetworkPlayerAnimationProtocol.make_action_state( _animation_action_id, - _animation_action_sequence, + next_sequence, _animation_action_elapsed, paused, ) @@ -972,6 +989,12 @@ func _physics_process(delta: float) -> void: if _network_interpolation_enabled: _update_network_interpolation(delta) return + _simulate_movement_physics(delta) + if _network_authoritative_simulation: + _record_authoritative_movement_state() + + +func _simulate_movement_physics(delta: float) -> void: if _network_authoritative_simulation: _update_network_input_freshness(delta) if local_control_enabled and _free_camera_active: @@ -1075,6 +1098,9 @@ func _physics_process(delta: float) -> void: func _process(delta: float) -> void: + if _network_simulation_only: + return + _update_local_reconciliation_visuals(delta) if not _animation_action_id.is_empty() and not _animation_action_paused: _animation_action_elapsed = minf( _animation_action_elapsed + delta, @@ -1607,6 +1633,23 @@ func is_sitting() -> bool: return _sitting +func get_network_sitting_state() -> bool: + return _sitting or _sit_after_landing + + +func apply_network_sitting_state(should_sit: bool) -> void: + _set_sitting(should_sit) + + +func apply_authoritative_network_sitting_state(should_sit: bool) -> void: + if should_sit and not is_on_floor(): + _sit_after_landing = true + _set_sitting(false) + return + _sit_after_landing = false + _set_sitting(should_sit) + + func _input(event: InputEvent) -> void: # Camera dragging must observe the complete mouse gesture before GUI controls # get a chance to consume one part of it. Do not claim the event here: the @@ -1648,7 +1691,7 @@ func _unhandled_input(event: InputEvent) -> void: var mouse_zoom_in: bool = ( event is InputEventMouseButton - and event.shift_pressed + and event.shift_pressed != _swap_hotbar_camera_scroll and event.is_action_pressed("camera_zoom_in") ) var controller_zoom_in: bool = ( @@ -1657,7 +1700,7 @@ func _unhandled_input(event: InputEvent) -> void: ) var mouse_zoom_out: bool = ( event is InputEventMouseButton - and event.shift_pressed + and event.shift_pressed != _swap_hotbar_camera_scroll and event.is_action_pressed("camera_zoom_out") ) var controller_zoom_out: bool = ( @@ -1878,10 +1921,14 @@ func get_network_peer_id() -> int: return _network_peer_id -func configure_network_remote(authoritative_simulation: bool) -> void: +func configure_network_remote( + authoritative_simulation: bool, + simulation_only: bool = false, +) -> void: set_local_control(false) _network_authoritative_simulation = authoritative_simulation _network_interpolation_enabled = not authoritative_simulation + set_network_simulation_only(simulation_only) _network_axis = Vector2.ZERO _network_jump_pending = false _network_sprint = false @@ -1889,6 +1936,7 @@ func configure_network_remote(authoritative_simulation: bool) -> void: _network_slow_walk = false _network_input_age = 0.0 _network_input_stale = false + _network_input_stale_timeout_seconds = NETWORK_INPUT_STALE_TIMEOUT_SECONDS _network_jump_intent_active = false _network_snapshot_ready = false _network_snapshot_age = 0.0 @@ -1900,10 +1948,43 @@ func configure_network_remote(authoritative_simulation: bool) -> void: _network_target_animation_action_elapsed = 0.0 _network_target_animation_action_paused = false _camera.current = false + _authoritative_movement_history.clear() + + +func set_network_simulation_only(enabled: bool) -> void: + _network_simulation_only = enabled + if not is_node_ready(): + return + set_process(not enabled) + for presentation_root: Node in [ + _visuals, + get_node_or_null("GroundShadow"), + get_node_or_null("SprintDust"), + _camera_yaw, + ]: + if presentation_root == null: + continue + presentation_root.process_mode = ( + Node.PROCESS_MODE_DISABLED + if enabled + else Node.PROCESS_MODE_INHERIT + ) + if presentation_root is Node3D: + (presentation_root as Node3D).visible = not enabled + + +func is_network_simulation_only() -> bool: + return _network_simulation_only func reset_network_movement_state() -> void: _clear_local_network_jump_intent() + if not _animation_action_id.is_empty(): + end_animation_action() + _sit_after_landing = false + _sitting_intent_pending = false + _sitting_intent_sequence = -1 + _set_sitting(false) _network_axis = Vector2.ZERO _network_jump_pending = false _network_sprint = false @@ -1911,6 +1992,7 @@ func reset_network_movement_state() -> void: _network_slow_walk = false _network_input_age = 0.0 _network_input_stale = false + _network_input_stale_timeout_seconds = NETWORK_INPUT_STALE_TIMEOUT_SECONDS _network_jump_intent_active = false _last_network_input_sequence = 0 @@ -1939,7 +2021,7 @@ func capture_network_input(sequence: int) -> Dictionary: "sprint": false, "sneak": false, "slow_walk": false, - "sitting": _sitting, + "sitting": get_network_sitting_state(), "casting": ( _fishing_visual_phase == FishingVisualPhase.CASTING ), @@ -1964,13 +2046,66 @@ func capture_network_input(sequence: int) -> Dictionary: "sprint": Input.is_action_pressed("sprint"), "sneak": Input.is_action_pressed("sneak"), "slow_walk": Input.is_action_pressed("slow_walk"), - "sitting": _sitting, + "sitting": get_network_sitting_state(), "casting": _fishing_visual_phase == FishingVisualPhase.CASTING, "animation_action": _make_animation_action_state(), } -func apply_authoritative_network_input(data: Dictionary) -> void: +func has_active_network_input() -> bool: + if not local_control_enabled: + return false + if _local_network_jump_intent_pending or _sitting_intent_pending: + return true + if not _is_movement_input_enabled() or _free_camera_active: + return false + return ( + not Input.get_vector( + "move_left", + "move_right", + "move_forward", + "move_backward", + ).is_zero_approx() + or Input.is_action_pressed("sprint") + or Input.is_action_pressed("sneak") + or Input.is_action_pressed("slow_walk") + or _fishing_visual_phase == FishingVisualPhase.CASTING + or not _animation_action_id.is_empty() + ) + + +func get_network_input_state_hash() -> int: + var axis: Vector2 = Vector2.ZERO + if ( + local_control_enabled + and _is_movement_input_enabled() + and not _free_camera_active + ): + axis = Input.get_vector( + "move_left", + "move_right", + "move_forward", + "move_backward", + ) + return hash([ + axis, + _camera_yaw.global_rotation.y, + _local_network_jump_intent_pending, + Input.is_action_pressed("sprint"), + Input.is_action_pressed("sneak"), + Input.is_action_pressed("slow_walk"), + get_network_sitting_state(), + _fishing_visual_phase == FishingVisualPhase.CASTING, + _animation_action_id, + _animation_action_sequence, + _animation_action_paused, + ]) + + +func apply_authoritative_network_input( + data: Dictionary, + stale_timeout_seconds: float = NETWORK_INPUT_STALE_TIMEOUT_SECONDS, +) -> void: var sequence: int = int(data.get("sequence", 0)) if sequence <= _last_network_input_sequence: return @@ -1980,6 +2115,11 @@ func apply_authoritative_network_input(data: Dictionary) -> void: _last_network_input_sequence = sequence _network_input_age = 0.0 _network_input_stale = false + _network_input_stale_timeout_seconds = clampf( + stale_timeout_seconds, + NETWORK_INPUT_STALE_TIMEOUT_SECONDS, + NETWORK_INPUT_STALE_TIMEOUT_MAX_SECONDS, + ) _network_axis = Vector2(float(axis[0]), float(axis[1])).limit_length(1.0) _network_camera_yaw = float(data.get("camera_yaw", 0.0)) var jump_intent_active: bool = bool(data.get("jump", false)) @@ -1989,15 +2129,13 @@ func apply_authoritative_network_input(data: Dictionary) -> void: _network_sprint = bool(data.get("sprint", false)) _network_sneak = bool(data.get("sneak", false)) _network_slow_walk = bool(data.get("slow_walk", false)) - _apply_animation_action_state(data.get("animation_action", {})) + apply_authoritative_network_animation_action( + data.get("animation_action", {}) + ) _apply_network_casting(bool(data.get("casting", false))) - var sitting_requested: bool = bool(data.get("sitting", false)) - if sitting_requested and not is_on_floor(): - _sit_after_landing = true - _set_sitting(false) - else: - _sit_after_landing = false - _set_sitting(sitting_requested) + apply_authoritative_network_sitting_state( + bool(data.get("sitting", false)) + ) func make_network_snapshot(peer_id: int) -> Dictionary: @@ -2007,19 +2145,49 @@ func make_network_snapshot(peer_id: int) -> Dictionary: "position": [global_position.x, global_position.y, global_position.z], "velocity": [velocity.x, velocity.y, velocity.z], "visual_yaw": _visuals.rotation.y, - "animation_state": NetworkPlayerAnimationProtocol.make_state( - _get_authoritative_locomotion_id(), - is_on_floor(), - _animation_action_id, - _animation_action_sequence, - _animation_action_elapsed, - _animation_action_paused, - ), - "sitting": _sitting, + "animation_state": make_network_animation_state(), + "grounded": is_on_floor(), + "sitting": get_network_sitting_state(), "casting": _fishing_visual_phase == FishingVisualPhase.CASTING, } +func make_network_animation_state() -> Dictionary: + return NetworkPlayerAnimationProtocol.make_state( + _get_authoritative_locomotion_id(), + is_on_floor(), + _animation_action_id, + _animation_action_sequence, + _animation_action_elapsed, + _animation_action_paused, + ) + + +func apply_network_animation_state(state: Dictionary) -> void: + _apply_network_target_animation_state(state) + + +func apply_authoritative_network_animation_action( + action_state: Dictionary, +) -> void: + if not NetworkPlayerAnimationProtocol.validate_action_state(action_state): + return + var incoming_sequence: int = int(action_state["sequence"]) + var wrapped_sequence: bool = ( + _animation_action_sequence + == NetworkPlayerAnimationProtocol.MAX_ACTION_SEQUENCE + and incoming_sequence == 1 + ) + if incoming_sequence < _animation_action_sequence and not wrapped_sequence: + return + if ( + incoming_sequence == _animation_action_sequence + and StringName(str(action_state["id"])) != _animation_action_id + ): + return + _apply_animation_action_state(action_state) + + func push_network_snapshot( snapshot: Dictionary, estimated_transit_seconds: float = -1.0, @@ -2053,7 +2221,8 @@ func push_network_snapshot( ) _network_target_velocity = parsed["velocity"] _network_target_visual_yaw = parsed["visual_yaw"] - _apply_network_target_animation_state(parsed["animation_state"]) + if not Dictionary(parsed.get("animation_state", {})).is_empty(): + _apply_network_target_animation_state(parsed["animation_state"]) _network_snapshot_age = 0.0 _apply_network_casting(bool(parsed["casting"])) _set_sitting(bool(parsed["sitting"])) @@ -2066,9 +2235,8 @@ func push_network_snapshot( func apply_local_prediction_correction( snapshot: Dictionary, - latest_input_sequence: int = 0, + pending_inputs: Array[Dictionary] = [], input_interval_seconds: float = 0.0, - estimated_transit_seconds: float = -1.0, ) -> void: var parsed: Dictionary = _parse_network_snapshot(snapshot) if parsed.is_empty(): @@ -2090,27 +2258,158 @@ func apply_local_prediction_correction( _clear_local_network_jump_intent() if not _sitting_intent_pending: _set_sitting(bool(parsed["sitting"])) - var authoritative_position: Vector3 = parsed["position"] - var transit_seconds: float = resolve_local_prediction_transit_seconds( - acknowledged_input, - latest_input_sequence, - input_interval_seconds, - estimated_transit_seconds, + var previous_visual_position: Vector3 = _visuals.global_position + var base_visual_local_position: Vector3 = ( + _visuals.position - _local_reconciliation_visual_offset ) - if transit_seconds > 0.0: - authoritative_position += ( - (parsed["velocity"] as Vector3) * transit_seconds - ) - var error_distance: float = global_position.distance_to( - authoritative_position + var previous_position: Vector3 = global_position + global_position = parsed["position"] + velocity = parsed["velocity"] + if input_interval_seconds > 0.0: + for input: Dictionary in pending_inputs: + _replay_network_movement_input(input, input_interval_seconds) + var correction_distance: float = previous_position.distance_to( + global_position ) - if error_distance > LOCAL_PREDICTION_SNAP_DISTANCE: - global_position = authoritative_position - elif error_distance > LOCAL_PREDICTION_CORRECTION_THRESHOLD: - global_position = global_position.lerp( - authoritative_position, - LOCAL_PREDICTION_CORRECTION_WEIGHT, + if correction_distance <= LOCAL_PREDICTION_SNAP_DISTANCE: + _visuals.global_position = previous_visual_position + _local_reconciliation_visual_offset = ( + _visuals.position - base_visual_local_position ) + else: + _local_reconciliation_visual_offset = Vector3.ZERO + + +func _replay_network_movement_input( + data: Dictionary, + delta: float, +) -> void: + var axis_value: Variant = data.get("axis", []) + if typeof(axis_value) != TYPE_ARRAY or axis_value.size() != 2: + return + if bool(data.get("sitting", false)) or _water_recovery_active: + velocity = Vector3.ZERO + return + var input_vector := Vector2( + float(axis_value[0]), + float(axis_value[1]), + ).limit_length(1.0) + var camera_basis := Basis( + Vector3.UP, + float(data.get("camera_yaw", 0.0)), + ) + var move_direction: Vector3 = ( + camera_basis.x * input_vector.x + + camera_basis.z * input_vector.y + ) + move_direction.y = 0.0 + move_direction = move_direction.normalized() + _network_sprint = bool(data.get("sprint", false)) + _network_sneak = bool(data.get("sneak", false)) + _network_slow_walk = bool(data.get("slow_walk", false)) + # Replay the speed authored by this exact pending input. Consulting the + # current InputMap here would make an older walk replay as a sprint (or the + # reverse) whenever the local button changed while a snapshot was in flight. + var replay_speed: float = walk_speed + if _network_sneak: + replay_speed = sneak_speed + elif _network_slow_walk: + replay_speed = slow_walk_speed + elif _network_sprint: + replay_speed = sprint_speed + if item_effects != null: + replay_speed *= item_effects.get_movement_multiplier() + var input_strength: float = minf(input_vector.length(), 1.0) + velocity.x = move_direction.x * replay_speed * input_strength + velocity.z = move_direction.z * replay_speed * input_strength + if not is_on_floor(): + var gravity_multiplier: float = ( + upward_gravity_multiplier + if velocity.y > 0.0 + else fall_gravity_multiplier + ) + velocity.y -= _gravity * gravity_multiplier * delta + elif bool(data.get("jump", false)): + velocity.y = jump_velocity + move_and_slide() + + +func _update_local_reconciliation_visuals(delta: float) -> void: + if _local_reconciliation_visual_offset.is_zero_approx(): + _local_reconciliation_visual_offset = Vector3.ZERO + return + var retained_ratio: float = exp(-14.0 * delta) + var retained_offset: Vector3 = ( + _local_reconciliation_visual_offset * retained_ratio + ) + _visuals.position += retained_offset - _local_reconciliation_visual_offset + _local_reconciliation_visual_offset = retained_offset + + +static func resolve_network_input_stale_timeout_seconds( + round_trip_msec: int, +) -> float: + if round_trip_msec < 0: + return NETWORK_INPUT_STALE_TIMEOUT_SECONDS + return clampf( + NETWORK_INPUT_STALE_TIMEOUT_SECONDS + + float(round_trip_msec) / 1000.0 + * NETWORK_INPUT_STALE_RTT_MULTIPLIER, + NETWORK_INPUT_STALE_TIMEOUT_SECONDS, + NETWORK_INPUT_STALE_TIMEOUT_MAX_SECONDS, + ) + + +func _record_authoritative_movement_state() -> void: + var now_seconds: float = Time.get_ticks_msec() / 1000.0 + _authoritative_movement_history.append({ + "sequence": _last_network_input_sequence, + "recorded_at": now_seconds, + "position": global_position, + "velocity": velocity, + "cast_origin": get_cast_origin_position(), + "facing": get_facing_direction(), + "sneaking": is_sneaking(), + }) + var oldest_allowed: float = now_seconds - NETWORK_MOVEMENT_HISTORY_SECONDS + while ( + not _authoritative_movement_history.is_empty() + and float( + _authoritative_movement_history[0].get("recorded_at", 0.0) + ) < oldest_allowed + ): + _authoritative_movement_history.pop_front() + + +func get_lag_compensated_movement_state( + input_sequence: int, + max_age_seconds: float = NETWORK_MAX_LAG_COMPENSATION_SECONDS, +) -> Dictionary: + var now_seconds: float = Time.get_ticks_msec() / 1000.0 + for index: int in range( + _authoritative_movement_history.size() - 1, + -1, + -1, + ): + var candidate: Dictionary = _authoritative_movement_history[index] + if now_seconds - float(candidate.get("recorded_at", 0.0)) > ( + max_age_seconds + ): + break + if ( + input_sequence <= 0 + or int(candidate.get("sequence", 0)) <= input_sequence + ): + return candidate.duplicate(true) + return { + "sequence": _last_network_input_sequence, + "recorded_at": now_seconds, + "position": global_position, + "velocity": velocity, + "cast_origin": get_cast_origin_position(), + "facing": get_facing_direction(), + "sneaking": is_sneaking(), + } static func resolve_local_prediction_transit_seconds( @@ -2154,7 +2453,8 @@ func apply_network_teleport(snapshot: Dictionary) -> void: _network_target_position = global_position _network_target_velocity = velocity _network_target_visual_yaw = _visuals.rotation.y - _apply_network_target_animation_state(parsed["animation_state"]) + if not Dictionary(parsed.get("animation_state", {})).is_empty(): + _apply_network_target_animation_state(parsed["animation_state"]) _network_snapshot_age = 0.0 _network_snapshot_ready = true @@ -2180,12 +2480,16 @@ func _parse_network_snapshot(snapshot: Dictionary) -> Dictionary: float(network_velocity[2]) ) var visual_yaw: float = float(snapshot.get("visual_yaw", 0.0)) - var animation_state_value: Variant = snapshot.get("animation_state") - if not NetworkPlayerAnimationProtocol.validate_state(animation_state_value): - return {} - var animation_state: Dictionary = ( - animation_state_value as Dictionary - ).duplicate(true) + var animation_state: Dictionary = {} + if snapshot.has("animation_state"): + var animation_state_value: Variant = snapshot.get("animation_state") + if not NetworkPlayerAnimationProtocol.validate_state( + animation_state_value + ): + return {} + animation_state = ( + animation_state_value as Dictionary + ).duplicate(true) if ( snapshot.has("acknowledged_input") and ( @@ -2237,6 +2541,23 @@ func _apply_network_target_animation_state(state: Dictionary) -> void: StringName(str(state["locomotion_id"])) ) var action: Dictionary = state["action"] + var incoming_sequence: int = int(action["sequence"]) + var wrapped_sequence: bool = ( + _network_target_animation_action_sequence + == NetworkPlayerAnimationProtocol.MAX_ACTION_SEQUENCE + and incoming_sequence == 1 + ) + if ( + incoming_sequence < _network_target_animation_action_sequence + and not wrapped_sequence + ): + return + if ( + incoming_sequence == _network_target_animation_action_sequence + and StringName(str(action["id"])) + != _network_target_animation_action_id + ): + return _network_target_animation_action_id = StringName(str(action["id"])) _network_target_animation_action_sequence = int(action["sequence"]) _network_target_animation_action_elapsed = float(action["elapsed"]) @@ -2414,11 +2735,11 @@ func _update_network_interpolation(delta: float) -> void: func _update_network_input_freshness(delta: float) -> void: _network_input_age = minf( _network_input_age + delta, - NETWORK_INPUT_STALE_TIMEOUT_SECONDS + 1.0, + _network_input_stale_timeout_seconds + 1.0, ) if ( _network_input_stale - or _network_input_age <= NETWORK_INPUT_STALE_TIMEOUT_SECONDS + or _network_input_age <= _network_input_stale_timeout_seconds ): return _network_input_stale = true @@ -2523,6 +2844,7 @@ func apply_camera_settings( new_mouse_sensitivity: float, new_controller_sensitivity: float, invert_vertical: bool, + swap_hotbar_camera_scroll: bool = false, ) -> void: mouse_sensitivity = clampf(new_mouse_sensitivity, 0.001, 0.012) controller_camera_speed = clampf( @@ -2531,6 +2853,7 @@ func apply_camera_settings( 5.0 ) invert_camera_y = invert_vertical + _swap_hotbar_camera_scroll = swap_hotbar_camera_scroll func is_camera_input_enabled() -> bool: diff --git a/save/player_save_inspection.gd b/save/player_save_inspection.gd index be5fd24..ff14de6 100644 --- a/save/player_save_inspection.gd +++ b/save/player_save_inspection.gd @@ -14,6 +14,10 @@ var detected_version: int = -1 var catch_count: int = 0 var wallet_balance: int = 0 var discovered_species_count: int = 0 +var total_experience: int = 0 +var player_level: int = 1 +var world_layout: StringName = &"generated_world" +var world_seed: int = 0 var has_primary_file: bool = false var message: String = "" diff --git a/save/player_save_manager.gd b/save/player_save_manager.gd index 1b4c1d2..1ad74a1 100644 --- a/save/player_save_manager.gd +++ b/save/player_save_manager.gd @@ -49,6 +49,7 @@ class LoadSnapshot: var catches: Array[FishCatchType] = [] var discovered_ids: Array[StringName] = [] var discovered_quality_masks: Dictionary[StringName, int] = {} + var catch_counts: Dictionary[StringName, int] = {} var wallet_balance: int = 0 var next_catch_sequence: int = 1 var bag_items: Array[OwnedItemType] = [] @@ -109,8 +110,22 @@ var _world_seed: int = DEFAULT_WORLD_SEED func configure_storage(path: String, data_root: PlayerDataRoot) -> void: + if not select_storage(path, data_root): + push_error("PlayerSaveManager could not configure progression storage.") + + +func select_storage(path: String, data_root: PlayerDataRoot) -> bool: + if path.is_empty() or data_root == null: + return false + if _autosave_enabled or _is_dirty: + return false + if _autosave_timer != null: + _autosave_timer.stop() _save_path = path _data_root = data_root + _expected_hash = "" + _automatic_saving_blocked = false + return true func _temp_path() -> String: @@ -297,9 +312,10 @@ func load_player_data() -> bool: snapshot.next_catch_sequence ) var collection_restored: bool = ( - _collection_log.replace_discovery_state( + _collection_log.replace_collection_state( snapshot.discovered_ids, snapshot.discovered_quality_masks, + snapshot.catch_counts, ) ) var wallet_restored: bool = _wallet.restore_balance( @@ -398,17 +414,30 @@ func load_player_data() -> bool: func inspect_save() -> SaveInspectionType: - var result := SaveInspectionType.new() _recover_interrupted_write() var read_path: String = _read_path() - result.has_primary_file = not read_path.is_empty() + return inspect_progression_at_path( + read_path, + read_path == _legacy_save_path(), + ) + + +func inspect_progression_at_path( + path: String, + allow_legacy_plaintext: bool = false, +) -> SaveInspectionType: + var result := SaveInspectionType.new() + if not path.is_empty(): + _remove_if_present(path + ".codec.tmp") + _recover_path_write(path) + result.has_primary_file = not path.is_empty() and FileAccess.file_exists(path) if not result.has_primary_file: result.status = SaveInspectionType.Status.MISSING result.message = "no save found." return result var decoded: Dictionary = ProgressionSaveCodec.read_local_save( - read_path, - read_path == _legacy_save_path(), + path, + allow_legacy_plaintext, ) if not bool(decoded.get("ok", false)): result.status = SaveInspectionType.Status.IO_ERROR @@ -439,6 +468,12 @@ func inspect_save() -> SaveInspectionType: result.catch_count = snapshot.catches.size() result.wallet_balance = snapshot.wallet_balance result.discovered_species_count = snapshot.discovered_ids.size() + result.total_experience = snapshot.total_experience + result.player_level = PlayerExperienceType.level_for_total_experience( + snapshot.total_experience + ) + result.world_layout = snapshot.world_layout + result.world_seed = snapshot.world_seed result.message = "save ready." return result @@ -451,31 +486,45 @@ func export_progression_archive(path: String) -> Dictionary: var source_path: String = _read_path() if source_path.is_empty(): return {"ok": false, "message": "there is no progression to export."} - var decoded: Dictionary = ProgressionSaveCodec.read_local_save( + return export_progression_archive_from_path( source_path, + path, source_path == _legacy_save_path(), ) + + +func export_progression_archive_from_path( + source_path: String, + destination_path: String, + allow_legacy_plaintext: bool = false, +) -> Dictionary: + if source_path.is_empty() or destination_path.is_empty(): + return {"ok": false, "message": "progression export is unavailable."} + var decoded: Dictionary = ProgressionSaveCodec.read_local_save( + source_path, + allow_legacy_plaintext, + ) if not bool(decoded.get("ok", false)): - return {"ok": false, "message": "the active progression could not be read."} + return {"ok": false, "message": "the selected progression could not be read."} var prepared: Dictionary = _prepare_external_save_data(decoded["data"]) if not bool(prepared.get("ok", false)): return prepared var bytes: PackedByteArray = ProgressionSaveCodec.encode_archive( prepared["data"], - path + ".codec.tmp", + destination_path + ".codec.tmp", ) if bytes.is_empty(): return {"ok": false, "message": "the progression archive could not be encoded."} var result: Dictionary = PortableFileGuard.write_guarded( - path, + destination_path, bytes, - PortableFileGuard.hash_file(path), + PortableFileGuard.hash_file(destination_path), _data_root.conflict_directory(), _data_root.device_id, ) if not bool(result.get("ok", false)): return {"ok": false, "message": "the progression archive could not be written."} - var verified: Dictionary = inspect_progression_archive(path) + var verified: Dictionary = inspect_progression_archive(destination_path) if not bool(verified.get("ok", false)): return {"ok": false, "message": "the progression archive could not be verified."} verified["message"] = "progression archive created." @@ -525,6 +574,83 @@ func import_progression_archive(path: String) -> Dictionary: } +func install_progression_archive_at_path( + archive_path: String, + destination_path: String, +) -> Dictionary: + if not _is_configured or destination_path.is_empty(): + return {"ok": false, "message": "progression import is unavailable."} + var inspected: Dictionary = inspect_progression_archive(archive_path) + if not bool(inspected.get("ok", false)): + return inspected + var result: Dictionary = _write_save_data_at_path( + inspected["data"], + destination_path, + PortableFileGuard.hash_file(destination_path), + ) + if not bool(result.get("ok", false)): + return {"ok": false, "message": "the imported progression could not be installed."} + return { + "ok": true, + "message": "progression imported as a new save slot.", + "catch_count": inspected["catch_count"], + "wallet_balance": inspected["wallet_balance"], + "discovered_species_count": inspected["discovered_species_count"], + "world_layout": inspected["world_layout"], + "world_seed": inspected["world_seed"], + } + + +func copy_progression_to_path( + source_path: String, + destination_path: String, + allow_legacy_plaintext: bool, + world_layout: StringName, + world_seed: int, +) -> Dictionary: + if ( + not _is_configured + or source_path.is_empty() + or destination_path.is_empty() + or not WorldLayoutType.is_valid(world_layout) + or world_seed <= 0 + or world_seed > MAX_WORLD_SEED + ): + return {"ok": false, "message": "progression duplication is unavailable."} + var decoded: Dictionary = ProgressionSaveCodec.read_local_save( + source_path, + allow_legacy_plaintext, + ) + if not bool(decoded.get("ok", false)): + return {"ok": false, "message": "the selected progression could not be read."} + var prepared: Dictionary = _prepare_external_save_data(decoded["data"]) + if not bool(prepared.get("ok", false)): + return prepared + var save_data: Dictionary = (prepared["data"] as Dictionary).duplicate(true) + var world_data: Dictionary = {} + if typeof(save_data.get("world")) == TYPE_DICTIONARY: + world_data = (save_data["world"] as Dictionary).duplicate(true) + world_data["layout"] = String(world_layout) + world_data["seed"] = world_seed + save_data["world"] = world_data + prepared = _prepare_external_save_data(save_data) + if not bool(prepared.get("ok", false)): + return prepared + var result: Dictionary = _write_save_data_at_path( + prepared["data"], + destination_path, + PortableFileGuard.hash_file(destination_path), + ) + if not bool(result.get("ok", false)): + return {"ok": false, "message": "the duplicated progression could not be written."} + return { + "ok": true, + "message": "save slot duplicated.", + "world_layout": String(world_layout), + "world_seed": world_seed, + } + + func initialize_new_game( world_seed: int = DEFAULT_WORLD_SEED, world_layout: StringName = WorldLayoutType.GENERATED, @@ -678,6 +804,20 @@ func _build_save_dictionary() -> Dictionary: ): return {} serialized_quality_masks[String(fish_id)] = quality_mask + var serialized_catch_counts: Dictionary = {} + var catch_counts: Dictionary[StringName, int] = ( + _collection_log.get_catch_counts() + ) + for fish_id: StringName in catch_counts: + var catch_count: int = catch_counts[fish_id] + if ( + fish_id.is_empty() + or catch_count <= 0 + or catch_count > CollectionLogType.MAX_CATCH_COUNT + or not _collection_log.has_discovered(fish_id) + ): + return {} + serialized_catch_counts[String(fish_id)] = catch_count var serialized_items: Array[Dictionary] = [] for owned: OwnedItemType in _bag.get_all_items(): if owned == null or not owned.is_valid(): @@ -725,6 +865,7 @@ func _build_save_dictionary() -> Dictionary: "collection": { "discovered_fish_ids": discovered_strings, "discovered_quality_masks": serialized_quality_masks, + "catch_counts": serialized_catch_counts, }, "inventory": { "next_catch_sequence": _inventory.get_next_catch_sequence(), @@ -926,6 +1067,30 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: ): return null snapshot.discovered_quality_masks[fish_id] = mask + var has_saved_catch_counts: bool = collection_data.has("catch_counts") + if ( + has_saved_catch_counts + and typeof(collection_data.get("catch_counts")) != TYPE_DICTIONARY + ): + return null + if has_saved_catch_counts: + var saved_catch_counts: Dictionary = collection_data["catch_counts"] + for key: Variant in saved_catch_counts: + if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME]: + return null + var fish_id := StringName(str(key)) + var catch_count: int = _read_integer( + saved_catch_counts[key], + -1, + CollectionLogType.MAX_CATCH_COUNT, + ) + if ( + fish_id.is_empty() + or not seen_discoveries.has(fish_id) + or catch_count <= 0 + ): + return null + snapshot.catch_counts[fish_id] = catch_count var seen_ids: Dictionary[StringName, bool] = {} var seen_sequences: Dictionary[int, bool] = {} @@ -976,6 +1141,13 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: fish_catch.catch_sequence ) snapshot.catches.append(fish_catch) + if ( + not has_saved_catch_counts + and seen_discoveries.has(fish_catch.fish_id) + ): + snapshot.catch_counts[fish_catch.fish_id] = ( + int(snapshot.catch_counts.get(fish_catch.fish_id, 0)) + 1 + ) snapshot.next_catch_sequence = maxi( requested_next_sequence, @@ -1504,15 +1676,23 @@ func _recover_path_write(path: String) -> void: func _write_current_save_data( save_data: Dictionary, expected_hash: String, +) -> Dictionary: + return _write_save_data_at_path(save_data, _save_path, expected_hash) + + +func _write_save_data_at_path( + save_data: Dictionary, + destination_path: String, + expected_hash: String, ) -> Dictionary: var bytes: PackedByteArray = ProgressionSaveCodec.encode_local_save( save_data, - _codec_scratch_path(), + destination_path + ".codec.tmp", ) if bytes.is_empty(): return {"ok": false} return PortableFileGuard.write_guarded( - _save_path, + destination_path, bytes, expected_hash, _data_root.conflict_directory(), @@ -1651,6 +1831,7 @@ func _restore_defaults() -> void: var empty_catches: Array[FishCatchType] = [] var empty_discoveries: Array[StringName] = [] var empty_quality_masks: Dictionary[StringName, int] = {} + var empty_catch_counts: Dictionary[StringName, int] = {} var default_items: Array[OwnedItemType] = [] var basic_rod := OwnedItemType.new() basic_rod.item_id = BASIC_ROD_ID @@ -1661,9 +1842,10 @@ func _restore_defaults() -> void: default_slots.fill(StringName()) default_slots[0] = BASIC_ROD_ID _inventory.replace_all_catches(empty_catches, 1) - _collection_log.replace_discovery_state( + _collection_log.replace_collection_state( empty_discoveries, empty_quality_masks, + empty_catch_counts, ) _wallet.restore_balance(0) _bag.replace_all_items(default_items) diff --git a/save/player_save_slot_catalog.gd b/save/player_save_slot_catalog.gd new file mode 100644 index 0000000..d3b9d23 --- /dev/null +++ b/save/player_save_slot_catalog.gd @@ -0,0 +1,591 @@ +class_name PlayerSaveSlotCatalog +extends RefCounted + +const FORMAT_VERSION: int = 1 +const MAX_SLOTS: int = 32 +const MAX_NAME_LENGTH: int = 32 +const LEGACY_SAVE_ID: StringName = &"player_save" + +signal slots_changed + +var _data_root: PlayerDataRoot +var _save_manager: PlayerSaveManager +var _manifest_path := "" +var _slots_directory := "" +var _slots: Array[Dictionary] = [] +var _active_slot_id := "" +var _expected_hash := "" +var _error_message := "" + + +func configure( + data_root: PlayerDataRoot, + save_manager: PlayerSaveManager, +) -> bool: + if data_root == null or save_manager == null or data_root.root_path.is_empty(): + return false + _data_root = data_root + _save_manager = save_manager + _manifest_path = data_root.path_for(&"save_slots") + _slots_directory = data_root.root_path.path_join("player/saves") + _slots.clear() + _active_slot_id = "" + _expected_hash = "" + _error_message = "" + if DirAccess.make_dir_recursive_absolute(_slots_directory) != OK: + _error_message = "the save-slot directory could not be created." + return false + _recover_interrupted_manifest_write() + var loaded: bool = _load_manifest() + if not loaded and FileAccess.file_exists(_manifest_path): + if not _preserve_invalid_manifest(): + _error_message = "the invalid save-slot catalog could not be preserved." + return false + var slot_count_before_discovery: int = _slots.size() + var active_before_discovery: String = _active_slot_id + _discover_untracked_saves() + if _active_slot_id.is_empty() and not _slots.is_empty(): + _active_slot_id = str(_slots.front().get("slot_id", "")) + if ( + not loaded + or _slots.size() != slot_count_before_discovery + or _active_slot_id != active_before_discovery + ): + if not _save_manifest(): + return false + return _select_configured_storage() + + +func get_error_message() -> String: + return _error_message + + +func list_slots() -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for entry: Dictionary in _slots: + result.append(_build_slot_summary(entry)) + result.sort_custom( + func(a: Dictionary, b: Dictionary) -> bool: + var a_played: int = int(a.get("last_played_at_unix", 0)) + var b_played: int = int(b.get("last_played_at_unix", 0)) + if a_played == b_played: + return int(a.get("created_at_unix", 0)) > int( + b.get("created_at_unix", 0) + ) + return a_played > b_played + ) + return result + + +func get_slot(slot_id: String) -> Dictionary: + var entry: Dictionary = _find_slot(slot_id) + return _build_slot_summary(entry) if not entry.is_empty() else {} + + +func get_active_slot_id() -> String: + return _active_slot_id + + +func has_slots() -> bool: + return not _slots.is_empty() + + +func ensure_active_slot() -> Dictionary: + if not _active_slot_id.is_empty() and not _find_slot(_active_slot_id).is_empty(): + return {"ok": true, "slot_id": _active_slot_id} + var created: Dictionary = create_empty_slot(_next_default_name()) + if not bool(created.get("ok", false)): + return created + var slot_id: String = str(created.get("slot_id", "")) + if not activate_slot(slot_id): + return {"ok": false, "message": "the new save slot could not be selected."} + return {"ok": true, "slot_id": slot_id} + + +func create_empty_slot(display_name: String) -> Dictionary: + if _slots.size() >= MAX_SLOTS: + return {"ok": false, "message": "the maximum number of save slots has been reached."} + var clean_name: String = normalized_name(display_name) + if clean_name.is_empty(): + return {"ok": false, "message": "enter a name for this save slot."} + var slot_id: String = _generate_slot_id() + var now: int = int(Time.get_unix_time_from_system()) + _slots.append({ + "slot_id": slot_id, + "display_name": clean_name, + "created_at_unix": now, + "last_played_at_unix": 0, + "legacy": false, + }) + if not _save_manifest(): + _slots.pop_back() + return {"ok": false, "message": "the save-slot catalog could not be updated."} + slots_changed.emit() + return {"ok": true, "slot_id": slot_id} + + +func duplicate_slot( + source_slot_id: String, + display_name: String, + world_layout: StringName, + world_seed: int, +) -> Dictionary: + if _slots.size() >= MAX_SLOTS: + return {"ok": false, "message": "the maximum number of save slots has been reached."} + var source: Dictionary = _find_slot(source_slot_id) + var clean_name: String = normalized_name(display_name) + if source.is_empty() or clean_name.is_empty(): + return {"ok": false, "message": "the selected save slot cannot be duplicated."} + var source_path: String = _existing_slot_path(source) + if source_path.is_empty(): + return {"ok": false, "message": "the selected save slot has no progression to duplicate."} + var slot_id: String = _generate_slot_id() + var destination: String = _slots_directory.path_join(slot_id + ".nfsave") + var copied: Dictionary = _save_manager.copy_progression_to_path( + source_path, + destination, + source_path.ends_with(".json"), + world_layout, + world_seed, + ) + if not bool(copied.get("ok", false)): + return copied + var now: int = int(Time.get_unix_time_from_system()) + _slots.append({ + "slot_id": slot_id, + "display_name": clean_name, + "created_at_unix": now, + "last_played_at_unix": 0, + "legacy": false, + }) + if not _save_manifest(): + _slots.pop_back() + _remove_if_present(destination) + return {"ok": false, "message": "the duplicated slot could not be recorded."} + slots_changed.emit() + return {"ok": true, "slot_id": slot_id} + + +func import_slot(archive_path: String, display_name: String) -> Dictionary: + if _slots.size() >= MAX_SLOTS: + return {"ok": false, "message": "the maximum number of save slots has been reached."} + var clean_name: String = normalized_name(display_name) + if clean_name.is_empty(): + return {"ok": false, "message": "the imported save needs a slot name."} + var slot_id: String = _generate_slot_id() + var destination: String = _slots_directory.path_join(slot_id + ".nfsave") + var installed: Dictionary = _save_manager.install_progression_archive_at_path( + archive_path, + destination, + ) + if not bool(installed.get("ok", false)): + return installed + var now: int = int(Time.get_unix_time_from_system()) + _slots.append({ + "slot_id": slot_id, + "display_name": clean_name, + "created_at_unix": now, + "last_played_at_unix": 0, + "legacy": false, + }) + if not _save_manifest(): + _slots.pop_back() + _remove_if_present(destination) + return {"ok": false, "message": "the imported slot could not be recorded."} + slots_changed.emit() + installed["slot_id"] = slot_id + return installed + + +func export_slot(slot_id: String, destination_path: String) -> Dictionary: + var entry: Dictionary = _find_slot(slot_id) + var source_path: String = _existing_slot_path(entry) + if entry.is_empty() or source_path.is_empty(): + return {"ok": false, "message": "the selected save slot cannot be exported."} + return _save_manager.export_progression_archive_from_path( + source_path, + destination_path, + source_path.ends_with(".json"), + ) + + +func activate_slot(slot_id: String) -> bool: + var entry: Dictionary = _find_slot(slot_id) + if entry.is_empty(): + return false + var previous_id: String = _active_slot_id + var previous_path: String = _configured_storage_path(previous_id) + var next_path: String = _slot_primary_path(entry) + if not _save_manager.select_storage(next_path, _data_root): + return false + _active_slot_id = slot_id + if _save_manifest(): + slots_changed.emit() + return true + _active_slot_id = previous_id + _save_manager.select_storage(previous_path, _data_root) + return false + + +func mark_played(slot_id: String) -> bool: + var index: int = _find_slot_index(slot_id) + if index < 0: + return false + var previous_played_at: int = int( + _slots[index].get("last_played_at_unix", 0) + ) + var previous_active: String = _active_slot_id + _slots[index]["last_played_at_unix"] = int(Time.get_unix_time_from_system()) + _active_slot_id = slot_id + if not _save_manifest(): + _slots[index]["last_played_at_unix"] = previous_played_at + _active_slot_id = previous_active + return false + slots_changed.emit() + return true + + +func rename_slot(slot_id: String, display_name: String) -> bool: + var index: int = _find_slot_index(slot_id) + var clean_name: String = normalized_name(display_name) + if index < 0 or clean_name.is_empty(): + return false + var previous: String = str(_slots[index].get("display_name", "")) + _slots[index]["display_name"] = clean_name + if not _save_manifest(): + _slots[index]["display_name"] = previous + return false + slots_changed.emit() + return true + + +func delete_slot(slot_id: String) -> bool: + var index: int = _find_slot_index(slot_id) + if index < 0: + return false + var previous_slots: Array[Dictionary] = [] + for previous_entry: Dictionary in _slots: + previous_slots.append(previous_entry.duplicate(true)) + var previous_active: String = _active_slot_id + var entry: Dictionary = _slots[index] + var source_path: String = _existing_slot_path(entry) + var preserved_path := "" + if not source_path.is_empty(): + preserved_path = _deleted_backup_path(entry, source_path) + if not _rename_file(source_path, preserved_path): + return false + _slots.remove_at(index) + if _active_slot_id == slot_id: + _active_slot_id = ( + str(_slots.front().get("slot_id", "")) + if not _slots.is_empty() + else "" + ) + if not _select_configured_storage() or not _save_manifest(): + _slots = previous_slots + _active_slot_id = previous_active + _select_configured_storage() + if not preserved_path.is_empty(): + _rename_file(preserved_path, source_path) + return false + for auxiliary: String in _slot_auxiliary_paths(entry): + _remove_if_present(auxiliary) + slots_changed.emit() + return true + + +static func normalized_name(value: String) -> String: + var clean_name: String = value.strip_edges().left(MAX_NAME_LENGTH) + if clean_name.is_empty(): + return "" + for index: int in clean_name.length(): + var codepoint: int = clean_name.unicode_at(index) + if codepoint < 32 or codepoint == 127: + return "" + return clean_name + + +func _build_slot_summary(entry: Dictionary) -> Dictionary: + if entry.is_empty(): + return {} + var summary: Dictionary = entry.duplicate(true) + var path: String = _existing_slot_path(entry) + var inspection: PlayerSaveInspection = _save_manager.inspect_progression_at_path( + path, + path.ends_with(".json"), + ) + summary["active"] = str(entry.get("slot_id", "")) == _active_slot_id + summary["has_save"] = inspection.can_continue() + summary["save_status"] = inspection.status + summary["status_message"] = inspection.message + summary["catch_count"] = inspection.catch_count + summary["wallet_balance"] = inspection.wallet_balance + summary["discovered_species_count"] = inspection.discovered_species_count + summary["player_level"] = inspection.player_level + summary["world_layout"] = String(inspection.world_layout) + summary["world_seed"] = inspection.world_seed + return summary + + +func _load_manifest() -> bool: + if not FileAccess.file_exists(_manifest_path): + return false + var file := FileAccess.open(_manifest_path, FileAccess.READ) + if file == null: + return false + var parser := JSON.new() + var error: Error = parser.parse(file.get_as_text()) + file.close() + if error != OK or typeof(parser.data) != TYPE_DICTIONARY: + return false + var data: Dictionary = parser.data + if int(data.get("format_version", -1)) != FORMAT_VERSION: + return false + if typeof(data.get("slots")) != TYPE_ARRAY: + return false + var loaded_slots: Array[Dictionary] = [] + var seen_ids: Dictionary[String, bool] = {} + for value: Variant in data["slots"]: + if typeof(value) != TYPE_DICTIONARY: + return false + var entry: Dictionary = (value as Dictionary).duplicate(true) + if not _valid_slot_entry(entry): + return false + var slot_id: String = str(entry["slot_id"]) + if seen_ids.has(slot_id): + return false + seen_ids[slot_id] = true + loaded_slots.append(entry) + _slots = loaded_slots + _active_slot_id = str(data.get("active_slot_id", "")) + if not _active_slot_id.is_empty() and not seen_ids.has(_active_slot_id): + _active_slot_id = "" + _expected_hash = PortableFileGuard.hash_file(_manifest_path) + return true + + +func _save_manifest() -> bool: + var data: Dictionary = { + "format_version": FORMAT_VERSION, + "active_slot_id": _active_slot_id, + "slots": _slots, + } + var result: Dictionary = PortableFileGuard.write_guarded( + _manifest_path, + JSON.stringify(data, "\t").to_utf8_buffer(), + _expected_hash, + _data_root.conflict_directory(), + _data_root.device_id, + ) + if bool(result.get("conflict", false)): + _data_root.report_conflict( + str(result.get("message", "")), + str(result.get("conflict_path", "")), + ) + if bool(result.get("ok", false)): + _expected_hash = str(result.get("hash", "")) + return true + _error_message = "the save-slot catalog could not be written safely." + return false + + +func _discover_untracked_saves() -> void: + var known_ids: Dictionary[String, bool] = {} + var has_legacy: bool = false + for entry: Dictionary in _slots: + known_ids[str(entry.get("slot_id", ""))] = true + has_legacy = has_legacy or bool(entry.get("legacy", false)) + var legacy_primary: String = _data_root.path_for(LEGACY_SAVE_ID) + var legacy_plaintext: String = legacy_primary.get_base_dir().path_join( + PlayerSaveManager.LEGACY_SAVE_FILENAME + ) + if ( + not has_legacy + and ( + FileAccess.file_exists(legacy_primary) + or FileAccess.file_exists(legacy_plaintext) + ) + ): + var legacy_id: String = _generate_slot_id() + _slots.append({ + "slot_id": legacy_id, + "display_name": _next_default_name(), + "created_at_unix": int(Time.get_unix_time_from_system()), + "last_played_at_unix": 0, + "legacy": true, + }) + known_ids[legacy_id] = true + var directory := DirAccess.open(_slots_directory) + if directory == null: + return + directory.list_dir_begin() + var filename: String = directory.get_next() + while not filename.is_empty(): + if not directory.current_is_dir() and filename.ends_with(".nfsave"): + var slot_id: String = filename.trim_suffix(".nfsave") + if _valid_slot_id(slot_id) and not known_ids.has(slot_id): + _slots.append({ + "slot_id": slot_id, + "display_name": _next_default_name(), + "created_at_unix": int(Time.get_unix_time_from_system()), + "last_played_at_unix": 0, + "legacy": false, + }) + known_ids[slot_id] = true + filename = directory.get_next() + directory.list_dir_end() + + +func _select_configured_storage() -> bool: + return _save_manager.select_storage( + _configured_storage_path(_active_slot_id), + _data_root, + ) + + +func _configured_storage_path(slot_id: String) -> String: + var entry: Dictionary = _find_slot(slot_id) + if not entry.is_empty(): + return _slot_primary_path(entry) + return _data_root.path_for(LEGACY_SAVE_ID) + + +func _slot_primary_path(entry: Dictionary) -> String: + if bool(entry.get("legacy", false)): + return _data_root.path_for(LEGACY_SAVE_ID) + return _slots_directory.path_join(str(entry.get("slot_id", "")) + ".nfsave") + + +func _existing_slot_path(entry: Dictionary) -> String: + if entry.is_empty(): + return "" + var primary: String = _slot_primary_path(entry) + if FileAccess.file_exists(primary): + return primary + if bool(entry.get("legacy", false)): + var plaintext: String = primary.get_base_dir().path_join( + PlayerSaveManager.LEGACY_SAVE_FILENAME + ) + if FileAccess.file_exists(plaintext): + return plaintext + return "" + + +func _slot_auxiliary_paths(entry: Dictionary) -> Array[String]: + var primary: String = _slot_primary_path(entry) + var paths: Array[String] = [ + primary + ".tmp", + primary + ".backup", + primary + ".codec.tmp", + ] + if bool(entry.get("legacy", false)): + var plaintext: String = primary.get_base_dir().path_join( + PlayerSaveManager.LEGACY_SAVE_FILENAME + ) + paths.append_array([ + plaintext + ".tmp", + plaintext + ".backup", + ]) + return paths + + +func _deleted_backup_path(entry: Dictionary, source_path: String) -> String: + var timestamp: String = Time.get_datetime_string_from_system().replace(":", "-") + var extension: String = ".json" if source_path.ends_with(".json") else ".nfsave" + var destination: String = _data_root.root_path.path_join( + "backups/saves/deleted-slot-%s-%s%s" + % [str(entry.get("slot_id", "")).left(8), timestamp, extension] + ) + if FileAccess.file_exists(destination): + destination = destination.trim_suffix(extension) + ( + "-%d%s" % [Time.get_ticks_usec(), extension] + ) + return destination + + +func _find_slot(slot_id: String) -> Dictionary: + var index: int = _find_slot_index(slot_id) + return _slots[index] if index >= 0 else {} + + +func _find_slot_index(slot_id: String) -> int: + for index: int in _slots.size(): + if str(_slots[index].get("slot_id", "")) == slot_id: + return index + return -1 + + +func _next_default_name() -> String: + var used: Dictionary[String, bool] = {} + for entry: Dictionary in _slots: + used[str(entry.get("display_name", "")).to_lower()] = true + var number: int = 1 + while used.has("save %d" % number): + number += 1 + return "save %d" % number + + +func _generate_slot_id() -> String: + var slot_id: String = Crypto.new().generate_random_bytes(16).hex_encode() + while _find_slot_index(slot_id) >= 0: + slot_id = Crypto.new().generate_random_bytes(16).hex_encode() + return slot_id + + +func _valid_slot_entry(entry: Dictionary) -> bool: + var display_name: String = str(entry.get("display_name", "")) + return ( + _valid_slot_id(str(entry.get("slot_id", ""))) + and display_name.length() <= MAX_NAME_LENGTH + and not normalized_name(display_name).is_empty() + and typeof(entry.get("created_at_unix")) in [TYPE_INT, TYPE_FLOAT] + and typeof(entry.get("last_played_at_unix")) in [TYPE_INT, TYPE_FLOAT] + and typeof(entry.get("legacy")) == TYPE_BOOL + ) + + +func _valid_slot_id(slot_id: String) -> bool: + if slot_id.length() != 32: + return false + for index: int in slot_id.length(): + var character: String = slot_id.substr(index, 1).to_lower() + if character not in "0123456789abcdef": + return false + return true + + +func _recover_interrupted_manifest_write() -> void: + var temporary: String = _manifest_path + ".tmp" + var backup: String = _manifest_path + ".backup" + if FileAccess.file_exists(_manifest_path): + _remove_if_present(temporary) + _remove_if_present(backup) + return + if FileAccess.file_exists(backup): + _rename_file(backup, _manifest_path) + _remove_if_present(temporary) + + +func _preserve_invalid_manifest() -> bool: + var destination: String = _data_root.migration_backup_directory().path_join( + "invalid-save-slots-%s.json" + % Time.get_datetime_string_from_system().replace(":", "-") + ) + if not _rename_file(_manifest_path, destination): + return false + _expected_hash = "" + _slots.clear() + _active_slot_id = "" + return true + + +func _rename_file(source: String, destination: String) -> bool: + if source.is_empty() or destination.is_empty(): + return false + if DirAccess.make_dir_recursive_absolute(destination.get_base_dir()) != OK: + return false + return DirAccess.rename_absolute(source, destination) == OK + + +func _remove_if_present(path: String) -> bool: + return not FileAccess.file_exists(path) or DirAccess.remove_absolute(path) == OK diff --git a/save/player_save_slot_catalog.gd.uid b/save/player_save_slot_catalog.gd.uid new file mode 100644 index 0000000..8906f5a --- /dev/null +++ b/save/player_save_slot_catalog.gd.uid @@ -0,0 +1 @@ +uid://djfeifv7xrqdt diff --git a/scripts/run_validations.sh b/scripts/run_validations.sh index 1368afc..2ebaeff 100755 --- a/scripts/run_validations.sh +++ b/scripts/run_validations.sh @@ -31,6 +31,7 @@ readonly -a QUICK_TESTS=( "tests/fishing_shop_controller_validation.gd" "tests/fishing_audio_validation.gd" "tests/fishing_surface_validation.gd" + "tests/friend_relationship_validation.gd" "tests/fur_pattern_validation.gd" "tests/generated_world_runtime_validation.gd" "tests/gathering_marker_surface_validation.gd" @@ -84,6 +85,7 @@ readonly -a NETWORK_TESTS=( "tests/chat_privacy_multiplayer_validation.gd" "tests/economy_regression_validation.gd" "tests/fish_showcase_multiplayer_validation.gd" + "tests/friend_multiplayer_validation.gd" "tests/fishing_multiplayer_validation.gd" "tests/job_multiplayer_validation.gd" "tests/movement_multiplayer_validation.gd" diff --git a/settings/keyboard_mouse_mapping_manager.gd b/settings/keyboard_mouse_mapping_manager.gd index d9cb03d..591bc52 100644 --- a/settings/keyboard_mouse_mapping_manager.gd +++ b/settings/keyboard_mouse_mapping_manager.gd @@ -177,6 +177,17 @@ func get_active_bindings() -> Dictionary: return _default_bindings.duplicate(true) +func get_binding(role: StringName) -> Dictionary: + var binding: Variant = get_active_bindings().get(str(role), {}) + if typeof(binding) != TYPE_DICTIONARY: + return {} + return (binding as Dictionary).duplicate(true) + + +func get_binding_label(role: StringName) -> String: + return binding_label(get_binding(role)) + + func get_role_label(role: StringName) -> String: return str(ROLE_LABELS.get(role, str(role).replace("_", " "))) diff --git a/settings/player_settings.gd b/settings/player_settings.gd index 4ea3cfa..59099fc 100644 --- a/settings/player_settings.gd +++ b/settings/player_settings.gd @@ -32,6 +32,7 @@ const UI_COMPACT_RENDER_HEIGHTS: Array[int] = [0, 408, 336, 264, 192] @export_range(0.001, 0.012, 0.0005) var mouse_camera_sensitivity: float = 0.005 @export_range(0.5, 5.0, 0.1) var controller_camera_sensitivity: float = 2.5 @export var invert_camera_y: bool = false +@export var swap_hotbar_camera_scroll: bool = false @export var on_screen_keyboard_enabled: bool = false @export var chat_draft: String = "" @export var chat_collapsed: bool = false @@ -79,6 +80,7 @@ func copy() -> PlayerSettings: result.mouse_camera_sensitivity = mouse_camera_sensitivity result.controller_camera_sensitivity = controller_camera_sensitivity result.invert_camera_y = invert_camera_y + result.swap_hotbar_camera_scroll = swap_hotbar_camera_scroll result.on_screen_keyboard_enabled = on_screen_keyboard_enabled result.chat_draft = chat_draft result.chat_collapsed = chat_collapsed diff --git a/settings/player_settings_manager.gd b/settings/player_settings_manager.gd index 6c8cf4f..3fd2a35 100644 --- a/settings/player_settings_manager.gd +++ b/settings/player_settings_manager.gd @@ -54,6 +54,10 @@ func load_settings() -> bool: if ( typeof(accessibility.get("auto_click_enabled")) != TYPE_BOOL or typeof(camera.get("invert_vertical")) != TYPE_BOOL + or ( + camera.has("swap_hotbar_camera_scroll") + and typeof(camera["swap_hotbar_camera_scroll"]) != TYPE_BOOL + ) or ( accessibility.has("on_screen_keyboard_enabled") and typeof(accessibility["on_screen_keyboard_enabled"]) != TYPE_BOOL @@ -109,6 +113,9 @@ func load_settings() -> bool: -1.0 ) loaded.invert_camera_y = camera["invert_vertical"] + loaded.swap_hotbar_camera_scroll = bool( + camera.get("swap_hotbar_camera_scroll", false) + ) loaded.on_screen_keyboard_enabled = bool( accessibility.get("on_screen_keyboard_enabled", false) ) @@ -237,6 +244,9 @@ func save_now() -> bool: "mouse_sensitivity": current_settings.mouse_camera_sensitivity, "controller_sensitivity": current_settings.controller_camera_sensitivity, "invert_vertical": current_settings.invert_camera_y, + "swap_hotbar_camera_scroll": ( + current_settings.swap_hotbar_camera_scroll + ), }, "audio": { "master": current_settings.master_volume, diff --git a/tests/art_tools_validation.gd b/tests/art_tools_validation.gd index f370605..3bc4633 100644 --- a/tests/art_tools_validation.gd +++ b/tests/art_tools_validation.gd @@ -81,12 +81,36 @@ func _run() -> void: "%NetworkSurfaceDrawingService" ) as NetworkSurfaceDrawingService var game_ui := main.get_node("%GameUI") as GameUI + var keyboard_mapping := main.get_node( + "%KeyboardMouseMappingManager" + ) as KeyboardMouseMappingManager var toolbar := game_ui.get_node( "%SurfaceDrawingToolbar" ) as SurfaceDrawingToolbar var chat_ui := game_ui.get_node("%ChatUI") as ChatUI assert(player != null and service != null and toolbar != null) assert(chat_ui != null) + assert(keyboard_mapping != null) + assert(keyboard_mapping.reset_mapping()) + await process_frame + var shop_prompt_key := game_ui.get_node("%ShopPromptKey") as Label + var storage_prompt_message := game_ui.get_node( + "%StoragePromptMessage" + ) as Label + assert(shop_prompt_key.text == "E") + assert(storage_prompt_message.text == "E open storage") + var remapped_interact := InputEventKey.new() + remapped_interact.physical_keycode = KEY_F + remapped_interact.pressed = true + assert(keyboard_mapping.set_binding( + KeyboardMouseMappingManager.ROLE_INTERACT, + keyboard_mapping.binding_from_event(remapped_interact), + )) + await process_frame + assert(shop_prompt_key.text == "F") + assert(storage_prompt_message.text == "F open storage") + assert(keyboard_mapping.reset_mapping()) + await process_frame assert(toolbar.get_parent() == chat_ui.get_parent()) assert( toolbar.get_index() > chat_ui.get_index(), @@ -252,6 +276,33 @@ func _run() -> void: else: assert(not typed_chat_entry.virtual_keyboard_enabled) assert(on_screen_keyboard.is_open()) + + # A bite temporarily owns gameplay input without discarding an in-progress + # message. The draft and insertion point return only after fishing releases + # that ownership, and any controller keyboard follows the restored field. + var chat_fishing_spot := main.get_node("%FishingSpot") as FishingSpot + assert(chat_fishing_spot != null) + var interrupted_draft := "finish this message after fishing" + var interrupted_caret: int = 12 + typed_chat_entry.text = interrupted_draft + typed_chat_entry.caret_column = interrupted_caret + chat_fishing_spot.call("_set_fishing_input_priority", true) + await process_frame + assert(not chat_ui.is_open()) + assert(not bool(chat_ui.get("_input_lock_applied"))) + assert(chat_ui.has_fishing_resume_pending()) + assert(not on_screen_keyboard.is_open()) + chat_ui.open_chat() + assert(not chat_ui.is_open()) + chat_fishing_spot.call("_set_fishing_input_priority", false) + for _frame: int in 4: + await process_frame + assert(chat_ui.is_open()) + assert(typed_chat_entry.text == interrupted_draft) + assert(typed_chat_entry.caret_column == interrupted_caret) + assert(bool(chat_ui.get("_input_lock_applied"))) + if not DisplayServer.has_feature(DisplayServer.FEATURE_VIRTUAL_KEYBOARD): + assert(on_screen_keyboard.is_open()) on_screen_keyboard.call("_close_keyboard", true) assert(typed_chat_entry.has_focus()) var left_bumper := InputEventJoypadButton.new() @@ -507,7 +558,7 @@ func _run() -> void: and stamp_button != null ) assert(export_button.text == "png") - assert(export_button.tooltip_text == "export aimed artwork as PNG") + assert(export_button.tooltip_text == "aim at artwork, then click to export PNG") assert(stamp_button.text == "stamp") assert(stamp_button.disabled) assert(toolbar.get_node_or_null("%CloseButton") == null) @@ -830,6 +881,18 @@ func _run() -> void: await process_frame assert(player.hotbar.get_selected_slot() == 1) assert(not service.is_active() and not toolbar.visible) + hotbar_ui.set_swap_hotbar_camera_scroll(true) + hotbar_ui._unhandled_input(wheel_down) + await process_frame + assert(player.hotbar.get_selected_slot() == 1) + var shifted_wheel_down := InputEventMouseButton.new() + shifted_wheel_down.button_index = MOUSE_BUTTON_WHEEL_DOWN + shifted_wheel_down.pressed = true + shifted_wheel_down.shift_pressed = true + hotbar_ui._unhandled_input(shifted_wheel_down) + await process_frame + assert(player.hotbar.get_selected_slot() == 2) + hotbar_ui.set_swap_hotbar_camera_scroll(false) var number_one := InputEventKey.new() number_one.physical_keycode = KEY_1 diff --git a/tests/camera_drag_validation.gd b/tests/camera_drag_validation.gd index 0299492..a2dd410 100644 --- a/tests/camera_drag_validation.gd +++ b/tests/camera_drag_validation.gd @@ -54,6 +54,26 @@ func _run() -> void: player.notification(NOTIFICATION_APPLICATION_FOCUS_OUT) assert(not bool(player.get("_camera_dragging"))) + var plain_wheel_down := InputEventMouseButton.new() + plain_wheel_down.button_index = MOUSE_BUTTON_WHEEL_DOWN + plain_wheel_down.pressed = true + var shifted_wheel_down := InputEventMouseButton.new() + shifted_wheel_down.button_index = MOUSE_BUTTON_WHEEL_DOWN + shifted_wheel_down.pressed = true + shifted_wheel_down.shift_pressed = true + player.apply_camera_settings(0.005, 2.5, false, false) + var default_zoom: float = float(player.get("_target_zoom")) + player._unhandled_input(plain_wheel_down) + assert(is_equal_approx(float(player.get("_target_zoom")), default_zoom)) + player._unhandled_input(shifted_wheel_down) + assert(float(player.get("_target_zoom")) > default_zoom) + player.apply_camera_settings(0.005, 2.5, false, true) + var swapped_zoom: float = float(player.get("_target_zoom")) + player._unhandled_input(shifted_wheel_down) + assert(is_equal_approx(float(player.get("_target_zoom")), swapped_zoom)) + player._unhandled_input(plain_wheel_down) + assert(float(player.get("_target_zoom")) > swapped_zoom) + player.queue_free() await process_frame print("Camera drag validation: PASS") diff --git a/tests/chat_privacy_multiplayer_validation.gd b/tests/chat_privacy_multiplayer_validation.gd index 5d38b08..e1d6f87 100644 --- a/tests/chat_privacy_multiplayer_validation.gd +++ b/tests/chat_privacy_multiplayer_validation.gd @@ -26,6 +26,10 @@ func _run_host() -> void: var main: Node = await _create_initialized_main() var session := main.get_node("%NetworkSession") as NetworkSession var save_manager := main.get("_save_manager") as PlayerSaveManager + assert(bool(main.call( + "_apply_world", WorldLayout.STARTER_ISLAND, 1, true + ))) + assert(session.set_host_world(WorldLayout.STARTER_ISLAND, 1)) assert(session.start_private_host(TEST_PORT)) assert(save_manager.initialize_new_game()) main.call("_enter_gameplay") diff --git a/tests/controller_menu_accessibility_validation.gd b/tests/controller_menu_accessibility_validation.gd index c2a58ee..bf856d3 100644 --- a/tests/controller_menu_accessibility_validation.gd +++ b/tests/controller_menu_accessibility_validation.gd @@ -6,6 +6,7 @@ const JoinGamePageScene = preload( const TitleScreenScene = preload("res://ui/title_screen.tscn") const PauseMenuScene = preload("res://ui/pause_menu.tscn") const SettingsPanelScene = preload("res://ui/settings_panel.tscn") +const SaveSlotsPageScene = preload("res://ui/save_slots_page.tscn") const BubbleConfirmationScene = preload( "res://ui/components/bubble_menu/bubble_confirmation_page.tscn" ) @@ -43,6 +44,7 @@ func _initialize() -> void: func _run() -> void: root.size = Vector2i(1280, 720) await _validate_primary_menu_navigation() + await _validate_save_slots_navigation() await _validate_join_game_navigation() await _validate_data_settings_navigation() await _validate_settings_adjustment_navigation() @@ -113,8 +115,20 @@ func _validate_primary_menu_navigation() -> void: (control as BaseButton).disabled = false title_controls.append(control) _expect( - title_controls.size() == 7, - "Title menu does not expose all seven primary actions.", + title_controls.size() == 5, + "Title menu does not expose its five primary actions.", + ) + var play_button := title.get_node("%PlayButton") as Button + _expect( + play_button.icon != null + and play_button.icon.resource_path + == "res://ui/icons/main_menu/continue.png", + "The Play action does not use the original Continue icon.", + ) + _expect( + not (title.get_node("%NewGameButton") as Control).visible + and not (title.get_node("%DeleteSaveButton") as Control).visible, + "Legacy New/Delete title bubbles are still visible.", ) _assert_directionally_reachable(title_controls.front(), title_controls) title.queue_free() @@ -141,12 +155,67 @@ func _validate_primary_menu_navigation() -> void: await process_frame +func _validate_save_slots_navigation() -> void: + var page := SaveSlotsPageScene.instantiate() as SaveSlotsPage + root.add_child(page) + await process_frame + page.open_page() + for _frame: int in 2: + await process_frame + await create_timer(0.25).timeout + var saves_tab := page.get_node("%SavesTab") as Button + var new_tab := page.get_node("%NewSlotTab") as Button + var content_panel := page.get_node("%ContentPanel") as PanelContainer + var tab_overlap: float = ( + saves_tab.get_global_rect().end.y + - content_panel.get_global_rect().position.y + ) + _expect( + is_equal_approx(tab_overlap, saves_tab.size.y * 0.5), + "Save-slot content does not cover the lower half of its organizer tabs.", + ) + _expect( + page.get_active_page_id() == &"new", + "An empty save catalog does not open directly to New Slot.", + ) + _assert_neighbor(saves_tab, &"focus_neighbor_right", new_tab) + _assert_neighbor(new_tab, &"focus_neighbor_left", saves_tab) + var new_controls: Array[Control] = [ + saves_tab, + new_tab, + page.get_node("%NewSlotName") as Control, + page.get_node("%GeneratedButton") as Control, + page.get_node("%StarterButton") as Control, + page.get_node("%RandomSeedButton") as Control, + page.get_node("%CustomSeedButton") as Control, + page.get_node("%CreateSlotButton") as Control, + page.get_node("%BackButton") as Control, + ] + _assert_directionally_reachable(new_tab, new_controls) + page.call("_select_page", &"saves", false) + await process_frame + var import_button := page.get_node("%ImportSlotButton") as Button + _expect( + saves_tab.find_valid_focus_neighbor(SIDE_BOTTOM) == import_button, + "An empty Save Slots page does not lead from its tab to Import Save.", + ) + _expect( + import_button.find_valid_focus_neighbor(SIDE_BOTTOM) + == page.get_node("%BackButton"), + "An empty Save Slots page does not lead from Import Save to Back.", + ) + page.queue_free() + await process_frame + + func _validate_join_game_navigation() -> void: var page := JoinGamePageScene.instantiate() as Control root.add_child(page) await process_frame page.show() + await process_frame var discover := page.get_node("%DiscoverButton") as Button + var friends := page.get_node("%FriendsButton") as Button var direct := page.get_node("%DirectButton") as Button var saved := page.get_node("%SavedButton") as Button var recent := page.get_node("%RecentButton") as Button @@ -161,8 +230,21 @@ func _validate_join_game_navigation() -> void: var delete := page.get_node("%DeleteButton") as Button var cancel := page.get_node("%CancelButton") as Button var back := page.get_node("%BackButton") as Button - var modes: Array[Control] = [discover, direct, saved, recent] + var direct_content := page.get_node("%DirectContent") as Control + var list_content := page.get_node("%ListContent") as Control + var content_panel := page.get_node("%ContentPanel") as PanelContainer + var modes: Array[Control] = [discover, friends, direct, saved, recent] + var tab_overlap: float = ( + discover.get_global_rect().end.y + - content_panel.get_global_rect().position.y + ) + _expect( + is_equal_approx(tab_overlap, discover.size.y * 0.5), + "Join-game content does not cover the lower half of its organizer tabs.", + ) + direct_content.hide() + list_content.show() address.hide() name_edit.hide() server_list.show() @@ -174,14 +256,15 @@ func _validate_join_game_navigation() -> void: _set_button_state(delete, false) _set_button_state(cancel, false) _set_button_state(back, true) - page.set("_mode", 0) + page.set("_mode", JoinGamePage.Mode.DISCOVER) page.call("_configure_controller_navigation") await process_frame _assert_neighbor(discover, &"focus_neighbor_bottom", server_list) _assert_neighbor(server_list, &"focus_neighbor_top", discover) _assert_neighbor(server_list, &"focus_neighbor_bottom", refresh) _assert_neighbor(refresh, &"focus_neighbor_right", join) - _assert_neighbor(back, &"focus_neighbor_left", join) + _assert_neighbor(join, &"focus_neighbor_bottom", back) + _assert_neighbor(back, &"focus_neighbor_top", join) var discover_controls: Array[Control] = modes.duplicate() discover_controls.append_array([server_list, refresh, join, back]) _assert_directionally_reachable(discover, discover_controls) @@ -209,18 +292,31 @@ func _validate_join_game_navigation() -> void: "Discovery's visible cursor and selected room should agree immediately.", ) + page.set("_mode", JoinGamePage.Mode.FRIENDS) + page.call("_configure_controller_navigation") + await process_frame + _assert_neighbor(friends, &"focus_neighbor_bottom", server_list) + _assert_neighbor(server_list, &"focus_neighbor_top", friends) + var friend_controls: Array[Control] = modes.duplicate() + friend_controls.append_array([server_list, refresh, join, back]) + _assert_directionally_reachable(friends, friend_controls) + + list_content.hide() + direct_content.show() address.show() address.editable = true server_list.hide() _set_button_state(refresh, false) _set_button_state(join, true) _set_button_state(save, true) - page.set("_mode", 1) + page.set("_mode", JoinGamePage.Mode.DIRECT) page.call("_configure_controller_navigation") await process_frame _assert_neighbor(direct, &"focus_neighbor_bottom", address) _assert_neighbor(address, &"focus_neighbor_top", direct) _assert_neighbor(address, &"focus_neighbor_bottom", join) + _assert_neighbor(save, &"focus_neighbor_bottom", back) + _assert_neighbor(back, &"focus_neighbor_top", save) var direct_controls: Array[Control] = modes.duplicate() direct_controls.append_array([address, join, save, back]) _assert_directionally_reachable(direct, direct_controls) @@ -251,8 +347,10 @@ func _validate_join_game_navigation() -> void: entry.display_name = "Saved server %d" % index saved_entries.append(entry) server_list.add_item(entry.display_name) - page.set("_mode", 2) + page.set("_mode", JoinGamePage.Mode.SAVED) page.set("_visible_entries", saved_entries) + direct_content.hide() + list_content.show() server_list.show() page.call("_configure_controller_navigation") page.call("_restore_entry_selection_and_focus", 1) @@ -387,8 +485,6 @@ func _validate_data_settings_navigation() -> void: "Open Data Folder left a controller-activatable dialog behind.", ) var change_data_folder := panel.get_node("%ChangeDataFolder") as Button - var export_progression := panel.get_node("%ExportProgression") as Button - var import_progression := panel.get_node("%ImportProgression") as Button var copy_fingerprint := panel.get_node("%CopyPlayerFingerprint") as Button var export_player := panel.get_node("%ExportPlayerIdentity") as Button var import_player := panel.get_node("%ImportPlayerIdentity") as Button @@ -398,8 +494,6 @@ func _validate_data_settings_navigation() -> void: data_tab, open_data_folder, change_data_folder, - export_progression, - import_progression, copy_fingerprint, export_player, import_player, @@ -423,7 +517,7 @@ func _validate_data_settings_navigation() -> void: _assert_neighbor( open_data_folder, &"focus_neighbor_bottom", - export_progression, + copy_fingerprint, ) _assert_neighbor( change_data_folder, @@ -433,43 +527,13 @@ func _validate_data_settings_navigation() -> void: _assert_neighbor( change_data_folder, &"focus_neighbor_bottom", - import_progression, + copy_fingerprint, ) _assert_neighbor( - export_progression, + copy_fingerprint, &"focus_neighbor_top", open_data_folder, ) - _assert_neighbor( - export_progression, - &"focus_neighbor_right", - import_progression, - ) - _assert_neighbor( - export_progression, - &"focus_neighbor_bottom", - copy_fingerprint, - ) - _assert_neighbor( - import_progression, - &"focus_neighbor_top", - change_data_folder, - ) - _assert_neighbor( - import_progression, - &"focus_neighbor_left", - export_progression, - ) - _assert_neighbor( - import_progression, - &"focus_neighbor_bottom", - copy_fingerprint, - ) - _assert_neighbor( - copy_fingerprint, - &"focus_neighbor_top", - export_progression, - ) _assert_neighbor( copy_fingerprint, &"focus_neighbor_bottom", @@ -582,6 +646,8 @@ func _validate_settings_adjustment_navigation() -> void: var on_screen_keyboard := panel.get_node( "%OnScreenKeyboardToggle" ) as Button + var swap_scroll := panel.get_node("%SwapScrollToggle") as Button + var invert_y := panel.get_node("%InvertYToggle") as Button var controller_binds := panel.get_node("%ControllerMapping") as Button var keyboard_binds := panel.get_node("%KeyboardMapping") as Button _expect( @@ -594,6 +660,26 @@ func _validate_settings_adjustment_navigation() -> void: and is_equal_approx(controller_slider.step, 0.1), "Sensitivity sliders do not retain their authored increments.", ) + _assert_neighbor( + invert_y, + &"focus_neighbor_bottom", + swap_scroll, + ) + _assert_neighbor( + swap_scroll, + &"focus_neighbor_top", + invert_y, + ) + _assert_neighbor( + swap_scroll, + &"focus_neighbor_bottom", + on_screen_keyboard, + ) + _assert_neighbor( + on_screen_keyboard, + &"focus_neighbor_top", + swap_scroll, + ) _assert_neighbor( on_screen_keyboard, &"focus_neighbor_bottom", @@ -623,7 +709,8 @@ func _validate_settings_adjustment_navigation() -> void: panel.get_node("%ControlsTab") as Control, mouse_slider, controller_slider, - panel.get_node("%InvertYToggle") as Control, + invert_y, + swap_scroll, on_screen_keyboard, controller_binds, keyboard_binds, diff --git a/tests/economy_regression_validation.gd b/tests/economy_regression_validation.gd index 9a53975..523dbb3 100644 --- a/tests/economy_regression_validation.gd +++ b/tests/economy_regression_validation.gd @@ -94,11 +94,15 @@ func _run() -> void: func _run_multiplayer_host() -> void: root.size = Vector2i(1280, 720) var main: Node = await _create_initialized_main() + var session := main.get_node("%NetworkSession") as NetworkSession + assert(bool(main.call( + "_apply_world", WorldLayout.STARTER_ISLAND, 1, true + ))) + assert(session.set_host_world(WorldLayout.STARTER_ISLAND, 1)) assert(bool(main.call("_prepare_private_host"))) var save_manager := main.get("_save_manager") as PlayerSaveManager assert(save_manager.initialize_new_game()) main.call("_enter_gameplay") - var session := main.get_node("%NetworkSession") as NetworkSession assert(session.set_host_open(true)) var client_connected := false # Loading the full client world can take longer on a cold import. Keep the @@ -126,7 +130,10 @@ func _run_multiplayer_host() -> void: var interaction := main.get("_shop_interaction") as FishingShopInteraction assert(interaction != null) remote_avatar.global_position = interaction.global_position - var completion_deadline: int = Time.get_ticks_msec() + 30000 + # The client can finish loading its local presentation well after the host + # has authenticated it, especially during a cold import. Keep the authority + # alive until the client completes the transaction sequence or disconnects. + var completion_deadline: int = Time.get_ticks_msec() + 90000 while Time.get_ticks_msec() < completion_deadline: await process_frame if session.get_authenticated_peer_ids().size() < 2: diff --git a/tests/fish_hotbar_showcase_validation.gd b/tests/fish_hotbar_showcase_validation.gd index 9421816..c2d687c 100644 --- a/tests/fish_hotbar_showcase_validation.gd +++ b/tests/fish_hotbar_showcase_validation.gd @@ -56,7 +56,7 @@ func _run() -> void: ) fish_catch.ensure_identity() player.inventory.add_catch(fish_catch) - player.collection_log.mark_quality_discovered( + player.collection_log.record_catch( fish_catch.fish_id, fish_catch.quality, ) @@ -132,6 +132,19 @@ func _run() -> void: int(saved_masks[String(fish.id)]) == FishQuality.bit_for(FishQuality.Tier.EXCEPTIONAL) ) + var saved_catch_counts: Dictionary = ( + (parsed as Dictionary)["collection"]["catch_counts"] + ) + assert(int(saved_catch_counts[String(fish.id)]) == 1) + var legacy_without_counts: Dictionary = parsed.duplicate(true) + (legacy_without_counts["collection"] as Dictionary).erase("catch_counts") + var legacy_snapshot: RefCounted = save_manager.call( + "_build_load_snapshot", + legacy_without_counts, + ) + assert(legacy_snapshot != null) + var recovered_counts: Dictionary = legacy_snapshot.get("catch_counts") + assert(int(recovered_counts.get(fish.id, 0)) == 1) assert(player.hotbar.clear_slot(1)) assert(player.experience.restore_total_experience(0)) @@ -159,6 +172,7 @@ func _run() -> void: FishQuality.Tier.EXCEPTIONAL, ) ) + assert(player.collection_log.get_catch_count(fish.id) == 1) assert(service.is_local_showcase_visible()) await _wait_for_held_fish_visibility(player, true) assert(player.inventory.remove_catch_by_id(fish_catch.catch_id) != null) @@ -183,8 +197,8 @@ 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 == 9) - assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10) + assert(NetworkProtocol.PROTOCOL_VERSION == 10) + assert(NetworkProtocol.ENET_CHANNEL_COUNT == 17) assert( NetworkProtocol.FISH_QUALITY_CAPABILITY == "fish_quality_v1" diff --git a/tests/fish_quality_validation.gd b/tests/fish_quality_validation.gd index 9c5d338..3441344 100644 --- a/tests/fish_quality_validation.gd +++ b/tests/fish_quality_validation.gd @@ -25,13 +25,60 @@ func _run() -> void: _validate_mail_round_trip() _validate_collection_mastery() _validate_version_four_migration() - assert(NetworkProtocol.PROTOCOL_VERSION == 9) - assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10) + _validate_fishing_protocol_v2() + assert(NetworkProtocol.PROTOCOL_VERSION == 10) + assert(NetworkProtocol.ENET_CHANNEL_COUNT == 17) + assert(NetworkProtocol.MOVEMENT_ANIMATION_CHANNEL == 11) + assert( + NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY + == "movement_reconciliation_v2" + ) + assert( + NetworkProtocol.FISHING_REPLICATION_CAPABILITY + == "fishing_replication_v2" + ) assert(NetworkProtocol.FISH_QUALITY_CAPABILITY == "fish_quality_v1") print("Fish quality validation: PASS") quit() +func _validate_fishing_protocol_v2() -> void: + var request: Dictionary = { + "request_id": "cast-1", + "session_id": "session-1", + "origin": [0.0, 1.0, 0.0], + "target": [0.0, 0.0, -4.0], + "charge": 0.5, + "rod_id": "basic_fishing_rod", + "reel_speed": 0.2, + "barrier_damage": 1, + "bite_multiplier": 1.0, + "rarity_multipliers": [], + "discovered_fish_ids": [], + "capacity_available": true, + "movement_sequence": 17, + } + assert(NetworkFishingProtocol.validate_cast_request(request).is_empty()) + var missing_sequence: Dictionary = request.duplicate(true) + missing_sequence.erase("movement_sequence") + assert( + NetworkFishingProtocol.validate_cast_request(missing_sequence) + == "Malformed fishing request." + ) + var invalid_sequence: Dictionary = request.duplicate(true) + invalid_sequence["movement_sequence"] = -1 + assert( + 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( + NetworkWorldSpawnProtocol.SNAPSHOT_CHANNEL + != NetworkFishingProtocol.SNAPSHOT_CHANNEL + ) + + func _validate_tiers_and_distribution() -> void: assert(FishQualityType.TIER_COUNT == 5) assert(FishQualityType.display_name(0) == "boring") @@ -194,7 +241,7 @@ 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.0)) + 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)) @@ -401,6 +448,11 @@ func _validate_collection_mastery() -> void: collection.get_quality_mask(&"bluegill") == FishQualityType.ALL_TIERS_MASK ) + assert(collection.get_catch_count(&"bluegill") == 0) + collection.record_catch(&"bluegill", FishQualityType.Tier.BORING) + collection.record_catch(&"bluegill", FishQualityType.Tier.SHINY) + assert(collection.get_catch_count(&"bluegill") == 2) + assert(int(collection.get_catch_counts()[&"bluegill"]) == 2) collection.queue_free() diff --git a/tests/fish_showcase_multiplayer_validation.gd b/tests/fish_showcase_multiplayer_validation.gd index 72f4a59..5161377 100644 --- a/tests/fish_showcase_multiplayer_validation.gd +++ b/tests/fish_showcase_multiplayer_validation.gd @@ -24,6 +24,10 @@ func _run() -> void: func _run_host() -> void: var main: Node = await _create_initialized_main() var session := main.get_node("%NetworkSession") as NetworkSession + assert(bool(main.call( + "_apply_world", WorldLayout.STARTER_ISLAND, 1, true + ))) + assert(session.set_host_world(WorldLayout.STARTER_ISLAND, 1)) assert(session.start_private_host(TEST_PORT)) var save_manager := main.get("_save_manager") as PlayerSaveManager assert(save_manager.initialize_new_game()) diff --git a/tests/fishing_authority_validation.gd b/tests/fishing_authority_validation.gd index 1f40086..e7c87bb 100644 --- a/tests/fishing_authority_validation.gd +++ b/tests/fishing_authority_validation.gd @@ -19,6 +19,12 @@ func _run() -> void: for _frame: int in 8: await process_frame assert(bool(main.get("_application_initialized"))) + assert(bool(main.call( + "_apply_world", + WorldLayout.GENERATED, + PlayerSaveManager.DEFAULT_WORLD_SEED, + true, + ))) assert(bool(main.call("_prepare_private_host"))) var save_manager := main.get("_save_manager") as PlayerSaveManager assert(save_manager.initialize_new_game()) @@ -151,9 +157,16 @@ func _run() -> void: attempt = attempts.get(session.get_local_peer_id()) assert(attempt != null) assert(player.bag.get_quantity(&"worms") == bait_quantity_before) + var fishing_priority_transitions: Array[bool] = [] + fishing_spot.fishing_input_priority_changed.connect( + func(active: bool) -> void: + fishing_priority_transitions.append(active) + ) service.call("_start_bite", attempt) await process_frame assert(attempt.phase == NetworkFishingAttempt.Phase.FIGHTING) + assert(fishing_spot.is_fishing_input_priority_active()) + assert(fishing_priority_transitions == [true]) assert(player.bag.get_quantity(&"worms") == bait_quantity_before - 1) var catalog: FishPool = main.get("fish_catalog") as FishPool assert(catalog != null) @@ -200,6 +213,7 @@ func _run() -> void: service.call("_on_attempt_escaped", session.get_local_peer_id()) await process_frame assert(not service.has_local_attempt()) + assert(fishing_spot.is_fishing_input_priority_active()) assert(fishing_status.text.is_empty()) assert(not fishing_status.visible) assert(not fishing_panel.visible) @@ -210,6 +224,8 @@ func _run() -> void: ): await process_frame assert(fishing_spot.state == FishingSpotType.FishingState.READY) + assert(not fishing_spot.is_fishing_input_priority_active()) + assert(fishing_priority_transitions == [true, false]) # Leaving a session during the cast presentation must release every local # action and equipment lock. This is the same cleanup path used by an diff --git a/tests/fishing_surface_validation.gd b/tests/fishing_surface_validation.gd index a6724a8..eb02c6c 100644 --- a/tests/fishing_surface_validation.gd +++ b/tests/fishing_surface_validation.gd @@ -490,7 +490,7 @@ func _validate_remote_presentation() -> void: await process_frame var origin: Vector3 = player.get_fishing_rod_tip().global_position var target: Vector3 = origin + Vector3(-4.0, -0.5, 0.0) - presentation.show_cast(origin, target) + presentation.show_cast(origin, target, "cast-attempt") var bobber := presentation.get("_bobber") as MeshInstance3D assert(bobber.visible) assert(presentation.get("_cast_tween") != null) @@ -500,6 +500,22 @@ func _validate_remote_presentation() -> void: await create_timer(0.2).timeout assert(not is_equal_approx(bobber.global_position.y, first_bob_y)) + # A client joining after the reliable cast event reconstructs the current + # remote fishing state from the observer summary instead of waiting for the + # next cast. This also covers a bite/fighting transition received by an + # already active observer. + presentation.cleanup() + presentation.synchronize_active("late-attempt", target, false) + assert(bobber.visible) + assert( + int(player.get("_fishing_visual_phase")) + == Player.FishingVisualPhase.FISHING + ) + presentation.synchronize_active("late-attempt", target, true) + assert(bool(player.get("_fighting_visual_active"))) + presentation.synchronize_active("late-attempt", target, false) + assert(not bool(player.get("_fighting_visual_active"))) + var fish_catch := FishCatchType.new() var fish: FishData = PondPool.candidates.front() fish_catch.fish = fish diff --git a/tests/friend_multiplayer_validation.gd b/tests/friend_multiplayer_validation.gd new file mode 100644 index 0000000..f8ef64d --- /dev/null +++ b/tests/friend_multiplayer_validation.gd @@ -0,0 +1,161 @@ +extends SceneTree + +const MainScene: PackedScene = preload("res://main/main.tscn") +const TEST_PORT: int = 18196 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var arguments := OS.get_cmdline_user_args() + if arguments.has("host"): + await _run_host() + return + if arguments.has("client"): + await _run_client() + return + push_error("Friend multiplayer validation needs host or client mode.") + quit(1) + + +func _run_host() -> void: + var main := await _create_initialized_main() + var session := main.get_node("%NetworkSession") as NetworkSession + var save_manager := main.get("_save_manager") as PlayerSaveManager + assert(bool(main.call( + "_apply_world", WorldLayout.STARTER_ISLAND, 1, true + ))) + assert(session.set_host_world(WorldLayout.STARTER_ISLAND, 1)) + assert(session.start_private_host(TEST_PORT)) + assert(save_manager.initialize_new_game()) + main.call("_enter_gameplay") + for _frame: int in 4: + await physics_frame + assert(session.set_host_open(true)) + + var remote_peer_id := await _wait_for_remote_peer(session) + assert(remote_peer_id > 1) + assert(session.peer_supports_capability( + remote_peer_id, NetworkProtocol.FRIENDS_CAPABILITY + )) + var remote_record := session.get_peer_record(remote_peer_id) + assert(remote_record != null) + var game_ui := main.get_node("%GameUI") as GameUI + var prompt_deadline := Time.get_ticks_msec() + 8000 + while ( + Time.get_ticks_msec() < prompt_deadline + and not game_ui.is_social_prompt_open() + ): + await process_frame + assert(game_ui.is_social_prompt_open()) + await create_timer(0.15).timeout + game_ui.call("_accept_social_prompt") + + var relationships := main.get_node( + "%PlayerRelationshipStore" + ) as PlayerRelationshipStore + var accepted_deadline := Time.get_ticks_msec() + 8000 + while ( + Time.get_ticks_msec() < accepted_deadline + and not relationships.is_friend(remote_record.identity_fingerprint) + ): + await process_frame + assert(relationships.is_friend(remote_record.identity_fingerprint)) + var friend := relationships.get_friend_record( + remote_record.identity_fingerprint + ) + assert(not str(friend.get("remote_presence_channel", "")).is_empty()) + assert(not str(friend.get("remote_invite_token", "")).is_empty()) + + var disconnect_deadline := Time.get_ticks_msec() + 8000 + while ( + Time.get_ticks_msec() < disconnect_deadline + and session.is_authenticated_peer(remote_peer_id) + ): + await process_frame + assert(not session.is_authenticated_peer(remote_peer_id)) + print("Friend multiplayer host validation: PASS") + await _cleanup(main, session) + + +func _run_client() -> void: + var main := await _create_initialized_main() + main.call( + "_on_title_join_game_requested", + "127.0.0.1:%d" % TEST_PORT, + ) + var session := main.get_node("%NetworkSession") as NetworkSession + var join_deadline := Time.get_ticks_msec() + 20000 + while Time.get_ticks_msec() < join_deadline: + await process_frame + if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY: + main.call("_confirm_server_trust") + if session.is_joined_client() and bool(main.get("_gameplay_started")): + break + assert(session.is_joined_client()) + assert(session.supports_server_capability( + NetworkProtocol.FRIENDS_CAPABILITY + )) + var host_record := session.get_peer_record(1) + assert(host_record != null) + var service := main.get_node( + "%NetworkPlayerListService" + ) as NetworkPlayerListService + assert(service.send_friend_request( + 1, + host_record.identity_fingerprint, + host_record.display_name, + )) + + var relationships := main.get_node( + "%PlayerRelationshipStore" + ) as PlayerRelationshipStore + var accepted_deadline := Time.get_ticks_msec() + 8000 + while ( + Time.get_ticks_msec() < accepted_deadline + and not relationships.is_friend(host_record.identity_fingerprint) + ): + await process_frame + assert(relationships.is_friend(host_record.identity_fingerprint)) + var friend := relationships.get_friend_record( + host_record.identity_fingerprint + ) + assert(not str(friend.get("remote_presence_channel", "")).is_empty()) + assert(not str(friend.get("remote_invite_token", "")).is_empty()) + print("Friend multiplayer client validation: PASS") + await _cleanup(main, session) + + +func _create_initialized_main() -> Node: + root.size = Vector2i(1280, 720) + var main := MainScene.instantiate() + root.add_child(main) + for _frame: int in 4: + await process_frame + if not bool(main.get("_application_initialized")): + main.call("_activate_selected_data_path", "", true) + for _frame: int in 8: + await process_frame + assert(bool(main.get("_application_initialized"))) + return main + + +func _wait_for_remote_peer(session: NetworkSession) -> int: + var deadline := Time.get_ticks_msec() + 20000 + while Time.get_ticks_msec() < deadline: + await process_frame + for peer_id: int in session.get_authenticated_peer_ids(): + if peer_id != session.get_local_peer_id(): + return peer_id + return 0 + + +func _cleanup(main: Node, session: NetworkSession) -> void: + session.disconnect_session("") + main.queue_free() + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout + quit() diff --git a/tests/friend_multiplayer_validation.gd.uid b/tests/friend_multiplayer_validation.gd.uid new file mode 100644 index 0000000..d243a6b --- /dev/null +++ b/tests/friend_multiplayer_validation.gd.uid @@ -0,0 +1 @@ +uid://cxltqyom4yt3j diff --git a/tests/friend_relationship_validation.gd b/tests/friend_relationship_validation.gd new file mode 100644 index 0000000..ce6bd90 --- /dev/null +++ b/tests/friend_relationship_validation.gd @@ -0,0 +1,76 @@ +extends SceneTree + +const FRIEND_FINGERPRINT := ( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +) + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var data_root := PlayerDataRoot.new() + root.add_child(data_root) + var portable_path := ProjectSettings.globalize_path( + "user://friend-relationship-validation" + ) + var created := data_root.create_unbound_root(portable_path) + assert(bool(created.get("ok", false))) + assert(data_root.activate_process_root(portable_path)) + + var store := PlayerRelationshipStore.new() + root.add_child(store) + store.configure_storage( + data_root.path_for(&"player_relationships"), data_root + ) + var local_capabilities := store.create_friend_capabilities() + var remote_capabilities := store.create_friend_capabilities() + assert(not local_capabilities.is_empty()) + assert(not remote_capabilities.is_empty()) + assert(store.add_friend( + FRIEND_FINGERPRINT, + "Pond Friend", + local_capabilities, + { + "presence_channel": remote_capabilities["presence_channel"], + "invite_token": remote_capabilities["invite_token"], + }, + )) + assert(store.is_friend(FRIEND_FINGERPRINT)) + + var reloaded := PlayerRelationshipStore.new() + root.add_child(reloaded) + reloaded.configure_storage( + data_root.path_for(&"player_relationships"), data_root + ) + assert(reloaded.is_friend(FRIEND_FINGERPRINT)) + var friend := reloaded.get_friend_record(FRIEND_FINGERPRINT) + assert( + str(friend["remote_presence_channel"]) + == str(remote_capabilities["presence_channel"]) + ) + assert( + str(friend["remote_invite_token"]) + == str(remote_capabilities["invite_token"]) + ) + assert(reloaded.set_blocked(FRIEND_FINGERPRINT, "Pond Friend", true)) + assert(reloaded.is_blocked(FRIEND_FINGERPRINT)) + assert(reloaded.is_muted(FRIEND_FINGERPRINT)) + assert(not reloaded.is_friend(FRIEND_FINGERPRINT)) + assert(reloaded.set_blocked(FRIEND_FINGERPRINT, "Pond Friend", false)) + assert(not reloaded.is_blocked(FRIEND_FINGERPRINT)) + assert(not reloaded.is_muted(FRIEND_FINGERPRINT)) + assert(not reloaded.is_friend(FRIEND_FINGERPRINT)) + + var hello := NetworkProtocol.make_client_hello( + "profile", + "Pond Friend", + "nonce", + ) + assert( + NetworkProtocol.FRIENDS_CAPABILITY + in PackedStringArray(hello.get("capability_flags", [])) + ) + print("Friend relationship validation: PASS") + quit() diff --git a/tests/friend_relationship_validation.gd.uid b/tests/friend_relationship_validation.gd.uid new file mode 100644 index 0000000..aa2acaf --- /dev/null +++ b/tests/friend_relationship_validation.gd.uid @@ -0,0 +1 @@ +uid://d3v4e1xtu8ytq diff --git a/tests/generated_world_runtime_validation.gd b/tests/generated_world_runtime_validation.gd index d97be79..64b6ee1 100644 --- a/tests/generated_world_runtime_validation.gd +++ b/tests/generated_world_runtime_validation.gd @@ -68,6 +68,7 @@ func _run() -> void: configured_generator.elevated_cliff_double_third_tier_chance = 0.0 root.add_child(region) await process_frame + assert(region.generate_world(FIRST_SEED)) await physics_frame var generator := region.get_node( @@ -348,6 +349,27 @@ func _validate_generated_region( generator.get_generated_chunks_root().get_child_count() == expected_chunk_count ) + var generated_root := generator.get_generated_chunks_root() + var terrain_collision_batches := generated_root.find_children( + "TerrainCollisionBatch_*", + "StaticBody3D", + true, + false, + ) + assert(not terrain_collision_batches.is_empty()) + assert( + terrain_collision_batches.size() + <= ceili(float(generator.grid_size.x) / generator.collision_batch_size) + * ceili(float(generator.grid_size.y) / generator.collision_batch_size) + ) + assert( + generated_root.find_children( + "TerrainCollision", + "StaticBody3D", + true, + false, + ).is_empty() + ) assert( generator.get_primary_terrain_meshes().size() == ( @@ -769,7 +791,6 @@ func _validate_elevated_cliff_feature( stable_id, ) assert(source_mesh != null and source_mesh.mesh != null) - assert(source_mesh.has_node("TerrainCollision")) continue layered_count += 1 var base_layer := chunk_root.get_node_or_null("TerrainBaseLayer") @@ -799,8 +820,6 @@ func _validate_elevated_cliff_feature( ) assert(base_mesh != null and base_mesh.mesh != null) assert(overlay_mesh != null and overlay_mesh.mesh != null) - assert(base_mesh.has_node("TerrainBaseLayerCollision")) - assert(overlay_mesh.has_node("TerrainCollision")) assert(layered_count == (29 if has_coastal_feature else 23)) var stacked_keys := generator.stacked_elevated_placement_keys() assert(stacked_keys.size() in [0, 4]) @@ -837,7 +856,6 @@ func _validate_elevated_cliff_feature( stacked_id, ) assert(stacked_mesh != null and stacked_mesh.mesh != null) - assert(stacked_mesh.has_node("TerrainCollision")) assert(stacked_count == 4) @@ -1056,7 +1074,11 @@ func _validate_decoration_transform( else: assert(collision == null) var query := PhysicsRayQueryParameters3D.create( - prop.global_position + Vector3.UP * 8.0, + # Stay below neighboring cliff overhangs while still beginning above the + # authored walkable surface. Regional collision batches intentionally + # keep every original shape on one body, so excluding an overhang body + # would also exclude the ground beneath the prop. + prop.global_position + Vector3.UP * 0.1, prop.global_position + Vector3.DOWN * 8.0, 1, ) @@ -1361,7 +1383,10 @@ func _validate_pond_collision( var hit := region.get_world_3d().direct_space_state.intersect_ray(query) assert(not hit.is_empty()) var collider := hit.get("collider") as Node - assert(collider != null and collider.get_parent() == pond) + assert( + collider != null + and collider.has_meta(&"terrain_collision_batch") + ) func _decoration_group_count( diff --git a/tests/job_multiplayer_validation.gd b/tests/job_multiplayer_validation.gd index c31d392..6c0fa5b 100644 --- a/tests/job_multiplayer_validation.gd +++ b/tests/job_multiplayer_validation.gd @@ -24,6 +24,10 @@ func _run_host() -> void: var main: Node = await _create_initialized_main() var session := main.get_node("%NetworkSession") as NetworkSession var jobs := main.get_node("%PlayerJobService") as PlayerJobService + assert(bool(main.call( + "_apply_world", WorldLayout.STARTER_ISLAND, 1, true + ))) + assert(session.set_host_world(WorldLayout.STARTER_ISLAND, 1)) assert(session.start_dedicated_host(TEST_PORT, 8, "127.0.0.1")) jobs.begin_progression_session() await process_frame diff --git a/tests/job_system_validation.gd b/tests/job_system_validation.gd index 8ffc633..1fe5ceb 100644 --- a/tests/job_system_validation.gd +++ b/tests/job_system_validation.gd @@ -89,6 +89,7 @@ func _run() -> void: net_page.call("_select_view", TheNetPage.View.LIFETIME) await process_frame _validate_fishnet_bounds(net_page) + await _validate_compact_value_tooltips(net_page) await _validate_unavailable_fishnet_layout() var wallet_before: int = player.wallet.get_balance() @@ -287,6 +288,53 @@ func _validate_fishnet_bounds(page: TheNetPage) -> void: assert(row.size.x <= jobs_scroll.size.x + 0.5) +func _validate_compact_value_tooltips(page: TheNetPage) -> void: + assert(str(page.call("_compact_integer", 2400)) == "2.4k") + assert(str(page.call("_compact_integer", 2500)) == "2.5k") + var list := page.get("_list") as VBoxContainer + assert(list != null) + var jobs: Array[Dictionary] = [{ + "title": "compact tooltip check", + "description": "compact values keep their exact values on hover", + "target": 2500, + "progress": 2400, + "fish_coin": 1200, + "experience": 3400, + }] + page.call("_build_job_rows", jobs, "") + await process_frame + var row := list.get_child(list.get_child_count() - 1) as PanelContainer + assert(row != null) + var progress_bar: ProgressBar + var progress_count: Label + var reward: VBoxContainer + for child: Node in row.find_children("*", "ProgressBar", true, false): + progress_bar = child as ProgressBar + break + for child: Node in row.find_children("*", "Label", true, false): + var label := child as Label + if label != null and label.text == "2.4k / 2.5k": + progress_count = label + break + for child: Node in row.find_children("*", "VBoxContainer", true, false): + var candidate := child as VBoxContainer + if candidate != null and candidate.tooltip_text == ( + "1200 fish coins · 3400 xp" + ): + reward = candidate + break + assert(progress_bar != null) + assert(progress_bar.tooltip_text == "2400 / 2500") + assert(progress_bar.mouse_filter == Control.MOUSE_FILTER_STOP) + assert(progress_count != null) + assert(progress_count.tooltip_text == "2400 / 2500") + assert(progress_count.mouse_filter == Control.MOUSE_FILTER_STOP) + assert(reward != null) + assert(reward.mouse_filter == Control.MOUSE_FILTER_STOP) + row.queue_free() + await process_frame + + func _validate_creature_jobs(jobs: PlayerJobService) -> void: var fish_count: int = 0 var insect_count: int = 0 diff --git a/tests/keyboard_mouse_mapping_validation.gd b/tests/keyboard_mouse_mapping_validation.gd index d8e9a88..db0bb71 100644 --- a/tests/keyboard_mouse_mapping_validation.gd +++ b/tests/keyboard_mouse_mapping_validation.gd @@ -28,6 +28,16 @@ func _run() -> void: defaults.size() == KeyboardMouseMappingManagerType.ROLE_ORDER.size() ) + assert( + not manager.get_binding( + KeyboardMouseMappingManagerType.ROLE_INTERACT + ).is_empty() + ) + assert( + manager.get_binding_label( + KeyboardMouseMappingManagerType.ROLE_INTERACT + ) == "e" + ) assert( str(defaults[ str(KeyboardMouseMappingManagerType.ROLE_PRIMARY_ACTION) @@ -120,6 +130,11 @@ func _run() -> void: interact_key.pressed = true panel._input(interact_key) assert(_has_physical_key(&"interact", KEY_F)) + assert( + manager.get_binding_label( + KeyboardMouseMappingManagerType.ROLE_INTERACT + ) == "f" + ) assert(not panel.is_capturing()) var settings_panel := SettingsPanelScene.instantiate() as SettingsPanel diff --git a/tests/late_join_multiplayer_validation.gd b/tests/late_join_multiplayer_validation.gd index f726dbc..3813486 100644 --- a/tests/late_join_multiplayer_validation.gd +++ b/tests/late_join_multiplayer_validation.gd @@ -3,6 +3,8 @@ extends SceneTree const MainScene: PackedScene = preload("res://main/main.tscn") const TEST_PORT: int = 18194 const LATE_MESSAGE: String = "late join visibility check" +const CLEAR_MESSAGE: String = "late join animation clear check" +const ACTIVE_ACTION: StringName = &"draw" func _initialize() -> void: @@ -26,8 +28,21 @@ func _run_host() -> void: var main: Node = await _create_initialized_main() var session := main.get_node("%NetworkSession") as NetworkSession var save_manager := main.get("_save_manager") as PlayerSaveManager + assert(bool(main.call( + "_apply_world", + WorldLayout.STARTER_ISLAND, + NetworkProtocol.DEFAULT_WORLD_SEED, + true, + ))) + assert(session.set_host_world( + WorldLayout.STARTER_ISLAND, + NetworkProtocol.DEFAULT_WORLD_SEED, + )) assert(session.start_private_host(TEST_PORT)) - assert(save_manager.initialize_new_game()) + assert(save_manager.initialize_new_game( + NetworkProtocol.DEFAULT_WORLD_SEED, + WorldLayout.STARTER_ISLAND, + )) main.call("_enter_gameplay") for _frame: int in 4: await physics_frame @@ -35,7 +50,26 @@ func _run_host() -> void: assert(await _wait_for_registry_size(session, 3)) var spawn := main.get_node("%PlayerSpawnService") as PlayerSpawnService assert(spawn.get_peer_ids().size() == 3) - await create_timer(5.0).timeout + var action_peer_id: int = await _wait_for_remote_action( + spawn, + ACTIVE_ACTION, + ) + assert(action_peer_id > 1) + assert(await _wait_for_avatar_bool( + spawn, + action_peer_id, + &"_active_item_is_net", + true, + )) + assert(await _wait_for_avatar_sitting(spawn, action_peer_id, true)) + assert(await _wait_for_avatar_action( + spawn, + action_peer_id, + &"", + )) + assert(await _wait_for_avatar_sitting(spawn, action_peer_id, false)) + var chat := main.get_node("%NetworkChatService") as NetworkChatService + assert(await _wait_for_history_message(chat, CLEAR_MESSAGE)) print("Late-join multiplayer host validation: PASS") await _cleanup(main, session) @@ -47,7 +81,23 @@ func _run_first_client() -> void: var preview: Dictionary = profile.get_persisted_appearance().duplicate(true) preview["scale"] = 0.9 assert(profile.preview_appearance(preview)) - await create_timer(2.0).timeout + var player := main.get("_player") as Player + assert(player != null) + var floor_deadline: int = Time.get_ticks_msec() + 5000 + while Time.get_ticks_msec() < floor_deadline and not player.is_on_floor(): + await physics_frame + assert(player.is_on_floor()) + player.toggle_sitting() + assert(player.is_sitting()) + assert(player.bag.add_item(&"crab_net")) + assert(player.hotbar.assign_item(0, &"crab_net")) + assert(player.hotbar.select_slot(0)) + for _frame: int in 2: + await process_frame + assert(bool(player.get("_active_item_is_net"))) + assert(player.begin_animation_action(ACTIVE_ACTION)) + await create_timer(0.5).timeout + assert(StringName(str(player.get("_animation_action_id"))) == ACTIVE_ACTION) var chat := main.get_node("%NetworkChatService") as NetworkChatService var deadline: int = Time.get_ticks_msec() + 15000 while Time.get_ticks_msec() < deadline: @@ -61,6 +111,15 @@ func _run_first_client() -> void: assert(_history_contains(chat, LATE_MESSAGE)) var spawn := main.get_node("%PlayerSpawnService") as PlayerSpawnService assert(spawn.get_peer_ids().size() == 3) + # Keep the action active until the late peer has joined and announced that + # it has received the lifecycle snapshot. This exercises client -> host -> + # existing client/late client replication, including the join-in-progress + # state carried by the spawn envelope. + await create_timer(1.0).timeout + player.end_animation_action() + player.toggle_sitting() + assert(not player.is_sitting()) + assert(await _wait_for_history_message(chat, CLEAR_MESSAGE)) print("Late-join first-client validation: PASS") await _cleanup(main, session) @@ -77,9 +136,28 @@ func _run_late_client() -> void: assert(session.get_authenticated_peer_ids().size() == 3) var spawn := main.get_node("%PlayerSpawnService") as PlayerSpawnService assert(spawn.get_peer_ids().size() == 3) + var action_peer_id: int = await _wait_for_remote_action( + spawn, + ACTIVE_ACTION, + ) + assert(action_peer_id > 1) + assert(await _wait_for_avatar_bool( + spawn, + action_peer_id, + &"_active_item_is_net", + true, + )) + assert(await _wait_for_avatar_sitting(spawn, action_peer_id, true)) var chat := main.get_node("%NetworkChatService") as NetworkChatService assert(chat.send_local_message(LATE_MESSAGE)) - await create_timer(1.0).timeout + assert(await _wait_for_avatar_action( + spawn, + action_peer_id, + &"", + )) + assert(await _wait_for_avatar_sitting(spawn, action_peer_id, false)) + assert(chat.send_local_message(CLEAR_MESSAGE)) + await create_timer(0.5).timeout print("Late-join late-client validation: PASS") await _cleanup(main, session) @@ -114,6 +192,136 @@ func _history_contains(service: NetworkChatService, body: String) -> bool: ) +func _wait_for_history_message( + service: NetworkChatService, + body: String, +) -> bool: + var deadline: int = Time.get_ticks_msec() + 10000 + while Time.get_ticks_msec() < deadline: + await process_frame + if _history_contains(service, body): + return true + return false + + +func _wait_for_remote_action( + spawn_service: PlayerSpawnService, + action_id: StringName, +) -> int: + var deadline: int = Time.get_ticks_msec() + 10000 + while Time.get_ticks_msec() < deadline: + await process_frame + for peer_id: int in spawn_service.get_peer_ids(): + var avatar: Player = spawn_service.get_avatar(peer_id) + if ( + avatar != null + and _observed_action_id(avatar) == action_id + ): + return peer_id + print( + "animation wait timed out: ", + _action_debug_snapshot(spawn_service), + ) + return 0 + + +func _wait_for_avatar_action( + spawn_service: PlayerSpawnService, + peer_id: int, + action_id: StringName, +) -> bool: + 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 _observed_action_id(avatar) == action_id + ): + return true + print( + "animation clear wait timed out: ", + _action_debug_snapshot(spawn_service), + ) + return false + + +func _wait_for_avatar_bool( + spawn_service: PlayerSpawnService, + peer_id: int, + property_name: StringName, + expected: bool, +) -> bool: + 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 bool(avatar.get(property_name)) == expected: + return true + return false + + +func _wait_for_avatar_sitting( + spawn_service: PlayerSpawnService, + peer_id: int, + expected: bool, +) -> bool: + 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: + return true + var avatar: Player = spawn_service.get_avatar(peer_id) + if 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( + "_network_authoritative_simulation" + )), + "last_input": int(avatar.get( + "_last_network_input_sequence" + )), + }, + ) + return false + + +func _observed_action_id(avatar: Player) -> StringName: + return StringName(str(avatar.get( + "_animation_action_id" + if bool(avatar.get("_network_authoritative_simulation")) + else "_network_target_animation_action_id" + ))) + + +func _action_debug_snapshot(spawn_service: PlayerSpawnService) -> Dictionary: + var result: Dictionary = {} + for peer_id: int in spawn_service.get_peer_ids(): + var avatar: Player = spawn_service.get_avatar(peer_id) + if avatar == null: + continue + result[peer_id] = { + "authoritative": bool(avatar.get( + "_network_authoritative_simulation" + )), + "source": str(avatar.get("_animation_action_id")), + "target": str(avatar.get( + "_network_target_animation_action_id" + )), + "last_input": int(avatar.get("_last_network_input_sequence")), + } + return result + + func _create_initialized_main() -> Node: root.size = Vector2i(1280, 720) var main: Node = MainScene.instantiate() diff --git a/tests/light_performance_profile_validation.gd b/tests/light_performance_profile_validation.gd index 4533abb..f56db35 100644 --- a/tests/light_performance_profile_validation.gd +++ b/tests/light_performance_profile_validation.gd @@ -24,6 +24,12 @@ func _run() -> void: for _frame: int in 8: await process_frame assert(bool(main.get("_application_initialized"))) + assert(bool(main.call( + "_apply_world", + WorldLayout.GENERATED, + PlayerSaveManager.DEFAULT_WORLD_SEED, + true, + ))) _validate_main_profile(main) _validate_minimal_weather(main) _stop_audio_players(main) @@ -42,6 +48,12 @@ func _run() -> void: for _frame: int in 8: await process_frame assert(bool(normal_main.get("_application_initialized"))) + assert(bool(normal_main.call( + "_apply_world", + WorldLayout.GENERATED, + PlayerSaveManager.DEFAULT_WORLD_SEED, + true, + ))) _validate_normal_profile(normal_main) _validate_new_game_music_transition(normal_main) _stop_audio_players(normal_main) diff --git a/tests/logbook_validation.gd b/tests/logbook_validation.gd index 1645096..7eca559 100644 --- a/tests/logbook_validation.gd +++ b/tests/logbook_validation.gd @@ -284,10 +284,11 @@ func _validate_page() -> void: ) fish_catch.ensure_identity() inventory.add_catch(fish_catch) + collection.record_catch(fish_catch.fish_id, fish_catch.quality) page.call("_select_entry", &"bluegill", &"bluegill") await process_frame assert(not _detail_text(page).contains("number owned")) - assert(_detail_text(page).contains("number caught\nunknown")) + assert(_detail_text(page).contains("number caught\n1")) assert(_detail_text(page).contains("body of water\nfresh water")) assert( _detail_text(page).contains( @@ -412,6 +413,8 @@ func _validate_page() -> void: assert(stats_view.visible) var stats_text: String = _descendant_label_text(stats_view) assert(stats_text.contains("catalog number")) + assert(stats_text.contains("number caught")) + assert(stats_text.contains("1")) assert(stats_text.contains("seasons")) assert(not stats_text.contains("number owned")) _validate_overlay_fonts(stats_view) diff --git a/tests/movement_multiplayer_validation.gd b/tests/movement_multiplayer_validation.gd index bbd9efd..f331534 100644 --- a/tests/movement_multiplayer_validation.gd +++ b/tests/movement_multiplayer_validation.gd @@ -32,7 +32,10 @@ func _validate_latency_smoothing() -> void: await process_frame avatar.set_process(false) avatar.set_physics_process(false) + _validate_compact_input_encoding() _validate_compact_snapshot_encoding() + _validate_compact_animation_encoding() + _validate_animation_action_ordering(avatar) _validate_transit_estimation() _validate_remote_snapshot_smoothing(avatar) _validate_reliable_jump_intent(avatar) @@ -41,6 +44,26 @@ func _validate_latency_smoothing() -> void: await process_frame +func _validate_compact_input_encoding() -> void: + var input: Dictionary = _movement_input( + 12, + true, + true, + true, + &"strike", + 4, + true, + ) + var encoded: Array = NetworkSession._encode_movement_input(input) + assert(encoded.size() == NetworkSession.MOVEMENT_INPUT_FIELD_COUNT) + assert(var_to_bytes(encoded).size() < var_to_bytes(input).size()) + assert(NetworkSession._decode_movement_input(encoded) == input) + assert(NetworkSession._decode_movement_input(encoded.slice(0, 3)).is_empty()) + var unknown_flags: Array = encoded.duplicate() + unknown_flags[3] = 1 << 12 + assert(NetworkSession._decode_movement_input(unknown_flags).is_empty()) + + func _validate_compact_snapshot_encoding() -> void: var moving_snapshot: Dictionary = _network_snapshot( Vector3.ZERO, @@ -54,31 +77,133 @@ func _validate_compact_snapshot_encoding() -> void: NetworkSession._encode_movement_snapshot(moving_snapshot) ) assert(var_to_bytes(encoded_snapshots).size() < 1200) + var expected_snapshot: Dictionary = moving_snapshot.duplicate(true) + expected_snapshot.erase("animation_state") + assert( + NetworkSession._decode_movement_snapshot(encoded_snapshots[0]) + == expected_snapshot + ) assert( NetworkSession._decode_movement_snapshot( - encoded_snapshots[0] - ) == moving_snapshot + encoded_snapshots[0].slice(0, 4) + ).is_empty() ) - var paused_snapshot: Dictionary = moving_snapshot.duplicate(true) - paused_snapshot["animation_state"] = ( + var invalid_snapshot_flags: Array = encoded_snapshots[0].duplicate() + invalid_snapshot_flags[5] = 1 << 12 + assert( + NetworkSession._decode_movement_snapshot( + invalid_snapshot_flags + ).is_empty() + ) + + +func _validate_compact_animation_encoding() -> void: + var paused_state := NetworkPlayerAnimationProtocol.make_state( + NetworkPlayerAnimationProtocol.LOCOMOTION_IDLE, + true, + &"strike", + 7, + 0.5, + true, + ) + var encoded: Array = NetworkSession._encode_movement_animation( + 2, + paused_state, + ) + assert(encoded.size() == NetworkSession.MOVEMENT_ANIMATION_FIELD_COUNT) + var decoded: Dictionary = NetworkSession._decode_movement_animation(encoded) + assert(int(decoded.get("peer_id", 0)) == 2) + assert(Dictionary(decoded.get("state", {})) == paused_state) + var advanced_state: Dictionary = paused_state.duplicate(true) + advanced_state["action"]["elapsed"] = 0.75 + assert( + NetworkSession._movement_animation_signature(advanced_state) + == NetworkSession._movement_animation_signature(paused_state) + ) + var action: Dictionary = paused_state["action"] + var encoded_action: Array = ( + NetworkSession._encode_movement_animation_action(action, true) + ) + assert( + encoded_action.size() + == NetworkSession.MOVEMENT_ANIMATION_ACTION_FIELD_COUNT + ) + var decoded_action: Dictionary = ( + NetworkSession._decode_movement_animation_action(encoded_action) + ) + assert(Dictionary(decoded_action.get("action", {})) == action) + assert(bool(decoded_action.get("sitting", false))) + assert( + NetworkSession._decode_movement_animation_action( + encoded_action.slice(0, 2) + ).is_empty() + ) + + +func _validate_animation_action_ordering(avatar: Player) -> void: + var draw := NetworkPlayerAnimationProtocol.make_action_state( + &"draw", 5, 0.2 + ) + avatar.apply_authoritative_network_animation_action(draw) + assert(StringName(str(avatar.get("_animation_action_id"))) == &"draw") + var cleared := NetworkPlayerAnimationProtocol.make_action_state( + &"", 6, 0.0 + ) + avatar.apply_authoritative_network_animation_action(cleared) + assert(StringName(str(avatar.get("_animation_action_id"))).is_empty()) + avatar.apply_authoritative_network_animation_action(draw) + assert(StringName(str(avatar.get("_animation_action_id"))).is_empty()) + avatar.apply_authoritative_network_animation_action( + NetworkPlayerAnimationProtocol.make_action_state(&"strike", 6, 0.0) + ) + assert(StringName(str(avatar.get("_animation_action_id"))).is_empty()) + var strike := NetworkPlayerAnimationProtocol.make_action_state( + &"strike", 7, 0.2 + ) + avatar.apply_authoritative_network_animation_action(strike) + avatar.apply_authoritative_network_animation_action( + NetworkPlayerAnimationProtocol.make_action_state( + &"strike", 8, 0.4, true + ) + ) + avatar.apply_authoritative_network_animation_action(strike) + assert(bool(avatar.get("_animation_action_paused"))) + avatar.apply_authoritative_network_animation_action( + NetworkPlayerAnimationProtocol.make_action_state(&"", 9, 0.0) + ) + + avatar.configure_network_remote(false) + avatar.apply_network_animation_state( NetworkPlayerAnimationProtocol.make_state( NetworkPlayerAnimationProtocol.LOCOMOTION_IDLE, true, &"strike", - 7, - 0.5, + 9, + 0.4, true, ) ) - var paused_encoded: Array = NetworkSession._encode_movement_snapshot( - paused_snapshot + avatar.apply_network_animation_state( + NetworkPlayerAnimationProtocol.make_state( + NetworkPlayerAnimationProtocol.LOCOMOTION_WALKING, + true, + &"draw", + 8, + 0.1, + ) ) - assert(paused_encoded.size() == NetworkSession.MOVEMENT_SNAPSHOT_FIELD_COUNT) - var paused_decoded: Dictionary = ( - NetworkSession._decode_movement_snapshot(paused_encoded) + assert( + StringName(str(avatar.get("_network_target_animation_action_id"))) + == &"strike" + ) + avatar.apply_network_animation_state( + NetworkPlayerAnimationProtocol.make_state( + NetworkPlayerAnimationProtocol.LOCOMOTION_IDLE, + true, + &"", + 10, + ) ) - assert(not paused_decoded.is_empty()) - assert(bool(paused_decoded["animation_state"]["action"]["paused"])) func _validate_transit_estimation() -> void: @@ -158,14 +283,16 @@ func _validate_remote_snapshot_smoothing(avatar: Player) -> void: avatar.set_local_control(true) avatar.global_position = Vector3(0.8, 0.0, 0.0) + var replay_input: Dictionary = _movement_input(2, false) + replay_input["axis"] = [1.0, 0.0] + var pending_inputs: Array[Dictionary] = [replay_input] avatar.apply_local_prediction_correction( moving_snapshot, - 10, + pending_inputs, 1.0 / 30.0, - 0.1, ) assert(avatar.global_position.x < 0.8) - assert(avatar.global_position.x > 0.7) + assert(avatar.global_position.x > 0.0) func _validate_reliable_jump_intent(avatar: Player) -> void: @@ -178,9 +305,8 @@ func _validate_reliable_jump_intent(avatar: Player) -> void: assert(int(avatar.get("_local_network_jump_intent_sequence")) == 20) avatar.apply_local_prediction_correction( _network_snapshot(avatar.global_position, Vector3.ZERO, 20), - 21, + [], 1.0 / 30.0, - 0.0, ) assert(not bool(avatar.capture_network_input(22)["jump"])) @@ -209,12 +335,24 @@ func _validate_reliable_jump_intent(avatar: Player) -> void: func _validate_stale_input_expiry(avatar: Player) -> void: avatar.configure_network_remote(true) - avatar.apply_authoritative_network_input(_movement_input(40, true)) + var high_latency_timeout := ( + Player.resolve_network_input_stale_timeout_seconds(400) + ) + assert(high_latency_timeout > Player.NETWORK_INPUT_STALE_TIMEOUT_SECONDS) + avatar.apply_authoritative_network_input( + _movement_input(40, true), + high_latency_timeout, + ) assert((avatar.get("_network_axis") as Vector2).length_squared() > 0.0) avatar.call( "_update_network_input_freshness", Player.NETWORK_INPUT_STALE_TIMEOUT_SECONDS + 0.01, ) + assert(not bool(avatar.get("_network_input_stale"))) + avatar.call( + "_update_network_input_freshness", + high_latency_timeout, + ) assert((avatar.get("_network_axis") as Vector2) == Vector2.ZERO) assert(not bool(avatar.get("_network_sprint"))) assert(bool(avatar.get("_network_input_stale"))) @@ -234,6 +372,7 @@ func _network_snapshot( "position": [position.x, position.y, position.z], "velocity": [velocity.x, velocity.y, velocity.z], "visual_yaw": 0.0, + "grounded": true, "animation_state": NetworkPlayerAnimationProtocol.make_state( NetworkPlayerAnimationProtocol.LOCOMOTION_RUNNING, true, @@ -247,8 +386,15 @@ func _run_host() -> void: var main: Node = await _create_initialized_main() var session := main.get_node("%NetworkSession") as NetworkSession var save_manager := main.get("_save_manager") as PlayerSaveManager + assert(session.set_host_world( + WorldLayout.STARTER_ISLAND, + NetworkProtocol.DEFAULT_WORLD_SEED, + )) assert(session.start_private_host(TEST_PORT)) - assert(save_manager.initialize_new_game()) + assert(save_manager.initialize_new_game( + NetworkProtocol.DEFAULT_WORLD_SEED, + WorldLayout.STARTER_ISLAND, + )) main.call("_enter_gameplay") for _frame: int in 4: await physics_frame diff --git a/tests/on_screen_keyboard_validation.gd b/tests/on_screen_keyboard_validation.gd index 1c324a2..a685c65 100644 --- a/tests/on_screen_keyboard_validation.gd +++ b/tests/on_screen_keyboard_validation.gd @@ -55,6 +55,7 @@ func _run() -> void: func _validate_default_and_persistence() -> void: var defaults := PlayerSettings.new() assert(not defaults.on_screen_keyboard_enabled) + assert(not defaults.swap_hotbar_camera_scroll) assert( KeyboardType.should_enable_for_controller(false, false, "Linux") ) @@ -87,11 +88,13 @@ func _validate_default_and_persistence() -> void: assert(manager.load_settings()) var edited: PlayerSettings = manager.current_settings.copy() edited.on_screen_keyboard_enabled = true + edited.swap_hotbar_camera_scroll = true assert(manager.apply_settings(edited)) var reloaded := SettingsManagerType.new() root.add_child(reloaded) assert(reloaded.load_settings()) assert(reloaded.current_settings.on_screen_keyboard_enabled) + assert(reloaded.current_settings.swap_hotbar_camera_scroll) manager.queue_free() reloaded.queue_free() diff --git a/tests/operator_multiplayer_validation.gd b/tests/operator_multiplayer_validation.gd index 059ced1..05f8ee9 100644 --- a/tests/operator_multiplayer_validation.gd +++ b/tests/operator_multiplayer_validation.gd @@ -36,6 +36,10 @@ func _run_host() -> void: as NetworkPlayerListService ) var bans := main.get_node("%HostBanStore") as HostBanStore + assert(bool(main.call( + "_apply_world", WorldLayout.STARTER_ISLAND, 1, true + ))) + assert(session.set_host_world(WorldLayout.STARTER_ISLAND, 1)) assert(session.start_private_host(TEST_PORT)) assert(session.set_host_open(true)) @@ -65,7 +69,7 @@ func _run_host() -> void: assert(players_page != null) players_page.call("_refresh") assert(_has_button_text(players_page, "deop")) - assert(_has_button_text(players_page, "clear art")) + assert(_has_button_tooltip(players_page, "scrub art")) var unban_deadline: int = Time.get_ticks_msec() + 12000 while ( @@ -149,9 +153,9 @@ func _run_client() -> void: assert(players_page != null) players_page.call("_refresh") var tabs := players_page.get("_tabs") as HBoxContainer - assert((tabs.get_child(2) as Button).visible) - players_page.call("_select_tab", 2) - assert(int(players_page.get("_current_tab")) == 2) + assert((tabs.get_child(3) as Button).visible) + players_page.call("_select_tab", 3) + assert(int(players_page.get("_current_tab")) == 3) var local_entry: PlayerListEntry = _entry_for_peer( service, session.get_local_peer_id() ) @@ -176,7 +180,7 @@ func _run_client() -> void: await process_frame assert(not session.is_local_operator()) assert(not service.is_local_moderator()) - assert(not (tabs.get_child(2) as Button).visible) + assert(not (tabs.get_child(3) as Button).visible) assert(int(players_page.get("_current_tab")) == 0) service.request_unban.rpc_id(1, SECOND_BANNED_FINGERPRINT) var disconnect_deadline: int = Time.get_ticks_msec() + 10000 @@ -252,3 +256,11 @@ func _has_button_text(root_node: Node, text: String) -> bool: if button != null and button.text == text: return true return false + + +func _has_button_tooltip(root_node: Node, tooltip: String) -> bool: + for node: Node in root_node.find_children("*", "Button", true, false): + var button := node as Button + if button != null and button.tooltip_text == tooltip: + return true + return false diff --git a/tests/profile_multiplayer_validation.gd b/tests/profile_multiplayer_validation.gd index ec7a669..4d2bd70 100644 --- a/tests/profile_multiplayer_validation.gd +++ b/tests/profile_multiplayer_validation.gd @@ -56,6 +56,10 @@ func _run_host() -> void: "kim", )) var save_manager := main.get("_save_manager") as PlayerSaveManager + assert(bool(main.call( + "_apply_world", WorldLayout.STARTER_ISLAND, 1, true + ))) + assert(session.set_host_world(WorldLayout.STARTER_ISLAND, 1)) assert(session.start_private_host(TEST_PORT)) var local_avatar := main.get_node("%PlayerSpawnService").get_avatar( 1 diff --git a/tests/progression_archive_validation.gd b/tests/progression_archive_validation.gd index 11fc51c..3fd9eff 100644 --- a/tests/progression_archive_validation.gd +++ b/tests/progression_archive_validation.gd @@ -82,27 +82,64 @@ func _run() -> void: assert(not FileAccess.file_exists(legacy_path)) _assert_opaque(save_path) + save_manager.set_autosave_enabled(false) + var save_slots := main.get("_save_slots") as PlayerSaveSlotCatalog + assert(save_slots != null) + assert(save_slots.configure(data_root, save_manager)) + var adopted_slots: Array[Dictionary] = save_slots.list_slots() + assert(adopted_slots.size() == 1) + var adopted_slot_id: String = str(adopted_slots.front().get("slot_id", "")) + assert(bool(adopted_slots.front().get("legacy", false))) + assert(bool(adopted_slots.front().get("has_save", false))) + + var duplicated: Dictionary = save_slots.duplicate_slot( + adopted_slot_id, + "another island", + WorldLayout.GENERATED, + 97531, + ) + assert(bool(duplicated.get("ok", false))) + var duplicated_slot_id: String = str(duplicated.get("slot_id", "")) + var duplicate_summary: Dictionary = save_slots.get_slot(duplicated_slot_id) + assert(int(duplicate_summary.get("wallet_balance", -1)) == 4321) + assert(int(duplicate_summary.get("world_seed", -1)) == 97531) + assert(save_slots.activate_slot(duplicated_slot_id)) + assert(save_manager.load_player_data()) + assert(player.wallet.get_balance() == 4321) + assert(save_manager.get_world_seed() == 97531) + assert(save_slots.rename_slot(duplicated_slot_id, "renamed island")) + + var slot_archive: String = data_root.progression_backup_directory().path_join( + "save-slot-export.nfsave" + ) + assert(bool(save_slots.export_slot( + duplicated_slot_id, + slot_archive, + ).get("ok", false))) + var imported_slot: Dictionary = save_slots.import_slot( + slot_archive, + "imported island", + ) + assert(bool(imported_slot.get("ok", false))) + var imported_slot_id: String = str(imported_slot.get("slot_id", "")) + assert(not save_slots.get_slot(imported_slot_id).is_empty()) + assert(save_slots.delete_slot(duplicated_slot_id)) + assert(save_slots.get_slot(duplicated_slot_id).is_empty()) + assert(save_slots.list_slots().size() == 2) + var settings_panels: Array[Node] = main.find_children( "*", "SettingsPanel", true, false ) assert(settings_panels.size() == 2) for settings_panel: SettingsPanel in settings_panels: - var export_button := settings_panel.get_node( - "%ExportProgression" - ) as Button - var import_button := settings_panel.get_node( - "%ImportProgression" - ) as Button - assert(export_button != null and import_button != null) - assert(not export_button.disabled and not import_button.disabled) - assert( - export_button.get_node(export_button.focus_neighbor_right) - == import_button - ) - assert( - import_button.get_node(import_button.focus_neighbor_left) - == export_button - ) + assert(settings_panel.get_node_or_null("%ExportProgression") == null) + assert(settings_panel.get_node_or_null("%ImportProgression") == null) + var save_slots_page := main.find_child( + "SaveSlotsPage", true, false + ) as SaveSlotsPage + assert(save_slots_page != null) + assert(save_slots_page.get_node("%ExportSlotButton") is Button) + assert(save_slots_page.get_node("%ImportSlotButton") is Button) var migrated_root: String = data_root.root_path.get_base_dir().path_join( "progression-migrated-data" @@ -118,6 +155,18 @@ func _run() -> void: assert(bool( ProgressionSaveCodec.read_local_save(migrated_save).get("ok", false) )) + assert(FileAccess.file_exists(migrated_root.path_join( + "player/save_slots.json" + ))) + var migrated_imported_slot: String = migrated_root.path_join( + "player/saves/%s.nfsave" % imported_slot_id + ) + assert(FileAccess.file_exists(migrated_imported_slot)) + assert(bool( + ProgressionSaveCodec.read_local_save(migrated_imported_slot).get( + "ok", false + ) + )) main.queue_free() for _frame: int in 4: diff --git a/tests/session_switch_multiplayer_validation.gd b/tests/session_switch_multiplayer_validation.gd index 68cfcc7..86c9a84 100644 --- a/tests/session_switch_multiplayer_validation.gd +++ b/tests/session_switch_multiplayer_validation.gd @@ -31,6 +31,10 @@ func _run() -> void: func _run_host(port: int, label: String) -> void: var main: Node = await _create_initialized_main() var session := main.get_node("%NetworkSession") as NetworkSession + assert(bool(main.call( + "_apply_world", WorldLayout.STARTER_ISLAND, 1, true + ))) + assert(session.set_host_world(WorldLayout.STARTER_ISLAND, 1)) assert(session.start_dedicated_host(port, 8, "127.0.0.1")) assert(session.set_host_open(true)) var remote_peer_id: int = await _wait_for_remote_peer(session) diff --git a/tests/surface_drawing_multiplayer_validation.gd b/tests/surface_drawing_multiplayer_validation.gd index ebba510..d0508b2 100644 --- a/tests/surface_drawing_multiplayer_validation.gd +++ b/tests/surface_drawing_multiplayer_validation.gd @@ -24,6 +24,10 @@ func _run() -> void: func _run_host() -> void: var main: Node = await _create_initialized_main() var session := main.get_node("%NetworkSession") as NetworkSession + assert(bool(main.call( + "_apply_world", WorldLayout.STARTER_ISLAND, 1, true + ))) + assert(session.set_host_world(WorldLayout.STARTER_ISLAND, 1)) assert(session.start_private_host(TEST_PORT)) var save_manager := main.get("_save_manager") as PlayerSaveManager assert(save_manager.initialize_new_game()) diff --git a/tests/surface_drawing_runtime_validation.gd b/tests/surface_drawing_runtime_validation.gd index 6fa7a9e..e04afc2 100644 --- a/tests/surface_drawing_runtime_validation.gd +++ b/tests/surface_drawing_runtime_validation.gd @@ -67,11 +67,23 @@ func _run() -> void: state["cells"][0]["author_fingerprint"] == session.get_local_identity_fingerprint() ) - var export_path: String = service.export_canvas_png(canvas_id) + service.set("_last_hovered_canvas_id", canvas_id) + service.set("_hovered_canvas_id", "") + var game_ui := main.get_node("%GameUI") as GameUI + var toolbar := game_ui.get_node( + "%SurfaceDrawingToolbar" + ) as SurfaceDrawingToolbar + var export_button := toolbar.get_node("%ExportButton") as Button + export_button.pressed.emit() + var exported_entries: Array[Dictionary] = service.get_saved_stamp_entries() + assert(exported_entries.size() == 1) + var export_path: String = str(exported_entries[0]["path"]) assert(not export_path.is_empty()) var data_root := main.get("_data_root") as PlayerDataRoot assert(export_path.begins_with(data_root.root_path.path_join("artwork"))) assert(FileAccess.file_exists(export_path)) + var status_label := game_ui.get_node("%StatusLabel") as Label + assert(status_label.text == "artwork exported to the data folder • artwork") var exported_image: Image = Image.load_from_file(export_path) assert(exported_image.get_size() == Vector2i(16, 16)) assert(exported_image.get_pixel(8, 7).is_equal_approx( diff --git a/tests/world_layout_validation.gd b/tests/world_layout_validation.gd index 86bb00a..dd40fcf 100644 --- a/tests/world_layout_validation.gd +++ b/tests/world_layout_validation.gd @@ -84,6 +84,11 @@ func _validate_world_switching() -> void: root.add_child(world) await process_frame assert(world.get_world_layout() == WorldLayout.GENERATED) + var initial_generator := world.get_node( + "Regions/GeneratedWorldRegion/Terrain/TerrainChunkGenerator" + ) as TerrainChunkGenerator + assert(initial_generator != null) + assert(initial_generator.get_generated_chunks_root() == null) assert(world.get_fishing_shop() != null) assert(world.get_player_storage() != null) _validate_world_boundary_clearance(world) diff --git a/tests/world_spawn_multiplayer_validation.gd b/tests/world_spawn_multiplayer_validation.gd index c2b4101..45be081 100644 --- a/tests/world_spawn_multiplayer_validation.gd +++ b/tests/world_spawn_multiplayer_validation.gd @@ -38,6 +38,10 @@ func _run_host() -> void: var session := main.get_node("%NetworkSession") as NetworkSession var service: Node = main.get_node("%NetworkWorldSpawnService") var world_time := main.get_node("%WorldTimeService") as WorldTimeService + assert(bool(main.call( + "_apply_world", WorldLayout.STARTER_ISLAND, 1, true + ))) + assert(session.set_host_world(WorldLayout.STARTER_ISLAND, 1)) assert(session.start_dedicated_host(TEST_PORT, 8, "127.0.0.1")) assert(world_time.synchronize_calendar_time(12.0, SUMMER_DATE_ID)) assert(world_time.set_authoritative_time(12.0)) diff --git a/tests/world_spawn_protocol_validation.gd b/tests/world_spawn_protocol_validation.gd index 66b43fb..f529389 100644 --- a/tests/world_spawn_protocol_validation.gd +++ b/tests/world_spawn_protocol_validation.gd @@ -16,7 +16,7 @@ const CalendarSeasonType = preload("res://world/calendar_season.gd") func _initialize() -> void: - assert(NetworkProtocol.PROTOCOL_VERSION == 9) + assert(NetworkProtocol.PROTOCOL_VERSION == 10) assert( NetworkWorldSpawnProtocol.CAPABILITY == NetworkProtocol.WORLD_SPAWN_CAPABILITY diff --git a/tests/world_time_multiplayer_validation.gd b/tests/world_time_multiplayer_validation.gd index abdd1e8..635ad4f 100644 --- a/tests/world_time_multiplayer_validation.gd +++ b/tests/world_time_multiplayer_validation.gd @@ -30,6 +30,10 @@ func _run_host() -> void: var world_weather := ( main.get_node("%WorldWeatherService") as WorldWeatherService ) + assert(bool(main.call( + "_apply_world", WorldLayout.STARTER_ISLAND, 1, true + ))) + assert(session.set_host_world(WorldLayout.STARTER_ISLAND, 1)) assert(session.start_private_host(TEST_PORT)) assert(world_time.set_authoritative_time(INITIAL_HOST_TIME)) assert(world_weather.set_authoritative_weather( diff --git a/ui/chat_ui.gd b/ui/chat_ui.gd index e282950..761ae4f 100644 --- a/ui/chat_ui.gd +++ b/ui/chat_ui.gd @@ -28,6 +28,7 @@ const SPEECH_POINTER_OVERLAP: float = 3.0 const ANIMALESE_FULL_VOLUME_DISTANCE: float = 4.0 const ANIMALESE_SILENT_DISTANCE: float = 24.0 const ANIMALESE_SILENT_VOLUME_DB: float = -80.0 +const MAX_EDITOR_GIVE_BALANCE: int = 1_000_000_000_000 const MOBILE_COMPACT_WIDTH: float = 620.0 const MOBILE_EXPANDED_WIDTH: float = 820.0 const MOBILE_COMPACT_HEIGHT: float = 220.0 @@ -142,6 +143,14 @@ var _send_pending: bool = false var _pending_send_body: String = "" var _controller_refocused: bool = false var _input_lock_applied: bool = false +var _fishing_input_priority_active: bool = false +var _fishing_resume_pending: bool = false +var _suspended_chat_state_valid: bool = false +var _suspended_chat_text: String = "" +var _suspended_chat_caret: int = 0 +var _suspended_chat_had_selection: bool = false +var _suspended_chat_selection_from: int = 0 +var _suspended_chat_selection_to: int = 0 var _last_submit_frame: int = -1 var _output_scale: float = 1.0 var _dock_right: bool = false @@ -325,6 +334,7 @@ func _update_status_effect_icons() -> void: func open_chat() -> void: if ( _opened or not _available or _service == null + or _fishing_input_priority_active or not _session.is_gameplay_session_active() ): return @@ -361,12 +371,115 @@ func open_command_chat() -> void: func set_available(value: bool) -> void: _available = value if not value: + _clear_suspended_chat_state() _send_pending = false _pending_send_body = "" _entry.editable = true close_chat() - _flush_draft() - _refresh_visibility() + _flush_draft() + _refresh_visibility() + elif _fishing_resume_pending and not _fishing_input_priority_active: + call_deferred("_resume_chat_after_fishing") + + +func set_fishing_input_priority(active: bool) -> void: + if _fishing_input_priority_active == active: + return + _fishing_input_priority_active = active + if active: + if not _opened: + return + _capture_suspended_chat_state() + _fishing_resume_pending = not _send_pending + close_chat(true) + elif _fishing_resume_pending: + call_deferred("_resume_chat_after_fishing") + + +func has_fishing_resume_pending() -> bool: + return _fishing_resume_pending + + +func get_text_entry_control() -> LineEdit: + return _entry + + +func _capture_suspended_chat_state() -> void: + _suspended_chat_state_valid = true + _suspended_chat_text = _entry.text + _suspended_chat_caret = _entry.caret_column + _suspended_chat_had_selection = _entry.has_selection() + if _suspended_chat_had_selection: + _suspended_chat_selection_from = ( + _entry.get_selection_from_column() + ) + _suspended_chat_selection_to = _entry.get_selection_to_column() + else: + _suspended_chat_selection_from = 0 + _suspended_chat_selection_to = 0 + + +func _resume_chat_after_fishing() -> void: + if _fishing_input_priority_active or not _fishing_resume_pending: + return + if ( + not _suspended_chat_state_valid + or not _available + or _service == null + or _session == null + or not _session.is_gameplay_session_active() + ): + _clear_suspended_chat_state() + return + var restored_text: String = _suspended_chat_text + var restored_caret: int = _suspended_chat_caret + var restored_had_selection: bool = _suspended_chat_had_selection + var restored_selection_from: int = _suspended_chat_selection_from + var restored_selection_to: int = _suspended_chat_selection_to + _clear_suspended_chat_state() + _entry.text = restored_text + _entry.caret_column = clampi(restored_caret, 0, restored_text.length()) + open_chat() + if not _opened: + return + call_deferred( + "_restore_suspended_chat_edit_state", + restored_caret, + restored_had_selection, + restored_selection_from, + restored_selection_to, + ) + + +func _restore_suspended_chat_edit_state( + caret: int, + had_selection: bool, + selection_from: int, + selection_to: int, +) -> void: + if not _opened or not _entry.visible: + return + var text_length: int = _entry.text.length() + _entry.caret_column = clampi(caret, 0, text_length) + if had_selection: + _entry.select( + clampi(selection_from, 0, text_length), + clampi(selection_to, 0, text_length), + ) + else: + _entry.deselect() + _entry.grab_focus() + _refresh_input_ownership() + + +func _clear_suspended_chat_state() -> void: + _fishing_resume_pending = false + _suspended_chat_state_valid = false + _suspended_chat_text = "" + _suspended_chat_caret = 0 + _suspended_chat_had_selection = false + _suspended_chat_selection_from = 0 + _suspended_chat_selection_to = 0 func close_chat(preserve_status: bool = false) -> void: @@ -942,7 +1055,7 @@ func _send() -> void: if submitted_text.length() == 0: close_chat() return - if _handle_editor_world_command(submitted_text): + if _handle_editor_command(submitted_text): return _send_pending = true _pending_send_body = submitted_text @@ -959,29 +1072,27 @@ func _send() -> void: _set_status("Sending…") -func _handle_editor_world_command(body: String) -> bool: +func _handle_editor_command(body: String) -> bool: # These commands are deliberately limited to sessions launched by the - # Godot editor. Exported builds do not have the editor feature tag, and a - # joined editor client must not be able to mutate its host's world. + # Godot editor. Exported builds do not have the editor feature tag. if not OS.has_feature("editor"): return false var command_text: String = body.strip_edges() - if not ( - command_text.begins_with("/time") - or command_text.begins_with("/weather") - ): - return false var parts: PackedStringArray = command_text.split(" ", false) + if parts.is_empty() or not String(parts[0]).begins_with("/"): + return false var command: String = String(parts[0]).trim_prefix("/").to_lower() + if command not in ["give", "time", "weather"]: + return false var result: String = "" - if _session == null or not _session.is_host(): + if command == "give": + result = _apply_editor_give_command(parts) + elif _session == null or not _session.is_host(): result = "Editor world commands require the authoritative host." elif command == "time": result = _apply_editor_time_command(parts) - elif command == "weather": - result = _apply_editor_weather_command(parts) else: - return false + result = _apply_editor_weather_command(parts) _entry.clear() _flush_draft() _set_status(result) @@ -989,6 +1100,28 @@ func _handle_editor_world_command(body: String) -> bool: return true +func _apply_editor_give_command(parts: PackedStringArray) -> String: + if parts.size() != 2 or _player == null or _player.wallet == null: + return "Usage: /give [positive integer]" + var amount_text: String = String(parts[1]) + if not amount_text.is_valid_int(): + return "Usage: /give [positive integer]" + var amount: int = amount_text.to_int() + var current_balance: int = _player.wallet.get_balance() + if amount <= 0: + return "Usage: /give [positive integer]" + if current_balance > MAX_EDITOR_GIVE_BALANCE - amount: + return "Editor balance cannot exceed %d fish coins." % ( + MAX_EDITOR_GIVE_BALANCE + ) + if not _player.wallet.credit(amount): + return "Editor fish coins could not be added." + return "Added %d fish coins. Balance: %d." % [ + amount, + _player.wallet.get_balance(), + ] + + func _apply_editor_time_command(parts: PackedStringArray) -> String: if parts.size() != 2 or _world_time == null: return "Usage: /time [dawn, day, dusk, night]" @@ -1043,6 +1176,7 @@ func _on_local_message_confirmed(message: Dictionary) -> void: return _send_pending = false _pending_send_body = "" + _clear_suspended_chat_state() _entry.editable = true _entry.clear() _set_status("") @@ -1194,6 +1328,11 @@ func _on_rejected(message: String) -> void: _pending_send_body = "" _entry.editable = true _set_status(message) + if _suspended_chat_state_valid: + _fishing_resume_pending = true + if not _fishing_input_priority_active: + call_deferred("_resume_chat_after_fishing") + return if not _opened: open_chat() else: diff --git a/ui/game_ui.gd b/ui/game_ui.gd index e0b87c6..6efea59 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -79,6 +79,8 @@ signal passive_pointer_ui_changed(is_enabled: bool) signal player_menu_backdrop_visibility_changed(is_visible: bool) signal shop_backdrop_visibility_changed(is_visible: bool) signal virtual_pointer_mode_changed(is_active: bool) +signal social_prompt_accepted +signal social_prompt_declined const VIRTUAL_MOUSE_INPUT_OWNER: StringName = &"controller_virtual_mouse" const EMOTE_RADIAL_CAMERA_OWNER: StringName = &"emote_radial_menu" @@ -139,10 +141,12 @@ const SHOP_NPC_SPEECH_COOLDOWN_MILLISECONDS: int = 5000 @onready var _screen_fade: ScreenFade = %ScreenFade @onready var _title_screen: TitleScreenType = %TitleScreen @onready var _pause_menu: PauseMenuType = %PauseMenu +@onready var _social_prompt: BubbleConfirmationPage = %SocialPrompt @onready var _hotbar_ui: HotbarUIType = %Hotbar @onready var _fishing_shop: FishingShopType = %FishingShop @onready var _player_storage: PlayerStorageType = %PlayerStorage @onready var _storage_prompt: PanelContainer = %StoragePrompt +@onready var _storage_prompt_message: Label = %StoragePromptMessage @onready var _shop_prompt: Control = %ShopPrompt @onready var _shop_prompt_bubble: PanelContainer = %ShopPromptBubble @onready var _shop_prompt_message: Label = %ShopPromptMessage @@ -182,6 +186,8 @@ var _gameplay_ui_enabled: bool = false var _gameplay_hud_hidden: bool = false var _fishing_spot: FishingSpotType var _system_menu_open: bool = false +var _social_prompt_open: bool = false +var _social_prompt_restore_system_menu: bool = false var _shop_open: bool = false var _storage_open: bool = false var _chat_input_open: bool = false @@ -208,16 +214,22 @@ var _virtual_mouse_stick: Vector2 = Vector2.ZERO var _virtual_mouse_trigger_rest_by_device: Dictionary[int, float] = {} var _shared_trigger_rest_by_device: Dictionary[int, float] = {} var _controller_mapping_manager: ControllerMappingManagerType +var _keyboard_mouse_mapping_manager: KeyboardMouseMappingManagerType var _settings_manager: PlayerSettingsManagerType var _controller_text_entry_request: Callable var _controller_text_entry_is_open: Callable +var _controller_text_entry_close: Callable +var _restore_chat_keyboard_after_fishing: bool = false func _ready() -> void: _prioritize_surface_drawing_pointer_input() _bite_prompt_button.pressed.connect(_on_bite_prompt_pressed) + _social_prompt.confirmed.connect(_accept_social_prompt) + _social_prompt.cancelled.connect(_decline_social_prompt) _apply_active_bait_indicator_style() _refresh_active_bait_indicator() + _refresh_interaction_prompt_bindings() # Reward feedback must remain above full-screen canonical menus. Keeping the # overlay as the final stage child makes that ownership explicit instead of # relying on scene declaration order when another menu adds high-z children. @@ -268,9 +280,11 @@ func _ready() -> void: func set_controller_text_entry_request( request: Callable, is_open: Callable = Callable(), + close: Callable = Callable(), ) -> void: _controller_text_entry_request = request _controller_text_entry_is_open = is_open + _controller_text_entry_close = close func request_controller_text_entry_for(control: Control = null) -> bool: @@ -278,7 +292,15 @@ func request_controller_text_entry_for(control: Control = null) -> bool: if target == null: target = get_viewport().gui_get_focus_owner() return ( - bool(_controller_text_entry_request.call(target)) + bool(_controller_text_entry_request.call(target, false)) + if _controller_text_entry_request.is_valid() + else false + ) + + +func _resume_controller_text_entry_for(control: Control) -> bool: + return ( + bool(_controller_text_entry_request.call(control, true)) if _controller_text_entry_request.is_valid() else false ) @@ -292,6 +314,14 @@ func is_controller_text_entry_open() -> bool: ) +func _close_controller_text_entry_for(control: Control) -> bool: + return ( + bool(_controller_text_entry_close.call(control)) + if _controller_text_entry_close.is_valid() + else false + ) + + func setup( player: PlayerType, inventory: FishInventoryType, @@ -386,6 +416,9 @@ func setup( ) fishing_spot.status_changed.connect(_on_fishing_status_changed) fishing_spot.bite_prompt_changed.connect(_on_bite_prompt_changed) + fishing_spot.fishing_input_priority_changed.connect( + _on_fishing_input_priority_changed + ) fishing_spot.catch_display_changed.connect(_on_catch_display_changed) fishing_spot.showcase_changed.connect(_on_showcase_changed) _player_menu.menu_visibility_changed.connect( @@ -480,6 +513,15 @@ func setup( _shop_interaction = shop_interaction _storage_interaction = storage_interaction _surface_drawing = surface_drawing + if ( + _surface_drawing != null + and not _surface_drawing.hud_state_changed.is_connected( + _on_surface_drawing_hud_state_changed + ) + ): + _surface_drawing.hud_state_changed.connect( + _on_surface_drawing_hud_state_changed + ) _surface_drawing_toolbar.setup(_surface_drawing, art_unlocks) set_edge_docks( settings_manager.current_settings.chat_dock_right, @@ -520,6 +562,10 @@ func _prioritize_surface_drawing_pointer_input() -> void: func _input(event: InputEvent) -> void: + if _social_prompt_open and event.is_action_pressed("ui_cancel"): + _decline_social_prompt() + get_viewport().set_input_as_handled() + return # The on-screen keyboard owns controller input while it is open. Its # overlay is processed before the UI beneath it and consumes the event. if is_controller_text_entry_open(): @@ -1336,7 +1382,6 @@ func _drawing_pointer_window_position() -> Vector2: func setup_data_and_identity( data_root: PlayerDataRoot, - progression_saves: PlayerSaveManager, identity_backups: IdentityBackupService, player_identity: PlayerIdentityStore, host_identity: HostIdentityStore, @@ -1348,7 +1393,6 @@ func setup_data_and_identity( ]: panel.setup_data_and_identity( data_root, - progression_saves, identity_backups, player_identity, host_identity, @@ -1375,10 +1419,64 @@ func setup_controller_mapping( func setup_keyboard_mouse_mapping( mapping_manager: KeyboardMouseMappingManagerType, ) -> void: + if ( + _keyboard_mouse_mapping_manager != null + and _keyboard_mouse_mapping_manager.mapping_changed.is_connected( + _refresh_interaction_prompt_bindings + ) + ): + _keyboard_mouse_mapping_manager.mapping_changed.disconnect( + _refresh_interaction_prompt_bindings + ) + _keyboard_mouse_mapping_manager = mapping_manager + if ( + _keyboard_mouse_mapping_manager != null + and not _keyboard_mouse_mapping_manager.mapping_changed.is_connected( + _refresh_interaction_prompt_bindings + ) + ): + _keyboard_mouse_mapping_manager.mapping_changed.connect( + _refresh_interaction_prompt_bindings + ) for panel: SettingsPanelType in [ _title_settings_panel, _pause_settings_panel ]: panel.setup_keyboard_mouse_mapping(mapping_manager) + _refresh_interaction_prompt_bindings() + + +func _refresh_interaction_prompt_bindings() -> void: + if not is_node_ready(): + return + var interact_label: String = "interact" + if _keyboard_mouse_mapping_manager != null: + var resolved_label: String = ( + _keyboard_mouse_mapping_manager.get_binding_label( + KeyboardMouseMappingManagerType.ROLE_INTERACT + ) + ) + if resolved_label not in ["", "unmapped", "unknown key"]: + interact_label = resolved_label + if interact_label.length() == 1: + interact_label = interact_label.to_upper() + _shop_prompt_key.text = interact_label + _storage_prompt_message.text = "%s open storage" % interact_label + _resize_interaction_prompts() + + +func _resize_interaction_prompts() -> void: + var badge_width: float = maxf( + 24.0, + ceilf(_shop_prompt_key.get_combined_minimum_size().x) + 12.0, + ) + _shop_prompt_key_badge.position.x = _shop_prompt.size.x - badge_width + 6.0 + _shop_prompt_key_badge.size.x = badge_width + var storage_width: float = maxf( + 220.0, + ceilf(_storage_prompt_message.get_combined_minimum_size().x) + 24.0, + ) + _storage_prompt.custom_minimum_size.x = storage_width + _storage_prompt.size.x = storage_width func is_controller_mapping_capturing() -> bool: @@ -1709,6 +1807,52 @@ func set_system_menu_open(is_open: bool) -> void: _emit_interactive_pointer_ui_changed() +func show_social_prompt( + message: String, + accept_text: String = "accept", + decline_text: String = "decline", +) -> bool: + if _social_prompt_open: + return false + _social_prompt_open = true + _social_prompt_restore_system_menu = _system_menu_open + set_system_menu_open(true) + _social_prompt.configure( + message, + accept_text, + decline_text, + BubbleConfirmationPage.InitialFocus.CONFIRM, + ) + _social_prompt.transition_in(0.08, func() -> void: pass) + return true + + +func is_social_prompt_open() -> bool: + return _social_prompt_open + + +func _accept_social_prompt() -> void: + _resolve_social_prompt(true) + + +func _decline_social_prompt() -> void: + _resolve_social_prompt(false) + + +func _resolve_social_prompt(accepted: bool) -> void: + if not _social_prompt_open or _social_prompt.is_transitioning(): + return + _social_prompt.lock_interaction() + _social_prompt.transition_out(0.08, func() -> void: + _social_prompt_open = false + set_system_menu_open(_social_prompt_restore_system_menu) + if accepted: + social_prompt_accepted.emit() + else: + social_prompt_declined.emit() + ) + + func get_fishing_shop() -> FishingShopType: return _fishing_shop @@ -1930,6 +2074,9 @@ func _on_player_settings_changed(settings: PlayerSettings) -> void: _player_menu.set_profile_preview_world_pixel_size( settings.world_pixel_size ) + _hotbar_ui.set_swap_hotbar_camera_scroll( + settings.swap_hotbar_camera_scroll + ) func set_edge_docks( @@ -1979,6 +2126,18 @@ func _on_fishing_status_changed(status: String) -> void: _set_fishing_status(status) +func _on_surface_drawing_hud_state_changed( + _is_active: bool, + _mode_name: String, + _color_name: String, + _color_value: Color, + _brush_size: int, + _grid_size: int, + status: String, +) -> void: + _set_fishing_status(status) + + func _set_fishing_status(text: String) -> void: var normalized_text: String = text.strip_edges() _status_label.text = normalized_text @@ -2004,6 +2163,31 @@ func _on_bite_prompt_changed(prompt_visible: bool) -> void: _refresh_fishing_panel_visibility() +func _on_fishing_input_priority_changed(active: bool) -> void: + if active: + _restore_chat_keyboard_after_fishing = ( + _chat_ui.is_open() and is_controller_text_entry_open() + ) + if _restore_chat_keyboard_after_fishing: + _close_controller_text_entry_for( + _chat_ui.get_text_entry_control() + ) + _chat_ui.set_fishing_input_priority(active) + if not active and _restore_chat_keyboard_after_fishing: + call_deferred("_restore_chat_keyboard_after_fishing_ends") + + +func _restore_chat_keyboard_after_fishing_ends() -> void: + await get_tree().process_frame + await get_tree().process_frame + if not _restore_chat_keyboard_after_fishing: + return + _restore_chat_keyboard_after_fishing = false + if not _chat_ui.is_open(): + return + _resume_controller_text_entry_for(_chat_ui.get_text_entry_control()) + + func _on_bite_prompt_pressed() -> void: if _fishing_spot != null: _fishing_spot.confirm_pending_bite() @@ -2023,7 +2207,6 @@ func _on_catch_display_changed( and _fishing_spot != null and _fishing_spot.is_fighting() ) - _hotbar_ui.set_item_name_suppressed(encounter_visible) _green_catch_progress.value = progress * 100.0 _red_chase_progress.value = maxf(chase_progress, 0.0) * 100.0 _catch_track.visible = encounter_visible diff --git a/ui/game_ui.tscn b/ui/game_ui.tscn index c73349f..483433a 100644 --- a/ui/game_ui.tscn +++ b/ui/game_ui.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=21 format=3] +[gd_scene load_steps=22 format=3] [ext_resource type="Script" path="res://ui/game_ui.gd" id="1_ui"] [ext_resource type="PackedScene" path="res://ui/player_menu.tscn" id="2_menu"] @@ -14,6 +14,7 @@ [ext_resource type="PackedScene" path="res://ui/quick_radial_menu.tscn" id="12_quick"] [ext_resource type="Script" path="res://ui/controller_virtual_cursor.gd" id="13_cursor"] [ext_resource type="PackedScene" path="res://ui/player_storage.tscn" id="14_storage"] +[ext_resource type="PackedScene" path="res://ui/components/bubble_menu/bubble_confirmation_page.tscn" id="15_social_prompt"] [sub_resource type="StyleBoxFlat" id="StyleBox_chase_background"] bg_color = Color(0.032, 0.118, 0.15, 1) @@ -461,7 +462,6 @@ grow_horizontal = 2 grow_vertical = 2 mouse_filter = 2 theme_override_font_sizes/font_size = 15 -text = "E" horizontal_alignment = 1 vertical_alignment = 1 @@ -491,9 +491,10 @@ theme_override_constants/margin_top = 7 theme_override_constants/margin_right = 12 theme_override_constants/margin_bottom = 7 -[node name="Message" type="Label" parent="UIRoot/CanonicalStage/GameplayTransientHUD/StoragePrompt/Margin"] +[node name="StoragePromptMessage" type="Label" parent="UIRoot/CanonicalStage/GameplayTransientHUD/StoragePrompt/Margin"] +unique_name_in_owner = true layout_mode = 2 -text = "E open storage" +text = "open storage" horizontal_alignment = 1 vertical_alignment = 1 theme_override_font_sizes/font_size = 18 @@ -533,3 +534,8 @@ unique_name_in_owner = true [node name="PauseMenu" parent="UIRoot/CanonicalStage" instance=ExtResource("6_pause")] unique_name_in_owner = true + +[node name="SocialPrompt" parent="UIRoot/CanonicalStage" instance=ExtResource("15_social_prompt")] +unique_name_in_owner = true +z_index = 3000 +z_as_relative = false diff --git a/ui/hotbar.gd b/ui/hotbar.gd index 1c41bfe..24a9aa8 100644 --- a/ui/hotbar.gd +++ b/ui/hotbar.gd @@ -9,7 +9,6 @@ const PlayerBagType = preload("res://inventory/player_bag.gd") const PlayerHotbarType = preload("res://inventory/player_hotbar.gd") const FishInventoryType = preload("res://inventory/fish_inventory.gd") const FishCatchType = preload("res://fish/fish_catch.gd") -const FishQualityType = preload("res://fish/fish_quality.gd") const BubbleHotbarSlotType = preload( "res://ui/components/bubble_hotbar/bubble_hotbar_slot.gd" ) @@ -28,8 +27,6 @@ const HOTBAR_MENU_Z_INDEX: int = 90 @onready var _presentation_scale_root: Control = %HotbarPresentationScaleRoot @onready var _bubble_field: Control = %BubbleField -@onready var _selected_item_label: Label = %SelectedItemLabel -@onready var _item_name_timer: Timer = %ItemNameTimer var _hotbar: PlayerHotbarType var _bag: PlayerBagType @@ -39,8 +36,7 @@ var _fishing_spot: FishingSpotType var _slots: Array[BubbleHotbarSlotType] = [] var _gameplay_input_enabled: bool = false var _drag_enabled: bool = false -var _hovered_slot_index: int = -1 -var _item_name_suppressed: bool = false +var _swap_hotbar_camera_scroll: bool = false var _motion_elapsed: float = 0.0 var _compact_layout: bool = false var _player_menu_context: bool = false @@ -56,7 +52,6 @@ var _visibility_generation: int = 0 func _ready() -> void: - _item_name_timer.timeout.connect(_on_item_name_timer_timeout) resized.connect(_apply_layout) _collect_slots() _apply_layout() @@ -100,13 +95,14 @@ func set_gameplay_input_enabled(enabled: bool) -> void: _gameplay_input_enabled = enabled +func set_swap_hotbar_camera_scroll(enabled: bool) -> void: + _swap_hotbar_camera_scroll = enabled + + func set_drag_enabled(enabled: bool) -> void: _drag_enabled = enabled for slot: BubbleHotbarSlotType in _slots: slot.set_drag_enabled(enabled) - if not enabled: - _hovered_slot_index = -1 - _hide_item_name() func begin_controller_placement( @@ -148,7 +144,6 @@ func end_controller_placement() -> void: for slot: BubbleHotbarSlotType in _slots: slot.focus_mode = Control.FOCUS_NONE slot.set_controller_placement_preview(false, null) - _show_selected_item_briefly() func begin_controller_management(initial_slot: int) -> void: @@ -178,7 +173,6 @@ func end_controller_management() -> void: _controller_management_active = false for slot: BubbleHotbarSlotType in _slots: slot.focus_mode = Control.FOCUS_NONE - _show_selected_item_briefly() func _resolve_controller_placement_texture() -> Texture2D: @@ -289,13 +283,6 @@ func set_presentation_visible( ) -func set_item_name_suppressed(suppressed: bool) -> void: - _item_name_suppressed = suppressed - if suppressed: - _hovered_slot_index = -1 - _hide_item_name() - - func _unhandled_input(event: InputEvent) -> void: if ( not _gameplay_input_enabled @@ -321,7 +308,7 @@ func _unhandled_input(event: InputEvent) -> void: if ( event is InputEventMouseButton and event.pressed - and not event.shift_pressed + and event.shift_pressed == _swap_hotbar_camera_scroll ): if event.button_index == MOUSE_BUTTON_WHEEL_UP: _hotbar.cycle_selection(-1) @@ -337,10 +324,6 @@ func _collect_slots() -> void: var slot := child as BubbleHotbarSlotType if slot == null: continue - slot.item_hovered.connect(_on_slot_item_hovered) - slot.item_hover_ended.connect(_on_slot_item_hover_ended) - slot.item_drag_started.connect(_on_slot_drag_started) - slot.item_drag_finished.connect(_on_slot_drag_finished) slot.focus_entered.connect( _on_controller_slot_focused.bind(slot.slot_index) ) @@ -405,9 +388,6 @@ func _on_selected_slot_changed( _refresh() if _controller_placement_active: _refresh_controller_placement_preview() - return - if _hovered_slot_index < 0: - _show_selected_item_briefly() func _on_controller_slot_focused(slot_index: int) -> void: @@ -419,80 +399,3 @@ func _on_controller_slot_focused(slot_index: int) -> void: _hotbar.select_slot(slot_index) if _controller_placement_active: _refresh_controller_placement_preview() - - -func _on_slot_item_hovered( - slot_index: int, - item_id: StringName, -) -> void: - if _item_name_suppressed: - return - _hovered_slot_index = slot_index - _item_name_timer.stop() - _show_assignment_name(slot_index, item_id) - - -func _on_slot_item_hover_ended(slot_index: int) -> void: - if slot_index != _hovered_slot_index: - return - _hovered_slot_index = -1 - _show_selected_item_briefly() - - -func _on_slot_drag_started() -> void: - _hovered_slot_index = -1 - _hide_item_name() - - -func _on_slot_drag_finished() -> void: - _show_selected_item_briefly() - - -func _show_selected_item_briefly() -> void: - if _item_name_suppressed or _hotbar == null: - _hide_item_name() - return - var selected_slot: int = _hotbar.get_selected_slot() - var identity: StringName = _hotbar.get_selected_item_id() - if identity.is_empty(): - identity = _hotbar.get_selected_fish_catch_id() - _show_assignment_name(selected_slot, identity) - if _selected_item_label.visible: - _item_name_timer.start() - - -func _show_assignment_name(slot_index: int, identity: StringName) -> void: - if identity.is_empty() or _hotbar == null: - _hide_item_name() - return - var catch_id: StringName = _hotbar.get_fish_catch_id(slot_index) - if not catch_id.is_empty() and _fish_inventory != null: - var fish_catch: FishCatchType = _fish_inventory.get_catch_by_id(catch_id) - if fish_catch != null: - _selected_item_label.text = FishQualityType.qualified_name( - fish_catch.fish.display_name, - fish_catch.quality, - ) - _selected_item_label.visible = true - return - var item = ( - _catalog.get_item_by_id(identity) - if _catalog != null - else null - ) - if item == null: - _hide_item_name() - return - _selected_item_label.text = item.display_name - _selected_item_label.visible = true - - -func _hide_item_name() -> void: - _item_name_timer.stop() - _selected_item_label.text = "" - _selected_item_label.visible = false - - -func _on_item_name_timer_timeout() -> void: - if _hovered_slot_index < 0: - _hide_item_name() diff --git a/ui/hotbar.tscn b/ui/hotbar.tscn index ebe0099..286a305 100644 --- a/ui/hotbar.tscn +++ b/ui/hotbar.tscn @@ -105,33 +105,3 @@ slot_index = 8 desktop_anchor = Vector2(735, 49) compact_anchor = Vector2(530, 42) motion_phase = 5.51 - -[node name="SelectedItemLabel" type="Label" parent="ResponsiveHotbarStage/HotbarPresentationScaleRoot"] -unique_name_in_owner = true -visible = false -layout_mode = 1 -anchors_preset = 12 -anchor_left = 0.5 -anchor_top = 1.0 -anchor_right = 0.5 -anchor_bottom = 1.0 -offset_left = -150.0 -offset_top = -132.0 -offset_right = 150.0 -offset_bottom = -112.0 -grow_horizontal = 2 -grow_vertical = 0 -mouse_filter = 2 -clip_text = true -text_overrun_behavior = 3 -horizontal_alignment = 1 -vertical_alignment = 1 -theme_override_colors/font_color = Color(0.925, 0.953, 0.965, 1) -theme_override_colors/font_outline_color = Color(0.015, 0.02, 0.03, 0.95) -theme_override_constants/outline_size = 2 -theme_override_font_sizes/font_size = 12 - -[node name="ItemNameTimer" type="Timer" parent="."] -unique_name_in_owner = true -wait_time = 1.5 -one_shot = true diff --git a/ui/icons/player_options/clean.png b/ui/icons/player_options/clean.png new file mode 100644 index 0000000..1caf918 Binary files /dev/null and b/ui/icons/player_options/clean.png differ diff --git a/ui/icons/player_options/clean.png.import b/ui/icons/player_options/clean.png.import new file mode 100644 index 0000000..86f3fe8 --- /dev/null +++ b/ui/icons/player_options/clean.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bmhhqwmx7lxxy" +path="res://.godot/imported/clean.png-762368d1ca0665fa46111797ef3210a1.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://ui/icons/player_options/clean.png" +dest_files=["res://.godot/imported/clean.png-762368d1ca0665fa46111797ef3210a1.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=64 +detect_3d/compress_to=1 diff --git a/ui/icons/player_options/friends.png b/ui/icons/player_options/friends.png new file mode 100644 index 0000000..e0cbc74 Binary files /dev/null and b/ui/icons/player_options/friends.png differ diff --git a/ui/icons/player_options/friends.png.import b/ui/icons/player_options/friends.png.import new file mode 100644 index 0000000..e38417a --- /dev/null +++ b/ui/icons/player_options/friends.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://brdj550pntc0t" +path="res://.godot/imported/friends.png-2546aa51ed36f2defc5121467d12527d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://ui/icons/player_options/friends.png" +dest_files=["res://.godot/imported/friends.png-2546aa51ed36f2defc5121467d12527d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=64 +detect_3d/compress_to=1 diff --git a/ui/logbook_page.gd b/ui/logbook_page.gd index 5921b8f..a79239d 100644 --- a/ui/logbook_page.gd +++ b/ui/logbook_page.gd @@ -962,7 +962,11 @@ func _build_known_details(fish: FishDataType) -> void: fish.get_maximum_weight(), ], ) - _add_detail_row(left_stats, "number caught", "unknown") + _add_detail_row( + left_stats, + "number caught", + str(_collection_log.get_catch_count(fish.id)), + ) _add_detail_row(right_stats, "rarity", fish.get_rarity_name().to_lower()) _add_detail_row(right_stats, "time of day", _availability_text(fish)) _add_detail_row(right_stats, "seasons", fish.get_season_text()) @@ -1127,6 +1131,7 @@ func _stats_overlay_text(fish: FishDataType, catalog_number: int) -> String: fish.get_minimum_weight(), fish.get_maximum_weight(), ], + "number caught: %d" % _collection_log.get_catch_count(fish.id), "rarity: %s" % fish.get_rarity_name().to_lower(), "time of day: %s" % _availability_text(fish), "seasons: %s" % fish.get_season_text(), diff --git a/ui/network/join_game_page.gd b/ui/network/join_game_page.gd index a6e5c11..72bf19e 100644 --- a/ui/network/join_game_page.gd +++ b/ui/network/join_game_page.gd @@ -7,6 +7,7 @@ signal back_requested enum Mode { DISCOVER, + FRIENDS, DIRECT, SAVED, RECENT, @@ -26,6 +27,12 @@ const DIRECT_WORKFLOW_HELP: String = ( % ADDRESS_FORMAT_HELP ) +@onready var _main_panel: PanelContainer = %MainPanel +@onready var _content_panel: PanelContainer = %ContentPanel +@onready var _direct_content: Control = %DirectContent +@onready var _list_content: Control = %ListContent +@onready var _list_title: Label = %ListTitle +@onready var _details_panel: PanelContainer = %DetailsPanel @onready var _address: LineEdit = %Address @onready var _address_label: Label = %AddressLabel @onready var _address_helper: Label = %AddressHelper @@ -34,10 +41,11 @@ const DIRECT_WORKFLOW_HELP: String = ( @onready var _name_helper: Label = %NameHelper @onready var _server_list: ItemList = %ServerList @onready var _details: Label = %Details -@onready var _discover_button: Button = %DiscoverButton -@onready var _direct_button: Button = %DirectButton -@onready var _saved_button: Button = %SavedButton -@onready var _recent_button: Button = %RecentButton +@onready var _discover_button: OrganizerTab = %DiscoverButton +@onready var _friends_button: OrganizerTab = %FriendsButton +@onready var _direct_button: OrganizerTab = %DirectButton +@onready var _saved_button: OrganizerTab = %SavedButton +@onready var _recent_button: OrganizerTab = %RecentButton @onready var _join_button: Button = %JoinButton @onready var _refresh_button: Button = %RefreshButton @onready var _save_button: Button = %SaveButton @@ -62,6 +70,8 @@ var _visible_entries: Array[SavedServerEntry] = [] var _selected_entry: SavedServerEntry var _discovery_rooms: Array[Dictionary] = [] var _selected_discovery_index: int = -1 +var _friend_entries: Array[Dictionary] = [] +var _selected_friend_index: int = -1 var _discovery_refresh_timer: Timer var _editing_entry_id: String = "" var _name_entry_active: bool = false @@ -69,24 +79,13 @@ var _delete_armed: bool = false var _connection_error_latched: bool = false var _pending_confirmation_endpoint: String = "" var _pending_confirmation_room: Dictionary = {} +var _owns_pending_public_join: bool = false func _ready() -> void: - UtilityPageStyle.apply_page(self) - var paper := get_node("Paper") as PanelContainer - paper.add_theme_stylebox_override( - "panel", UtilityPageStyle.panel_style() - ) - for button: BaseButton in [ - _discover_button, _direct_button, _saved_button, _recent_button, - _refresh_button, _join_button, - _save_button, _edit_button, _favorite_button, _delete_button, - _cancel_button, _back_button, - ]: - UtilityPageStyle.apply_ocean_button(button) - UtilityPageStyle.apply_ocean_line_edit(_address) - UtilityPageStyle.apply_ocean_line_edit(_name_edit) + _configure_style() _discover_button.pressed.connect(_set_mode.bind(Mode.DISCOVER)) + _friends_button.pressed.connect(_set_mode.bind(Mode.FRIENDS)) _direct_button.pressed.connect(_set_mode.bind(Mode.DIRECT)) _saved_button.pressed.connect(_set_mode.bind(Mode.SAVED)) _recent_button.pressed.connect(_set_mode.bind(Mode.RECENT)) @@ -121,6 +120,96 @@ func _ready() -> void: hide() +func _configure_style() -> void: + UtilityPageStyle.apply_page(self) + _main_panel.add_theme_stylebox_override( + "panel", + UtilityPageStyle.rounded_style( + UtilityPageStyle.OCEAN_PANEL_MID, + 28, + ), + ) + _content_panel.add_theme_stylebox_override( + "panel", + UtilityPageStyle.rounded_style( + UtilityPageStyle.OCEAN_FIELD, + 20, + ), + ) + _details_panel.add_theme_stylebox_override( + "panel", + UtilityPageStyle.row_style(), + ) + _server_list.add_theme_stylebox_override( + "panel", + UtilityPageStyle.rounded_style( + UtilityPageStyle.OCEAN_PANEL_MID, + 12, + ), + ) + _server_list.add_theme_stylebox_override( + "focus", + StyleBoxEmpty.new(), + ) + _server_list.add_theme_stylebox_override( + "selected", + UtilityPageStyle.row_style(true), + ) + _server_list.add_theme_stylebox_override( + "selected_focus", + UtilityPageStyle.row_style(true), + ) + _server_list.add_theme_color_override( + "font_color", + UtilityPageStyle.OCEAN_TEXT_PRIMARY, + ) + _server_list.add_theme_color_override( + "font_selected_color", + UtilityPageStyle.OCEAN_TEXT_PRIMARY, + ) + for node: Node in find_children("*", "Label", true, false): + var label := node as Label + label.add_theme_color_override( + "font_color", + UtilityPageStyle.OCEAN_TEXT_PRIMARY, + ) + for node: Node in find_children("*", "Button", true, false): + if node is OrganizerTab: + continue + UtilityPageStyle.apply_ocean_button(node as BaseButton) + UtilityPageStyle.apply_ocean_line_edit(_address) + UtilityPageStyle.apply_ocean_line_edit(_name_edit) + _join_button.add_theme_stylebox_override( + "normal", + UtilityPageStyle.ocean_button_style( + UtilityPageStyle.GREEN, + ), + ) + _delete_button.add_theme_stylebox_override( + "normal", + UtilityPageStyle.ocean_button_style( + UtilityPageStyle.OCEAN_DANGER, + ), + ) + _back_button.add_theme_stylebox_override( + "normal", + UtilityPageStyle.ocean_button_style( + UtilityPageStyle.OCEAN_PANEL_DEEP, + ), + ) + for secondary_label: Label in [ + _address_helper, + _name_helper, + _details, + _status, + _session_summary, + ]: + secondary_label.add_theme_color_override( + "font_color", + UtilityPageStyle.OCEAN_TEXT_SECONDARY, + ) + + func setup( network_session: NetworkSession, saved_servers: SavedServerStore, @@ -177,6 +266,19 @@ func setup( _discovery.public_join_status_changed.connect( _on_public_join_status_changed ) + if not _discovery.friend_presence_updated.is_connected( + _on_friend_presence_updated + ): + _discovery.friend_presence_updated.connect( + _on_friend_presence_updated + ) + if not _discovery.social_status_changed.is_connected( + _on_social_status_changed + ): + _discovery.social_status_changed.connect( + _on_social_status_changed + ) + _friend_entries = _discovery.get_friend_presence() _refresh() @@ -232,7 +334,9 @@ func confirm_pending_join() -> bool: var room: Dictionary = _pending_confirmation_room.duplicate(true) cancel_pending_join_confirmation() if not room.is_empty(): + _owns_pending_public_join = true if _discovery == null or not _discovery.prepare_public_join(room): + _owns_pending_public_join = false _set_status("Could not prepare the public connection.", true) return false return true @@ -244,15 +348,19 @@ func confirm_pending_join() -> bool: func _set_mode(mode: Mode, clear_connection_error: bool = true) -> void: if clear_connection_error: _connection_error_latched = false + _set_status(_default_mode_status(mode)) _mode = mode _selected_entry = null _selected_discovery_index = -1 + _selected_friend_index = -1 _clear_edit_state() _refresh_entries() if mode == Mode.DISCOVER and not _discovery_rooms.is_empty(): _select_discovery_index(0) + elif mode == Mode.FRIENDS and not _friend_entries.is_empty(): + _select_friend_index(0) _refresh() - if mode == Mode.DISCOVER: + if mode in [Mode.DISCOVER, Mode.FRIENDS]: _discovery_refresh_timer.start() _request_discovery_refresh() else: @@ -262,6 +370,19 @@ func _set_mode(mode: Mode, clear_connection_error: bool = true) -> void: _defer_focus_control(_address if mode == Mode.DIRECT else _server_list) +func _default_mode_status(mode: Mode) -> String: + match mode: + Mode.FRIENDS: + return "friend status is live and not stored by discovery" + Mode.DIRECT: + return "direct connection • default port 7777" + Mode.SAVED: + return "saved servers are stored on this device" + Mode.RECENT: + return "recent connections are stored on this device" + return "looking for public rooms…" + + func _request_join() -> void: _connection_error_latched = false if _network_session.state in [ @@ -271,8 +392,22 @@ func _request_join() -> void: _network_session.reset_failure() var endpoint_text: String = _address.text var discovery_room: Dictionary = {} - if _mode == Mode.DISCOVER: - var room: Dictionary = _selected_discovery_room() + if _mode in [Mode.DISCOVER, Mode.FRIENDS]: + var room: Dictionary = ( + _selected_discovery_room() + if _mode == Mode.DISCOVER + else _selected_friend_room() + ) + if _mode == Mode.FRIENDS: + var friend := _selected_friend() + if not bool(friend.get("online", false)): + _set_status("This person needs to be online to do this.", true) + return + if room.is_empty(): + _set_status( + "This friend is online but is not in a joinable room.", true + ) + return if _discovery != null and _discovery.is_own_room(room): _set_status("You are already hosting this room.", true) return @@ -296,7 +431,9 @@ func _request_join() -> void: join_confirmation_requested.emit(endpoint.normalized_display) return if not discovery_room.is_empty(): + _owns_pending_public_join = true if not _discovery.prepare_public_join(discovery_room): + _owns_pending_public_join = false _set_status("Could not prepare the public connection.", true) return _set_status("Connecting…") @@ -305,7 +442,10 @@ func _request_join() -> void: func _on_public_join_prepared(endpoint_text: String) -> void: - if _mode != Mode.DISCOVER or not is_visible_in_tree(): + if not _owns_pending_public_join: + return + _owns_pending_public_join = false + if _mode not in [Mode.DISCOVER, Mode.FRIENDS] or not is_visible_in_tree(): return var endpoint: ConnectionEndpoint = EndpointParser.parse(endpoint_text) if not endpoint.is_valid(): @@ -317,8 +457,13 @@ func _on_public_join_prepared(endpoint_text: String) -> void: func _on_public_join_status_changed(message: String, is_error: bool) -> void: - if _mode == Mode.DISCOVER and is_visible_in_tree(): + if ( + _owns_pending_public_join + and _mode in [Mode.DISCOVER, Mode.FRIENDS] + and is_visible_in_tree() + ): if is_error: + _owns_pending_public_join = false _connection_error_latched = true elif _connection_error_latched: return @@ -473,6 +618,11 @@ func _on_list_item_selected(index: int) -> void: return _refresh() return + if _mode == Mode.FRIENDS: + if not _select_friend_index(index): + return + _refresh() + return if index < 0 or index >= _visible_entries.size(): return _selected_entry = _visible_entries[index] @@ -518,6 +668,8 @@ func _restore_entry_selection_and_focus( func _current_mode_button() -> Button: match _mode: + Mode.FRIENDS: + return _friends_button Mode.DIRECT: return _direct_button Mode.SAVED: @@ -536,6 +688,15 @@ func _select_discovery_index(index: int) -> bool: return true +func _select_friend_index(index: int) -> bool: + if index < 0 or index >= _friend_entries.size(): + return false + _selected_friend_index = index + _selected_entry = null + _server_list.select(index) + return true + + func _refresh_entries() -> void: _visible_entries.clear() _server_list.clear() @@ -556,6 +717,25 @@ func _refresh_entries() -> void: ] ) return + if _mode == Mode.FRIENDS: + for friend: Dictionary in _friend_entries: + var room: Dictionary = ( + friend.get("room", {}) + if typeof(friend.get("room", {})) == TYPE_DICTIONARY + else {} + ) + var state := "offline" + if bool(friend.get("online", false)): + state = ( + "playing in %s" + % str(room.get("room_name", "a public room")) + if not room.is_empty() + else "online" + ) + _server_list.add_item("%s — %s" % [ + str(friend.get("display_name", "Player")), state, + ]) + return if _saved_servers == null or _mode == Mode.DIRECT: return _visible_entries = ( @@ -594,15 +774,24 @@ func _refresh() -> void: ] or (_discovery != null and _discovery.is_public_join_preparing()) var direct: bool = _mode == Mode.DIRECT var discovery_mode: bool = _mode == Mode.DISCOVER + var friends_mode: bool = _mode == Mode.FRIENDS var selected: bool = ( _selected_discovery_index >= 0 if discovery_mode + else _selected_friend_index >= 0 + if friends_mode else _selected_entry != null ) - _discover_button.button_pressed = discovery_mode - _direct_button.button_pressed = direct - _saved_button.button_pressed = _mode == Mode.SAVED - _recent_button.button_pressed = _mode == Mode.RECENT + _discover_button.set_selected(discovery_mode, false) + _friends_button.set_selected(friends_mode, false) + _direct_button.set_selected(direct, false) + _saved_button.set_selected(_mode == Mode.SAVED, false) + _recent_button.set_selected(_mode == Mode.RECENT, false) + var direct_content_visible: bool = direct or _name_entry_active + var list_content_visible: bool = not direct_content_visible + _direct_content.visible = direct_content_visible + _list_content.visible = list_content_visible + _list_title.text = _current_list_title() _address.visible = direct or _name_entry_active _address_label.visible = _address.visible _address_helper.visible = _address.visible @@ -613,15 +802,20 @@ func _refresh() -> void: _name_edit.visible = _name_entry_active _name_label.visible = _name_entry_active _name_helper.visible = _name_entry_active - _server_list.visible = not direct and not _name_entry_active - _details.visible = not direct and not _name_entry_active + _server_list.visible = list_content_visible + _details_panel.visible = list_content_visible + _details.visible = list_content_visible _join_button.disabled = ( connecting or (not direct and not selected) or ( - discovery_mode + (discovery_mode or friends_mode) and selected - and _discovery_room_is_full(_selected_discovery_room()) + and _discovery_room_is_full( + _selected_discovery_room() + if discovery_mode + else _selected_friend_room() + ) ) or ( discovery_mode @@ -629,16 +823,19 @@ func _refresh() -> void: and _discovery != null and _discovery.is_own_room(_selected_discovery_room()) ) + or (friends_mode and selected and _selected_friend_room().is_empty()) + ) + _join_button.text = "join now" if direct else "join" + _refresh_button.visible = ( + (discovery_mode or friends_mode) and not _name_entry_active ) - _join_button.text = "join\nnow" if direct else "join" - _refresh_button.visible = discovery_mode and not _name_entry_active _save_button.visible = ( direct or _mode == Mode.RECENT or _name_entry_active ) _save_button.disabled = connecting or ( _mode == Mode.RECENT and not selected and not _name_entry_active ) - _save_button.text = "save" if _name_entry_active else "save\nserver" + _save_button.text = "save" if _name_entry_active else "save server" _edit_button.visible = _mode == Mode.SAVED and selected _favorite_button.visible = _mode == Mode.SAVED and selected _favorite_button.text = ( @@ -676,16 +873,22 @@ func _refresh() -> void: _details.text = ( _format_discovery_details(_selected_discovery_room()) if discovery_mode + else _format_friend_details(_selected_friend()) + if friends_mode else _format_entry_details(_selected_entry) ) elif ( _discovery_rooms.is_empty() if discovery_mode + else _friend_entries.is_empty() + if friends_mode else _visible_entries.is_empty() ): _details.text = ( "No public rooms are available." if discovery_mode + else "No friends added yet." + if friends_mode else "No saved servers yet." if _mode == Mode.SAVED else "No recent connections yet." @@ -702,9 +905,21 @@ func _refresh() -> void: _configure_controller_navigation() +func _current_list_title() -> String: + match _mode: + Mode.FRIENDS: + return "friends" + Mode.SAVED: + return "saved servers" + Mode.RECENT: + return "recent connections" + return "public rooms" + + func _configure_controller_navigation() -> void: var mode_buttons: Array[Control] = [ _discover_button, + _friends_button, _direct_button, _saved_button, _recent_button, @@ -722,7 +937,6 @@ func _configure_controller_navigation() -> void: _favorite_button, _delete_button, _cancel_button, - _back_button, ]: if _controller_focus_eligible(control): action_controls.append(control) @@ -730,6 +944,7 @@ func _configure_controller_navigation() -> void: all_controls.append_array(mode_buttons) all_controls.append_array(content_controls) all_controls.append_array(action_controls) + all_controls.append(_back_button) for control: Control in all_controls: control.focus_mode = Control.FOCUS_ALL for control: Control in [ @@ -743,10 +958,10 @@ func _configure_controller_navigation() -> void: _favorite_button, _delete_button, _cancel_button, - _back_button, ]: if control not in all_controls: control.focus_mode = Control.FOCUS_NONE + _back_button.focus_mode = Control.FOCUS_ALL var primary_content: Control = ( content_controls.front() if not content_controls.is_empty() @@ -775,7 +990,7 @@ func _configure_controller_navigation() -> void: if index < content_controls.size() - 1 else action_controls.front() if not action_controls.is_empty() - else content + else _back_button ) _set_controller_neighbors(content, content, content, above, below) for index: int in action_controls.size(): @@ -787,8 +1002,22 @@ func _configure_controller_navigation() -> void: content_controls.back() if not content_controls.is_empty() else mode_buttons[int(_mode)], - action, + _back_button, ) + var back_above: Control = ( + action_controls.back() + if not action_controls.is_empty() + else content_controls.back() + if not content_controls.is_empty() + else mode_buttons[int(_mode)] + ) + _set_controller_neighbors( + _back_button, + _back_button, + _back_button, + back_above, + _back_button, + ) ControllerFocusNavigationType.configure_traversal(all_controls) _recover_controller_focus(all_controls, primary_content) @@ -920,6 +1149,29 @@ func _format_discovery_details(room: Dictionary) -> String: return "\n".join(lines) +func _format_friend_details(friend: Dictionary) -> String: + if friend.is_empty(): + return "Select a friend." + var room := _selected_friend_room() + var lines: Array[String] = [ + "Friend: %s" % str(friend.get("display_name", "Player")), + "Status: %s" % ( + "online" if bool(friend.get("online", false)) else "offline" + ), + ] + if not room.is_empty(): + lines.append("Room: %s" % str(room.get("room_name", "Public room"))) + lines.append("Players: %d / %d" % [ + int(room.get("current_players", 0)), + int(room.get("max_players", 0)), + ]) + elif bool(friend.get("online", false)): + lines.append("This friend is not in a joinable public room.") + else: + lines.append("This person needs to be online to join them.") + return "\n".join(lines) + + func _selected_discovery_room() -> Dictionary: if ( _selected_discovery_index < 0 @@ -929,6 +1181,25 @@ func _selected_discovery_room() -> Dictionary: return _discovery_rooms[_selected_discovery_index] +func _selected_friend() -> Dictionary: + if ( + _selected_friend_index < 0 + or _selected_friend_index >= _friend_entries.size() + ): + return {} + return _friend_entries[_selected_friend_index] + + +func _selected_friend_room() -> Dictionary: + var friend := _selected_friend() + var room: Variant = friend.get("room", {}) + return ( + (room as Dictionary) + if typeof(room) == TYPE_DICTIONARY + else {} + ) + + func _discovery_room_is_full(room: Dictionary) -> bool: if room.is_empty(): return false @@ -938,11 +1209,14 @@ func _discovery_room_is_full(room: Dictionary) -> bool: func _request_discovery_refresh() -> void: if ( _discovery == null - or _mode != Mode.DISCOVER + or _mode not in [Mode.DISCOVER, Mode.FRIENDS] or not is_visible_in_tree() ): return - _discovery.request_rooms() + if _mode == Mode.DISCOVER: + _discovery.request_rooms() + else: + _discovery.request_friend_presence() func _on_discovery_rooms_updated(rooms: Array[Dictionary]) -> void: @@ -972,6 +1246,30 @@ func _on_discovery_status_changed(message: String, is_error: bool) -> void: _set_status(message, is_error) +func _on_friend_presence_updated(friends: Array[Dictionary]) -> void: + var selected_fingerprint := str( + _selected_friend().get("fingerprint", "") + ) + _friend_entries = friends.duplicate(true) + _selected_friend_index = -1 + if _mode != Mode.FRIENDS: + return + _refresh_entries() + if not selected_fingerprint.is_empty(): + for index: int in _friend_entries.size(): + if str(_friend_entries[index].get("fingerprint", "")) == selected_fingerprint: + _select_friend_index(index) + break + if _selected_friend_index < 0 and not _friend_entries.is_empty(): + _select_friend_index(0) + _refresh() + + +func _on_social_status_changed(message: String, is_error: bool) -> void: + if _mode == Mode.FRIENDS and is_visible_in_tree(): + _set_status(message, is_error) + + func _format_result_code(result_code: String) -> String: match result_code.strip_edges().to_upper(): "SUCCESS": diff --git a/ui/network/join_game_page.tscn b/ui/network/join_game_page.tscn index c4be4fa..54f354d 100644 --- a/ui/network/join_game_page.tscn +++ b/ui/network/join_game_page.tscn @@ -1,76 +1,10 @@ -[gd_scene load_steps=12 format=3] +[gd_scene load_steps=5 format=3] [ext_resource type="Script" path="res://ui/network/join_game_page.gd" id="1_script"] [ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"] +[ext_resource type="Script" path="res://ui/components/organizer_tab.gd" id="3_tab"] [ext_resource type="PackedScene" path="res://ui/components/bubble_menu/bubble_confirmation_page.tscn" id="4_confirmation"] -[sub_resource type="StyleBoxFlat" id="StyleBox_paper"] -bg_color = Color(0.051, 0.173, 0.227, 1) -corner_radius_top_left = 54 -corner_radius_top_right = 46 -corner_radius_bottom_right = 58 -corner_radius_bottom_left = 48 - -[sub_resource type="StyleBoxFlat" id="StyleBox_list"] -bg_color = Color(0.071, 0.247, 0.306, 1) -corner_radius_top_left = 12 -corner_radius_top_right = 9 -corner_radius_bottom_right = 13 -corner_radius_bottom_left = 10 - -[sub_resource type="StyleBoxFlat" id="StyleBox_selected"] -bg_color = Color(0.137, 0.525, 0.592, 1) -corner_radius_top_left = 8 -corner_radius_top_right = 7 -corner_radius_bottom_right = 9 -corner_radius_bottom_left = 6 - -[sub_resource type="StyleBoxEmpty" id="StyleBox_focus"] - -[sub_resource type="StyleBoxFlat" id="StyleBox_input"] -content_margin_left = 12.0 -content_margin_top = 7.0 -content_margin_right = 12.0 -content_margin_bottom = 7.0 -bg_color = Color(0.031, 0.122, 0.169, 1) -corner_radius_top_left = 8 -corner_radius_top_right = 7 -corner_radius_bottom_right = 9 -corner_radius_bottom_left = 6 - -[sub_resource type="StyleBoxFlat" id="StyleBox_input_focus"] -content_margin_left = 12.0 -content_margin_top = 7.0 -content_margin_right = 12.0 -content_margin_bottom = 7.0 -bg_color = Color(0.137, 0.525, 0.592, 1) -corner_radius_top_left = 8 -corner_radius_top_right = 7 -corner_radius_bottom_right = 9 -corner_radius_bottom_left = 6 - -[sub_resource type="StyleBoxFlat" id="StyleBox_input_read_only"] -content_margin_left = 12.0 -content_margin_top = 7.0 -content_margin_right = 12.0 -content_margin_bottom = 7.0 -bg_color = Color(0.031, 0.122, 0.169, 0.82) -corner_radius_top_left = 8 -corner_radius_top_right = 7 -corner_radius_bottom_right = 9 -corner_radius_bottom_left = 6 - -[sub_resource type="StyleBoxFlat" id="StyleBox_status"] -content_margin_left = 10.0 -content_margin_top = 4.0 -content_margin_right = 10.0 -content_margin_bottom = 4.0 -bg_color = Color(0.071, 0.247, 0.306, 0.9) -corner_radius_top_left = 7 -corner_radius_top_right = 6 -corner_radius_bottom_right = 8 -corner_radius_bottom_left = 5 - [node name="JoinGamePage" type="Control"] visible = false layout_mode = 3 @@ -82,243 +16,322 @@ grow_vertical = 2 theme = ExtResource("2_theme") script = ExtResource("1_script") -[node name="Paper" type="PanelContainer" parent="."] -layout_mode = 0 -offset_left = 230.0 -offset_top = 64.0 -offset_right = 1050.0 -offset_bottom = 656.0 -theme_override_styles/panel = SubResource("StyleBox_paper") +[node name="MainPanel" type="PanelContainer" parent="."] +unique_name_in_owner = true +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -440.0 +offset_top = -330.0 +offset_right = 440.0 +offset_bottom = 330.0 +grow_horizontal = 2 +grow_vertical = 2 -[node name="Margin" type="MarginContainer" parent="Paper"] +[node name="OuterMargin" type="MarginContainer" parent="MainPanel"] layout_mode = 2 -theme_override_constants/margin_left = 54 -theme_override_constants/margin_top = 30 -theme_override_constants/margin_right = 54 -theme_override_constants/margin_bottom = 30 +theme_override_constants/margin_left = 24 +theme_override_constants/margin_top = 18 +theme_override_constants/margin_right = 24 +theme_override_constants/margin_bottom = 18 -[node name="Layout" type="VBoxContainer" parent="Paper/Margin"] +[node name="Layout" type="VBoxContainer" parent="MainPanel/OuterMargin"] layout_mode = 2 -theme_override_constants/separation = 10 -alignment = 1 +theme_override_constants/separation = -26 -[node name="Title" type="Label" parent="Paper/Margin/Layout"] +[node name="Heading" type="Label" parent="MainPanel/OuterMargin/Layout"] +custom_minimum_size = Vector2(0, 66) layout_mode = 2 -theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1) theme_override_font_sizes/font_size = 30 text = "join game" horizontal_alignment = 1 -[node name="Modes" type="HBoxContainer" parent="Paper/Margin/Layout"] +[node name="TabBar" type="HBoxContainer" parent="MainPanel/OuterMargin/Layout"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 52) layout_mode = 2 -theme_override_constants/separation = 14 +theme_override_constants/separation = 8 alignment = 1 -[node name="DiscoverButton" type="Button" parent="Paper/Margin/Layout/Modes"] +[node name="DiscoverButton" type="Button" parent="MainPanel/OuterMargin/Layout/TabBar"] unique_name_in_owner = true -custom_minimum_size = Vector2(100, 72) +custom_minimum_size = Vector2(165, 52) layout_mode = 2 -toggle_mode = true +focus_mode = 2 text = "discover" +script = ExtResource("3_tab") -[node name="DirectButton" type="Button" parent="Paper/Margin/Layout/Modes"] +[node name="FriendsButton" type="Button" parent="MainPanel/OuterMargin/Layout/TabBar"] unique_name_in_owner = true -custom_minimum_size = Vector2(100, 72) +custom_minimum_size = Vector2(140, 52) layout_mode = 2 -toggle_mode = true +focus_mode = 2 +text = "friends" +script = ExtResource("3_tab") +palette_index = 1 + +[node name="DirectButton" type="Button" parent="MainPanel/OuterMargin/Layout/TabBar"] +unique_name_in_owner = true +custom_minimum_size = Vector2(140, 52) +layout_mode = 2 +focus_mode = 2 text = "direct" +script = ExtResource("3_tab") +palette_index = 1 -[node name="SavedButton" type="Button" parent="Paper/Margin/Layout/Modes"] +[node name="SavedButton" type="Button" parent="MainPanel/OuterMargin/Layout/TabBar"] unique_name_in_owner = true -custom_minimum_size = Vector2(100, 72) +custom_minimum_size = Vector2(140, 52) layout_mode = 2 -toggle_mode = true +focus_mode = 2 text = "saved" +script = ExtResource("3_tab") +palette_index = 2 -[node name="RecentButton" type="Button" parent="Paper/Margin/Layout/Modes"] +[node name="RecentButton" type="Button" parent="MainPanel/OuterMargin/Layout/TabBar"] unique_name_in_owner = true -custom_minimum_size = Vector2(100, 72) +custom_minimum_size = Vector2(140, 52) layout_mode = 2 -toggle_mode = true +focus_mode = 2 text = "recent" +script = ExtResource("3_tab") +palette_index = 1 -[node name="AddressLabel" type="Label" parent="Paper/Margin/Layout"] +[node name="ContentPanel" type="PanelContainer" parent="MainPanel/OuterMargin/Layout"] unique_name_in_owner = true +custom_minimum_size = Vector2(0, 474) layout_mode = 2 -theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1) +size_flags_vertical = 3 + +[node name="ContentMargin" type="MarginContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel"] +layout_mode = 2 +theme_override_constants/margin_left = 28 +theme_override_constants/margin_top = 20 +theme_override_constants/margin_right = 28 +theme_override_constants/margin_bottom = 20 + +[node name="ContentLayout" type="VBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="PageStack" type="Control" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="ListContent" type="VBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack"] +unique_name_in_owner = true +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/separation = 10 + +[node name="ListTitle" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/ListContent"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_override_font_sizes/font_size = 20 +text = "public rooms" + +[node name="ServerList" type="ItemList" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/ListContent"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 180) +layout_mode = 2 +size_flags_vertical = 3 theme_override_font_sizes/font_size = 17 -text = "server address" -horizontal_alignment = 1 - -[node name="Address" type="LineEdit" parent="Paper/Margin/Layout"] -unique_name_in_owner = true -custom_minimum_size = Vector2(0, 42) -layout_mode = 2 -theme_override_colors/font_color = Color(0.96, 0.93, 0.82, 1) -theme_override_colors/font_uneditable_color = Color(0.624, 0.812, 0.824, 1) -theme_override_colors/font_selected_color = Color(1, 0.98, 0.9, 1) -theme_override_colors/font_placeholder_color = Color(0.624, 0.812, 0.824, 0.78) -theme_override_colors/caret_color = Color(0.902, 0.969, 0.969, 1) -theme_override_colors/selection_color = Color(0.2, 0.48, 0.59, 0.9) -theme_override_font_sizes/font_size = 19 -theme_override_styles/normal = SubResource("StyleBox_input") -theme_override_styles/focus = SubResource("StyleBox_input_focus") -theme_override_styles/read_only = SubResource("StyleBox_input_read_only") -placeholder_text = "example.net or 192.168.1.50:7777" -alignment = 1 -max_length = 300 - -[node name="AddressHelper" type="Label" parent="Paper/Margin/Layout"] -unique_name_in_owner = true -layout_mode = 2 -theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1) -theme_override_font_sizes/font_size = 13 -text = "Hostname, IPv4, or [IPv6]. Port 7777 is used when omitted; include the port shown by a host when it differs.\nJoin Now connects once; Save Server stores this address locally." -horizontal_alignment = 1 -autowrap_mode = 2 - -[node name="NameLabel" type="Label" parent="Paper/Margin/Layout"] -unique_name_in_owner = true -visible = false -layout_mode = 2 -theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1) -theme_override_font_sizes/font_size = 17 -text = "server name" -horizontal_alignment = 1 - -[node name="NameEdit" type="LineEdit" parent="Paper/Margin/Layout"] -unique_name_in_owner = true -visible = false -custom_minimum_size = Vector2(0, 42) -layout_mode = 2 -theme_override_colors/font_color = Color(0.96, 0.93, 0.82, 1) -theme_override_colors/font_uneditable_color = Color(0.624, 0.812, 0.824, 1) -theme_override_colors/font_selected_color = Color(1, 0.98, 0.9, 1) -theme_override_colors/font_placeholder_color = Color(0.624, 0.812, 0.824, 0.78) -theme_override_colors/caret_color = Color(0.902, 0.969, 0.969, 1) -theme_override_colors/selection_color = Color(0.2, 0.48, 0.59, 0.9) -theme_override_font_sizes/font_size = 19 -theme_override_styles/normal = SubResource("StyleBox_input") -theme_override_styles/focus = SubResource("StyleBox_input_focus") -theme_override_styles/read_only = SubResource("StyleBox_input_read_only") -placeholder_text = "Friend's server" -alignment = 1 -max_length = 80 - -[node name="NameHelper" type="Label" parent="Paper/Margin/Layout"] -unique_name_in_owner = true -visible = false -layout_mode = 2 -theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1) -theme_override_font_sizes/font_size = 13 -text = "Only visible on this device." -horizontal_alignment = 1 - -[node name="ServerList" type="ItemList" parent="Paper/Margin/Layout"] -unique_name_in_owner = true -visible = false -custom_minimum_size = Vector2(0, 145) -layout_mode = 2 -theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1) -theme_override_colors/font_selected_color = Color(0.902, 0.969, 0.969, 1) -theme_override_colors/guide_color = Color(0.624, 0.812, 0.824, 0.22) -theme_override_font_sizes/font_size = 17 -theme_override_styles/panel = SubResource("StyleBox_list") -theme_override_styles/focus = SubResource("StyleBox_focus") -theme_override_styles/selected = SubResource("StyleBox_selected") -theme_override_styles/selected_focus = SubResource("StyleBox_selected") allow_reselect = true same_column_width = true -[node name="Details" type="Label" parent="Paper/Margin/Layout"] +[node name="DetailsPanel" type="PanelContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/ListContent"] unique_name_in_owner = true -visible = false -custom_minimum_size = Vector2(0, 92) +custom_minimum_size = Vector2(0, 100) layout_mode = 2 -theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1) -theme_override_font_sizes/font_size = 16 + +[node name="Details" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/ListContent/DetailsPanel"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_font_sizes/font_size = 15 text = "Select a server." horizontal_alignment = 1 vertical_alignment = 1 autowrap_mode = 2 -[node name="Status" type="Label" parent="Paper/Margin/Layout"] +[node name="DirectContent" type="VBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack"] unique_name_in_owner = true -custom_minimum_size = Vector2(0, 38) +visible = false +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/separation = 10 + +[node name="AddressLabel" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/DirectContent"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 28) layout_mode = 2 -theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1) -theme_override_font_sizes/font_size = 15 -theme_override_styles/normal = SubResource("StyleBox_status") -text = "Direct UDP connection • default port 7777" +theme_override_font_sizes/font_size = 20 +text = "server address" + +[node name="Address" type="LineEdit" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/DirectContent"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +focus_mode = 2 +theme_override_font_sizes/font_size = 19 +placeholder_text = "example.net or 192.168.1.50:7777" +alignment = 1 +max_length = 300 + +[node name="AddressHelper" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/DirectContent"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 +theme_override_font_sizes/font_size = 13 +text = "Hostname, IPv4, or [IPv6]. Port 7777 is used when omitted; include the port shown by a host when it differs.\nJoin Now connects once; Save Server stores this address locally." horizontal_alignment = 1 vertical_alignment = 1 autowrap_mode = 2 -[node name="SessionSummary" type="Label" parent="Paper/Margin/Layout"] +[node name="NameLabel" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/DirectContent"] +unique_name_in_owner = true +visible = false +custom_minimum_size = Vector2(0, 24) +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "server name" + +[node name="NameEdit" type="LineEdit" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/DirectContent"] +unique_name_in_owner = true +visible = false +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +focus_mode = 2 +theme_override_font_sizes/font_size = 19 +placeholder_text = "Friend's server" +alignment = 1 +max_length = 80 + +[node name="NameHelper" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/DirectContent"] unique_name_in_owner = true visible = false layout_mode = 2 -theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1) -theme_override_font_sizes/font_size = 15 -text = "1 / 8 players" +theme_override_font_sizes/font_size = 13 +text = "Only visible on this device." horizontal_alignment = 1 -[node name="Actions" type="HBoxContainer" parent="Paper/Margin/Layout"] +[node name="Spacer" type="Control" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/DirectContent"] layout_mode = 2 -theme_override_constants/separation = 7 +size_flags_vertical = 3 + +[node name="Actions" type="HBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_constants/separation = 10 alignment = 1 -[node name="RefreshButton" type="Button" parent="Paper/Margin/Layout/Actions"] +[node name="RefreshButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/Actions"] unique_name_in_owner = true visible = false -custom_minimum_size = Vector2(82, 72) +custom_minimum_size = Vector2(120, 46) layout_mode = 2 +focus_mode = 2 text = "refresh" -[node name="JoinButton" type="Button" parent="Paper/Margin/Layout/Actions"] +[node name="JoinButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/Actions"] unique_name_in_owner = true -custom_minimum_size = Vector2(82, 72) +custom_minimum_size = Vector2(120, 46) layout_mode = 2 +focus_mode = 2 text = "join" -[node name="SaveButton" type="Button" parent="Paper/Margin/Layout/Actions"] +[node name="SaveButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/Actions"] unique_name_in_owner = true -custom_minimum_size = Vector2(82, 72) +custom_minimum_size = Vector2(130, 46) layout_mode = 2 -text = "save\nserver" +focus_mode = 2 +text = "save server" -[node name="EditButton" type="Button" parent="Paper/Margin/Layout/Actions"] +[node name="EditButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/Actions"] unique_name_in_owner = true visible = false -custom_minimum_size = Vector2(76, 68) +custom_minimum_size = Vector2(110, 46) layout_mode = 2 +focus_mode = 2 text = "edit" -[node name="FavoriteButton" type="Button" parent="Paper/Margin/Layout/Actions"] +[node name="FavoriteButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/Actions"] unique_name_in_owner = true visible = false -custom_minimum_size = Vector2(88, 68) +custom_minimum_size = Vector2(130, 46) layout_mode = 2 +focus_mode = 2 text = "favorite" -[node name="DeleteButton" type="Button" parent="Paper/Margin/Layout/Actions"] +[node name="DeleteButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/Actions"] unique_name_in_owner = true visible = false -custom_minimum_size = Vector2(82, 68) +custom_minimum_size = Vector2(110, 46) layout_mode = 2 +focus_mode = 2 text = "delete" -[node name="CancelButton" type="Button" parent="Paper/Margin/Layout/Actions"] +[node name="CancelButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/Actions"] unique_name_in_owner = true visible = false -custom_minimum_size = Vector2(76, 68) +custom_minimum_size = Vector2(120, 46) layout_mode = 2 +focus_mode = 2 text = "cancel" -[node name="BackButton" type="Button" parent="Paper/Margin/Layout/Actions"] -unique_name_in_owner = true -custom_minimum_size = Vector2(76, 68) +[node name="FooterGroup" type="MarginContainer" parent="MainPanel/OuterMargin/Layout"] layout_mode = 2 +theme_override_constants/margin_top = 34 + +[node name="FooterLayout" type="VBoxContainer" parent="MainPanel/OuterMargin/Layout/FooterGroup"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="InfoRow" type="HBoxContainer" parent="MainPanel/OuterMargin/Layout/FooterGroup/FooterLayout"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Status" type="Label" parent="MainPanel/OuterMargin/Layout/FooterGroup/FooterLayout/InfoRow"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 24) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 14 +text = "looking for public rooms…" +vertical_alignment = 1 +text_overrun_behavior = 3 + +[node name="SessionSummary" type="Label" parent="MainPanel/OuterMargin/Layout/FooterGroup/FooterLayout/InfoRow"] +unique_name_in_owner = true +visible = false +custom_minimum_size = Vector2(210, 24) +layout_mode = 2 +theme_override_font_sizes/font_size = 14 +text = "1 / 8 players" +horizontal_alignment = 2 +vertical_alignment = 1 + +[node name="Footer" type="HBoxContainer" parent="MainPanel/OuterMargin/Layout/FooterGroup/FooterLayout"] +layout_mode = 2 +alignment = 2 + +[node name="BackButton" type="Button" parent="MainPanel/OuterMargin/Layout/FooterGroup/FooterLayout/Footer"] +unique_name_in_owner = true +custom_minimum_size = Vector2(150, 46) +layout_mode = 2 +focus_mode = 2 text = "back" [node name="DeleteConfirmation" parent="." instance=ExtResource("4_confirmation")] diff --git a/ui/on_screen_keyboard.gd b/ui/on_screen_keyboard.gd index ee7475c..6b6ea44 100644 --- a/ui/on_screen_keyboard.gd +++ b/ui/on_screen_keyboard.gd @@ -100,6 +100,23 @@ func is_open() -> bool: return visible or _native_text_entry_is_active() +func close_for_control(control: Control = null) -> bool: + if visible and (control == null or _target == control): + _close_keyboard(false) + return true + if not _uses_native_virtual_keyboard(): + return false + var native_target: Control = _native_session_target() + if native_target != null and (control == null or native_target == control): + _close_native_keyboard() + return true + if control != null and control.has_focus() and _can_edit(control): + _hide_native_keyboard() + control.release_focus() + return true + return false + + func setup_controller_mapping( mapping_manager: ControllerMappingManagerType, ) -> void: @@ -110,7 +127,10 @@ func request_for_focused_control() -> bool: return request_for_control(get_viewport().gui_get_focus_owner()) -func request_for_control(control: Control = null) -> bool: +func request_for_control( + control: Control = null, + preserve_caret: bool = false, +) -> bool: if visible or not _can_edit(control): return false if _uses_native_virtual_keyboard(): @@ -118,7 +138,7 @@ func request_for_control(control: Control = null) -> bool: return true if not _is_available_for_controller(): return false - _open_for(control) + _open_for(control, preserve_caret) return true @@ -478,14 +498,20 @@ func _can_edit(control: Control) -> bool: return false -func _open_for(control: Control) -> void: +func _open_for(control: Control, preserve_caret: bool = false) -> void: _target = control _target_virtual_keyboard_enabled = bool( _target.get("virtual_keyboard_enabled") ) _target.set("virtual_keyboard_enabled", false) _buffer = str(_target.get("text")) - _buffer_caret = _buffer.length() + if preserve_caret and _target is LineEdit: + _buffer_caret = (_target as LineEdit).caret_column + elif preserve_caret and _target is TextEdit: + _buffer_caret = _text_edit_caret_offset(_target as TextEdit) + else: + _buffer_caret = _buffer.length() + _buffer_caret = clampi(_buffer_caret, 0, _buffer.length()) _set_target_caret(_buffer_caret) _page = Page.LOWER _last_focused_key = null diff --git a/ui/players_page.gd b/ui/players_page.gd index 7b77ef1..27c142c 100644 --- a/ui/players_page.gd +++ b/ui/players_page.gd @@ -17,6 +17,12 @@ const MODERATION_KICK_ICON: Texture2D = preload( const MODERATION_MUTE_ICON: Texture2D = preload( "res://ui/icons/moderation_options/moderation_options_mute_light.png" ) +const FRIEND_ICON: Texture2D = preload( + "res://ui/icons/player_options/friends.png" +) +const CLEAR_ART_ICON: Texture2D = preload( + "res://ui/icons/player_options/clean.png" +) const MODERATION_BUTTON_SIZE := Vector2(52.0, 40.0) const MODERATION_ICON_SIZE: int = 40 @@ -61,11 +67,31 @@ func setup( _status.text = message _refresh() ) + _service.friend_action_finished.connect(func(_ok: bool, message: String) -> void: + _status.text = message + _refresh() + ) if _discovery != null: _discovery.host_settings_changed.connect( _on_host_settings_changed ) _discovery.host_status_changed.connect(_on_host_status_changed) + _discovery.friend_presence_updated.connect( + func(_friends: Array[Dictionary]) -> void: _refresh() + ) + _discovery.presence_sharing_changed.connect( + func(_enabled: bool) -> void: _refresh() + ) + _discovery.friend_invite_finished.connect( + func(_ok: bool, message: String) -> void: + _status.text = message + _refresh() + ) + _discovery.social_status_changed.connect( + func(message: String, is_error: bool) -> void: + if is_error or _current_tab == 1: + _status.text = message + ) _refresh() @@ -185,9 +211,9 @@ func _build() -> void: _tabs.size_flags_horizontal = Control.SIZE_EXPAND_FILL _tabs.add_theme_constant_override("separation", 10) tab_row.add_child(_tabs) - for index: int in 3: + for index: int in 4: var button := Button.new() - button.text = ["players", "relationships", "banned"][index] + button.text = ["players", "friends", "relationships", "banned"][index] button.toggle_mode = true button.pressed.connect(_select_tab.bind(index)) UtilityPageStyle.apply_ocean_button(button) @@ -322,7 +348,7 @@ func _set_host_toggle_state( func _select_tab(index: int) -> void: - if index == 2 and (_service == null or not _service.is_local_moderator()): + if index == 3 and (_service == null or not _service.is_local_moderator()): return _current_tab = index _refresh() @@ -335,12 +361,12 @@ func _refresh() -> void: _count_label.text = "%d / %d connected" % [ _service.get_connected_count(), _service.get_max_players(), ] - if _current_tab == 2 and not _service.is_local_moderator(): + if _current_tab == 3 and not _service.is_local_moderator(): _current_tab = 0 for index: int in _tabs.get_child_count(): var button := _tabs.get_child(index) as Button button.button_pressed = index == _current_tab - button.visible = index != 2 or _service.is_local_moderator() + button.visible = index != 3 or _service.is_local_moderator() _refresh_host_settings() for child: Node in _list.get_children(): child.queue_free() @@ -348,8 +374,10 @@ func _refresh() -> void: 0: _build_active_rows() 1: - _build_relationship_rows() + _build_friend_rows() 2: + _build_relationship_rows() + 3: _build_ban_rows() if _controller_zone == ControllerZone.BODY and _body_controls().is_empty(): _controller_zone = ControllerZone.TABS @@ -520,6 +548,23 @@ func _build_active_player_row(entry: PlayerListEntry) -> void: actions.alignment = BoxContainer.ALIGNMENT_END actions.add_theme_constant_override("separation", 6) row.add_child(actions) + var friend := Button.new() + friend.disabled = entry.is_local_player or entry.is_friend or not entry.can_request_friend + var friend_action := "friends" if entry.is_friend else "add friend" + _configure_moderation_button(friend, FRIEND_ICON, friend_action) + friend.tooltip_text = ( + "Already friends." + if entry.is_friend + else "This person needs to be online to do this." + if not entry.can_request_friend + else "Send a live friend request." + ) + friend.pressed.connect(func() -> void: + _service.send_friend_request( + entry.peer_id, entry.full_fingerprint, entry.display_name + ) + ) + actions.add_child(friend) var mute := Button.new() var mute_action: String = "unmute" if entry.muted else "mute" mute.disabled = entry.is_local_player @@ -539,14 +584,9 @@ func _build_active_player_row(entry: PlayerListEntry) -> void: operator.custom_minimum_size = MODERATION_BUTTON_SIZE actions.add_child(operator) var clear_art := Button.new() - clear_art.text = "clear art" clear_art.disabled = not entry.can_clear_art - clear_art.tooltip_text = ( - "Remove every shared artwork this player participated in." - ) + _configure_moderation_button(clear_art, CLEAR_ART_ICON, "scrub art") clear_art.pressed.connect(_confirm_clear_art.bind(entry)) - UtilityPageStyle.apply_compact_ocean_button(clear_art) - clear_art.custom_minimum_size = Vector2(84.0, 40.0) actions.add_child(clear_art) var kick := Button.new() kick.disabled = not entry.can_kick @@ -661,6 +701,104 @@ func _build_relationship_rows() -> void: row.add_child(unmute) +func _build_friend_rows() -> void: + _build_presence_controls() + var friends: Array[Dictionary] = ( + _discovery.get_friend_presence() + if _discovery != null + else [] + ) + if friends.is_empty(): + _add_empty("No friends added yet. Add someone while you are in a room together.") + return + for friend: Dictionary in friends: + var row := _make_row() + var fingerprint := str(friend.get("fingerprint", "")) + var display_name := str(friend.get("display_name", "Player")) + var room: Dictionary = ( + friend.get("room", {}) + if typeof(friend.get("room", {})) == TYPE_DICTIONARY + else {} + ) + var label := Label.new() + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + label.clip_text = true + label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + label.text = "%s · %s %s" % [ + display_name, + NetworkIdentityCrypto.compact_suffix(fingerprint), + "playing in %s" % str(room.get("room_name", "a public room")) + if not room.is_empty() + else "Online" + if bool(friend.get("online", false)) + else "Offline", + ] + label.tooltip_text = NetworkIdentityCrypto.format_fingerprint(fingerprint) + label.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY + ) + row.add_child(label) + var invite := Button.new() + invite.text = "invite" + invite.tooltip_text = ( + "Invite this friend to your listed public room." + if bool(friend.get("online", false)) + else "This person needs to be online to do this." + ) + invite.pressed.connect(func() -> void: + _discovery.send_friend_invite(fingerprint) + ) + UtilityPageStyle.apply_compact_ocean_button(invite) + row.add_child(invite) + var remove := Button.new() + remove.text = "remove" + remove.pressed.connect(func() -> void: + _confirm( + "Remove %s from your friends?" % display_name, + func() -> void: + _service.remove_friend(fingerprint, display_name), + ) + ) + UtilityPageStyle.apply_compact_ocean_button(remove) + row.add_child(remove) + var block := Button.new() + block.text = "block" + block.pressed.connect(func() -> void: + _confirm( + "Block %s?\nThis also removes the friendship." % display_name, + func() -> void: + _service.set_blocked(fingerprint, display_name, true), + ) + ) + UtilityPageStyle.apply_compact_ocean_button(block) + row.add_child(block) + + +func _build_presence_controls() -> void: + var row := _make_row() + var label := Label.new() + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + label.text = "share online status with friends" + label.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY + ) + row.add_child(label) + var toggle := Button.new() + var enabled := _discovery != null and _discovery.is_presence_sharing() + toggle.text = "on" if enabled else "off" + toggle.tooltip_text = ( + "Friends can see when you are online and whether your room is joinable." + ) + toggle.disabled = _discovery == null or not _discovery.is_configured() + toggle.pressed.connect(func() -> void: + _discovery.set_presence_sharing( + not _discovery.is_presence_sharing() + ) + ) + UtilityPageStyle.apply_compact_ocean_button(toggle) + row.add_child(toggle) + + func _build_ban_rows() -> void: var records := _service.get_bans() if records.is_empty(): diff --git a/ui/save_slots_page.gd b/ui/save_slots_page.gd new file mode 100644 index 0000000..e50c8ee --- /dev/null +++ b/ui/save_slots_page.gd @@ -0,0 +1,731 @@ +class_name SaveSlotsPage +extends Control + +const PAGE_SAVES: StringName = &"saves" +const PAGE_NEW: StringName = &"new" +const WorldLayoutType = preload("res://world/world_layout.gd") +const SaveManagerType = preload("res://save/player_save_manager.gd") +const NewGameSetupPageType = preload("res://ui/new_game_setup_page.gd") +const DialogControllerNavigationType = preload( + "res://ui/file_dialog_controller_navigation.gd" +) + +signal back_requested +signal play_requested(slot_id: String) +signal create_requested( + display_name: String, + world_layout: StringName, + world_seed: int, + duplicate_source_slot_id: String, +) + +enum SeedMode { + RANDOM, + CUSTOM, +} + +@onready var _main_panel: PanelContainer = %MainPanel +@onready var _content_panel: PanelContainer = %ContentPanel +@onready var _saves_tab: OrganizerTab = %SavesTab +@onready var _new_slot_tab: OrganizerTab = %NewSlotTab +@onready var _saves_page: Control = %SavesPage +@onready var _new_slot_page: Control = %NewSlotPage +@onready var _slot_list: VBoxContainer = %SlotList +@onready var _empty_slots_label: Label = %EmptySlotsLabel +@onready var _slot_name_edit: LineEdit = %SelectedSlotName +@onready var _slot_summary: RichTextLabel = %SlotSummary +@onready var _play_button: Button = %PlaySlotButton +@onready var _rename_button: Button = %RenameSlotButton +@onready var _duplicate_button: Button = %DuplicateSlotButton +@onready var _export_button: Button = %ExportSlotButton +@onready var _delete_button: Button = %DeleteSlotButton +@onready var _import_button: Button = %ImportSlotButton +@onready var _new_slot_heading: Label = %NewSlotHeading +@onready var _new_slot_name: LineEdit = %NewSlotName +@onready var _generated_button: Button = %GeneratedButton +@onready var _starter_button: Button = %StarterButton +@onready var _world_description: Label = %WorldDescription +@onready var _seed_section: VBoxContainer = %SeedSection +@onready var _random_seed_button: Button = %RandomSeedButton +@onready var _custom_seed_button: Button = %CustomSeedButton +@onready var _seed_edit: LineEdit = %SeedEdit +@onready var _seed_help: Label = %SeedHelp +@onready var _create_button: Button = %CreateSlotButton +@onready var _status: Label = %Status +@onready var _back_button: Button = %BackButton + +var _catalog: PlayerSaveSlotCatalog +var _data_root: PlayerDataRoot +var _interface_fonts: InterfaceFontController +var _active_page_id: StringName = PAGE_SAVES +var _selected_slot_id := "" +var _duplicate_source_slot_id := "" +var _world_layout: StringName = WorldLayoutType.GENERATED +var _seed_mode: SeedMode = SeedMode.RANDOM +var _random_seed: int = SaveManagerType.DEFAULT_WORLD_SEED +var _slot_buttons: Array[Button] = [] +var _import_dialog: FileDialog +var _export_dialog: FileDialog +var _delete_dialog: ConfirmationDialog +var _busy: bool = false + + +func _ready() -> void: + _configure_style() + _saves_tab.pressed.connect(_select_page.bind(PAGE_SAVES, true)) + _new_slot_tab.pressed.connect(_open_fresh_slot_page) + _play_button.pressed.connect(_request_play) + _rename_button.pressed.connect(_rename_selected_slot) + _duplicate_button.pressed.connect(_prepare_duplicate) + _export_button.pressed.connect(_choose_export_path) + _delete_button.pressed.connect(_confirm_delete_selected_slot) + _import_button.pressed.connect(_choose_import_path) + _generated_button.pressed.connect( + _set_world_layout.bind(WorldLayoutType.GENERATED) + ) + _starter_button.pressed.connect( + _set_world_layout.bind(WorldLayoutType.STARTER_ISLAND) + ) + _random_seed_button.pressed.connect(_choose_random_seed) + _custom_seed_button.pressed.connect(_choose_custom_seed) + _new_slot_name.text_changed.connect(_on_new_slot_name_changed) + _seed_edit.text_changed.connect(_on_seed_text_changed) + _seed_edit.text_submitted.connect(_on_seed_submitted) + _new_slot_name.text_submitted.connect(_on_new_slot_name_submitted) + _create_button.pressed.connect(_request_create) + _back_button.pressed.connect(request_back) + visibility_changed.connect(_on_visibility_changed) + hide() + + +func _configure_style() -> void: + UtilityPageStyle.apply_page(self) + _main_panel.add_theme_stylebox_override( + "panel", + UtilityPageStyle.rounded_style( + UtilityPageStyle.OCEAN_PANEL_MID, + 28, + ), + ) + _content_panel.add_theme_stylebox_override( + "panel", + UtilityPageStyle.rounded_style( + UtilityPageStyle.OCEAN_FIELD, + 20, + ), + ) + for node: Node in find_children("*", "Label", true, false): + var label := node as Label + label.add_theme_color_override( + "font_color", + UtilityPageStyle.OCEAN_TEXT_PRIMARY, + ) + for node: Node in find_children("*", "Button", true, false): + if node is OrganizerTab: + continue + UtilityPageStyle.apply_ocean_button(node as BaseButton) + _delete_button.add_theme_stylebox_override( + "normal", + UtilityPageStyle.ocean_button_style( + UtilityPageStyle.OCEAN_DANGER, + ), + ) + _back_button.add_theme_stylebox_override( + "normal", + UtilityPageStyle.ocean_button_style( + UtilityPageStyle.OCEAN_PANEL_DEEP, + ), + ) + _status.add_theme_color_override( + "font_color", + UtilityPageStyle.OCEAN_TEXT_SECONDARY, + ) + + +func setup( + catalog: PlayerSaveSlotCatalog, + data_root: PlayerDataRoot, + interface_fonts: InterfaceFontController, +) -> void: + _catalog = catalog + _data_root = data_root + _interface_fonts = interface_fonts + if not _catalog.slots_changed.is_connected(_on_slots_changed): + _catalog.slots_changed.connect(_on_slots_changed) + + +func open_page() -> void: + _busy = false + _status.text = "" + _duplicate_source_slot_id = "" + _refresh_slots() + if _catalog != null and _catalog.has_slots(): + _select_page(PAGE_SAVES, false) + else: + _prepare_fresh_slot() + _select_page(PAGE_NEW, false) + show() + _main_panel.modulate.a = 1.0 + _main_panel.scale = Vector2.ONE + UtilityPageStyle.animate_in(self) + call_deferred("_focus_open_page") + + +func close_page() -> void: + _release_owned_focus() + hide() + + +func request_back() -> void: + if _import_dialog != null and _import_dialog.visible: + _import_dialog.hide() + return + if _export_dialog != null and _export_dialog.visible: + _export_dialog.hide() + return + if _delete_dialog != null and _delete_dialog.visible: + _delete_dialog.hide() + return + if _busy: + return + if _active_page_id == PAGE_NEW and _catalog != null and _catalog.has_slots(): + _duplicate_source_slot_id = "" + _select_page(PAGE_SAVES, true) + return + back_requested.emit() + + +func set_status(message: String) -> void: + _busy = false + _status.text = message + _refresh_action_state() + + +func finish_request_if_pending(message: String) -> void: + if _busy: + set_status(message) + + +func refresh_page() -> void: + if visible: + _refresh_slots() + + +func get_active_page_id() -> StringName: + return _active_page_id if visible else StringName() + + +func _select_page(page_id: StringName, focus_tab: bool) -> void: + if page_id not in [PAGE_SAVES, PAGE_NEW]: + return + _active_page_id = page_id + _saves_page.visible = page_id == PAGE_SAVES + _new_slot_page.visible = page_id == PAGE_NEW + _saves_tab.set_selected(page_id == PAGE_SAVES, is_inside_tree()) + _new_slot_tab.set_selected(page_id == PAGE_NEW, is_inside_tree()) + _configure_controller_focus() + if focus_tab: + (_saves_tab if page_id == PAGE_SAVES else _new_slot_tab).grab_focus() + + +func _open_fresh_slot_page() -> void: + _prepare_fresh_slot() + _select_page(PAGE_NEW, true) + + +func _prepare_fresh_slot() -> void: + _duplicate_source_slot_id = "" + _new_slot_heading.text = "new progression" + _new_slot_name.text = _suggested_slot_name() + _world_layout = WorldLayoutType.GENERATED + _seed_mode = SeedMode.RANDOM + _roll_random_seed() + _refresh_new_slot_presentation() + + +func _prepare_duplicate() -> void: + var slot: Dictionary = _selected_slot() + if slot.is_empty() or not bool(slot.get("has_save", false)): + return + _duplicate_source_slot_id = _selected_slot_id + _new_slot_heading.text = "duplicate progression" + _new_slot_name.text = PlayerSaveSlotCatalog.normalized_name( + "copy of %s" % str(slot.get("display_name", "save")) + ) + _world_layout = WorldLayoutType.GENERATED + _seed_mode = SeedMode.RANDOM + _roll_random_seed() + _refresh_new_slot_presentation() + _select_page(PAGE_NEW, true) + + +func _refresh_slots() -> void: + for button: Button in _slot_buttons: + _slot_list.remove_child(button) + button.queue_free() + _slot_buttons.clear() + if _catalog == null: + _selected_slot_id = "" + _empty_slots_label.show() + _refresh_selected_slot() + return + var slots: Array[Dictionary] = _catalog.list_slots() + var known_selection: bool = false + for slot: Dictionary in slots: + var slot_id: String = str(slot.get("slot_id", "")) + var button := Button.new() + button.name = "Slot_%s" % slot_id.left(8) + button.custom_minimum_size = Vector2(0.0, 56.0) + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.focus_mode = Control.FOCUS_ALL + button.toggle_mode = true + button.text = str(slot.get("display_name", "save")) + ( + " · active" if bool(slot.get("active", false)) else "" + ) + button.alignment = HORIZONTAL_ALIGNMENT_LEFT + UtilityPageStyle.apply_ocean_button(button) + button.pressed.connect(_select_slot.bind(slot_id, false)) + button.focus_entered.connect(_select_slot.bind(slot_id, true)) + _slot_list.add_child(button) + _slot_buttons.append(button) + known_selection = known_selection or slot_id == _selected_slot_id + _empty_slots_label.visible = slots.is_empty() + if not known_selection: + _selected_slot_id = _catalog.get_active_slot_id() + if _selected_slot_id.is_empty() and not slots.is_empty(): + _selected_slot_id = str(slots.front().get("slot_id", "")) + _refresh_selected_slot() + _configure_controller_focus() + + +func _select_slot(slot_id: String, _from_focus: bool) -> void: + if _catalog == null or _catalog.get_slot(slot_id).is_empty(): + return + _selected_slot_id = slot_id + _refresh_selected_slot() + + +func _refresh_selected_slot() -> void: + var slot: Dictionary = _selected_slot() + for button: Button in _slot_buttons: + button.set_pressed_no_signal( + button.name == "Slot_%s" % _selected_slot_id.left(8) + ) + if slot.is_empty(): + _slot_name_edit.text = "" + _slot_name_edit.editable = false + _slot_summary.text = "[center]create or import a save slot to begin.[/center]" + else: + _slot_name_edit.editable = true + _slot_name_edit.text = str(slot.get("display_name", "save")) + _slot_summary.text = _format_slot_summary(slot) + _refresh_action_state() + + +func _format_slot_summary(slot: Dictionary) -> String: + if not bool(slot.get("has_save", false)): + return "[center]%s[/center]" % str( + slot.get("status_message", "this slot has no progression yet.") + ) + var last_played: String = _format_timestamp( + int(slot.get("last_played_at_unix", 0)) + ) + return ( + "[font_size=24]level %d[/font_size]\n\n" + + "%d catches · %d discovered\n" + + "%d fishcoins\n\n" + + "%s · seed %d\n" + + "last played %s" + ) % [ + int(slot.get("player_level", 1)), + int(slot.get("catch_count", 0)), + int(slot.get("discovered_species_count", 0)), + int(slot.get("wallet_balance", 0)), + WorldLayoutType.display_name(slot.get("world_layout", "")), + int(slot.get("world_seed", 0)), + last_played, + ] + + +func _refresh_action_state() -> void: + var slot: Dictionary = _selected_slot() + var has_slot: bool = not slot.is_empty() + var has_save: bool = has_slot and bool(slot.get("has_save", false)) + _play_button.disabled = _busy or not has_save + _rename_button.disabled = _busy or not has_slot + _duplicate_button.disabled = _busy or not has_save + _export_button.disabled = _busy or not has_save + _delete_button.disabled = _busy or not has_slot + _import_button.disabled = _busy or _catalog == null + _create_button.disabled = _busy or not _new_slot_request_is_valid() + + +func _request_play() -> void: + if _play_button.disabled or _selected_slot_id.is_empty(): + return + _busy = true + _status.text = "loading save slot..." + _refresh_action_state() + play_requested.emit(_selected_slot_id) + + +func _rename_selected_slot() -> void: + if _catalog == null or _rename_button.disabled: + return + if not _catalog.rename_slot(_selected_slot_id, _slot_name_edit.text): + _status.text = "the save slot could not be renamed." + return + _status.text = "save slot renamed." + + +func _confirm_delete_selected_slot() -> void: + var slot: Dictionary = _selected_slot() + if slot.is_empty() or _delete_button.disabled: + return + if _delete_dialog == null: + _delete_dialog = ConfirmationDialog.new() + _delete_dialog.title = "delete save slot?" + _delete_dialog.ok_button_text = "delete" + _delete_dialog.confirmed.connect(_delete_selected_slot) + if _interface_fonts != null: + _interface_fonts.apply_utility_theme(_delete_dialog) + add_child(_delete_dialog) + _delete_dialog.dialog_text = ( + "delete \"%s\"? this cannot be undone from the game." + % str(slot.get("display_name", "save")) + ) + _delete_dialog.popup_centered(Vector2i(560, 260)) + _configure_delete_dialog.call_deferred() + + +func _configure_delete_dialog() -> void: + if ( + _delete_dialog != null + and is_instance_valid(_delete_dialog) + and _delete_dialog.visible + ): + DialogControllerNavigationType.configure_scope( + _delete_dialog, + _delete_dialog.get_cancel_button(), + ) + + +func _delete_selected_slot() -> void: + if _catalog == null or _selected_slot_id.is_empty(): + return + if not _catalog.delete_slot(_selected_slot_id): + _status.text = "the save slot could not be deleted." + return + _selected_slot_id = _catalog.get_active_slot_id() + _status.text = "save slot deleted." + _refresh_slots() + if not _catalog.has_slots(): + _prepare_fresh_slot() + _select_page(PAGE_NEW, true) + + +func _choose_import_path() -> void: + if _catalog == null or _data_root == null: + return + if _import_dialog == null: + _import_dialog = FileDialog.new() + _import_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE + _import_dialog.access = FileDialog.ACCESS_FILESYSTEM + _import_dialog.use_native_dialog = false + _import_dialog.filters = PackedStringArray([ + "*.nfsave ; NETfishing progression archive", + ]) + _import_dialog.file_selected.connect(_import_file_selected) + if _interface_fonts != null: + _interface_fonts.apply_utility_theme(_import_dialog) + add_child(_import_dialog) + _import_dialog.current_dir = _data_root.progression_backup_directory() + if _interface_fonts != null: + _interface_fonts.popup_file_dialog(_import_dialog) + else: + _import_dialog.popup_centered_ratio(0.85) + + +func _import_file_selected(path: String) -> void: + var suggested_name: String = path.get_file().get_basename().replace("_", " ") + suggested_name = suggested_name.replace("-", " ") + var result: Dictionary = _catalog.import_slot(path, suggested_name) + _status.text = str(result.get("message", "progression import failed.")) + if bool(result.get("ok", false)): + _selected_slot_id = str(result.get("slot_id", "")) + _refresh_slots() + _select_page(PAGE_SAVES, false) + + +func _choose_export_path() -> void: + var slot: Dictionary = _selected_slot() + if slot.is_empty() or _data_root == null: + return + var timestamp: String = Time.get_datetime_string_from_system().replace(":", "-") + var filename: String = "%s-%s%s" % [ + _safe_filename(str(slot.get("display_name", "save"))), + timestamp, + PlayerSaveManager.ARCHIVE_EXTENSION, + ] + if _export_dialog == null: + _export_dialog = FileDialog.new() + _export_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE + _export_dialog.access = FileDialog.ACCESS_FILESYSTEM + _export_dialog.use_native_dialog = false + _export_dialog.filters = PackedStringArray([ + "*.nfsave ; NETfishing progression archive", + ]) + _export_dialog.file_selected.connect(_export_file_selected) + if _interface_fonts != null: + _interface_fonts.apply_utility_theme(_export_dialog) + add_child(_export_dialog) + _export_dialog.current_dir = _data_root.progression_backup_directory() + _export_dialog.current_file = filename + if _interface_fonts != null: + _interface_fonts.popup_file_dialog(_export_dialog) + else: + _export_dialog.popup_centered_ratio(0.85) + + +func _export_file_selected(path: String) -> void: + var destination: String = ( + path + if path.ends_with(PlayerSaveManager.ARCHIVE_EXTENSION) + else path + PlayerSaveManager.ARCHIVE_EXTENSION + ) + var result: Dictionary = _catalog.export_slot( + _selected_slot_id, + destination, + ) + _status.text = str(result.get("message", "progression export failed.")) + + +func _request_create() -> void: + if _create_button.disabled: + return + var seed: int = _selected_world_seed() + if _world_layout == WorldLayoutType.GENERATED and 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() + _busy = true + _status.text = "creating save slot..." + _refresh_action_state() + create_requested.emit( + PlayerSaveSlotCatalog.normalized_name(_new_slot_name.text), + _world_layout, + seed, + _duplicate_source_slot_id, + ) + + +func _set_world_layout(layout: StringName) -> void: + if not WorldLayoutType.is_valid(layout): + return + _world_layout = layout + _status.text = "" + _refresh_new_slot_presentation() + + +func _choose_random_seed() -> void: + _seed_mode = SeedMode.RANDOM + _roll_random_seed() + _status.text = "" + _refresh_new_slot_presentation() + + +func _choose_custom_seed() -> void: + _seed_mode = SeedMode.CUSTOM + if NewGameSetupPageType.parse_seed_text(_seed_edit.text) == _random_seed: + _seed_edit.text = "" + _status.text = "" + _refresh_new_slot_presentation() + _seed_edit.grab_focus.call_deferred() + _seed_edit.select_all.call_deferred() + + +func _roll_random_seed() -> void: + _random_seed = SaveManagerType.roll_world_seed() + _seed_edit.text = str(_random_seed) + + +func _selected_world_seed() -> int: + return ( + _random_seed + if _seed_mode == SeedMode.RANDOM + else NewGameSetupPageType.parse_seed_text(_seed_edit.text) + ) + + +func _on_seed_text_changed(_text: String) -> void: + if _seed_mode == SeedMode.CUSTOM: + _status.text = "" + _refresh_action_state() + + +func _on_new_slot_name_changed(_text: String) -> void: + _refresh_action_state() + + +func _on_seed_submitted(_text: String) -> void: + _request_create() + + +func _on_new_slot_name_submitted(_text: String) -> void: + if _world_layout == WorldLayoutType.STARTER_ISLAND: + _request_create() + + +func _refresh_new_slot_presentation() -> void: + var generated: bool = _world_layout == WorldLayoutType.GENERATED + _generated_button.set_pressed_no_signal(generated) + _starter_button.set_pressed_no_signal(not generated) + _world_description.text = ( + "build a new island from terrain chunks.\n" + + "the same seed always builds the same world." + if generated + else "play on the starter island." + ) + _seed_section.visible = generated + _random_seed_button.set_pressed_no_signal(_seed_mode == SeedMode.RANDOM) + _custom_seed_button.set_pressed_no_signal(_seed_mode == SeedMode.CUSTOM) + _seed_edit.editable = _seed_mode == SeedMode.CUSTOM + _seed_edit.focus_mode = ( + Control.FOCUS_ALL if _seed_mode == SeedMode.CUSTOM else Control.FOCUS_NONE + ) + _seed_help.text = ( + "press random seed again to roll another world." + if _seed_mode == SeedMode.RANDOM + else "enter text or a number from 1 to %d." + % SaveManagerType.MAX_WORLD_SEED + ) + _create_button.text = ( + "duplicate and play" + if not _duplicate_source_slot_id.is_empty() + else "create and play" + ) + _refresh_action_state() + _configure_controller_focus() + + +func _new_slot_request_is_valid() -> bool: + return ( + not PlayerSaveSlotCatalog.normalized_name(_new_slot_name.text).is_empty() + and ( + _world_layout != WorldLayoutType.GENERATED + or _seed_mode == SeedMode.RANDOM + or NewGameSetupPageType.parse_seed_text(_seed_edit.text) > 0 + ) + ) + + +func _configure_controller_focus() -> void: + if not is_node_ready(): + return + _saves_tab.focus_neighbor_right = _saves_tab.get_path_to(_new_slot_tab) + _new_slot_tab.focus_neighbor_left = _new_slot_tab.get_path_to(_saves_tab) + if _active_page_id == PAGE_SAVES: + var first: Control = _slot_buttons.front() if not _slot_buttons.is_empty() else _import_button + var last: Control = _slot_buttons.back() if not _slot_buttons.is_empty() else _import_button + _saves_tab.focus_neighbor_bottom = _saves_tab.get_path_to(first) + _new_slot_tab.focus_neighbor_bottom = _new_slot_tab.get_path_to(first) + for index: int in _slot_buttons.size(): + var button: Button = _slot_buttons[index] + var top: Control = _saves_tab if index == 0 else _slot_buttons[index - 1] + var bottom: Control = _import_button if index == _slot_buttons.size() - 1 else _slot_buttons[index + 1] + _set_neighbors(button, button, _slot_name_edit, top, bottom) + _set_neighbors(_import_button, _import_button, _slot_name_edit, last, _back_button) + _set_neighbors(_slot_name_edit, first, _slot_name_edit, _saves_tab, _play_button) + _set_neighbors(_play_button, _import_button, _rename_button, _slot_name_edit, _duplicate_button) + _set_neighbors(_rename_button, _play_button, _rename_button, _slot_name_edit, _export_button) + _set_neighbors(_duplicate_button, _import_button, _export_button, _play_button, _delete_button) + _set_neighbors(_export_button, _duplicate_button, _export_button, _rename_button, _delete_button) + _set_neighbors(_delete_button, _import_button, _delete_button, _duplicate_button, _back_button) + _set_neighbors(_back_button, _back_button, _back_button, _import_button, _back_button) + return + _saves_tab.focus_neighbor_bottom = _saves_tab.get_path_to(_new_slot_name) + _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 + _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(_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 + _set_neighbors(_create_button, _create_button, _back_button, action_top, _create_button) + _set_neighbors(_back_button, _create_button, _back_button, action_top, _back_button) + + +func _set_neighbors( + control: Control, + left: Control, + right: Control, + top: Control, + bottom: Control, +) -> void: + control.focus_neighbor_left = control.get_path_to(left) + control.focus_neighbor_right = control.get_path_to(right) + control.focus_neighbor_top = control.get_path_to(top) + control.focus_neighbor_bottom = control.get_path_to(bottom) + + +func _focus_open_page() -> void: + if not visible: + return + (_saves_tab if _active_page_id == PAGE_SAVES else _new_slot_tab).grab_focus() + + +func _selected_slot() -> Dictionary: + return _catalog.get_slot(_selected_slot_id) if _catalog != null else {} + + +func _suggested_slot_name() -> String: + var used: Dictionary[String, bool] = {} + if _catalog != null: + for slot: Dictionary in _catalog.list_slots(): + used[str(slot.get("display_name", "")).to_lower()] = true + var number: int = 1 + while used.has("save %d" % number): + number += 1 + return "save %d" % number + + +func _format_timestamp(unix_time: int) -> String: + if unix_time <= 0: + return "never" + var value: Dictionary = Time.get_datetime_dict_from_unix_time(unix_time) + return "%04d-%02d-%02d" % [ + int(value.get("year", 0)), + int(value.get("month", 0)), + int(value.get("day", 0)), + ] + + +func _safe_filename(value: String) -> String: + var result: String = value.strip_edges().to_lower().replace(" ", "-") + for character: String in ["/", "\\", ":", "*", "?", "\"", "<", ">", "|"]: + result = result.replace(character, "") + return "netfishing-save" if result.is_empty() else result + + +func _on_slots_changed() -> void: + if visible: + _refresh_slots() + + +func _on_visibility_changed() -> void: + if not visible: + _release_owned_focus() + + +func _release_owned_focus() -> void: + if not is_inside_tree(): + return + var focus_owner: Control = get_viewport().gui_get_focus_owner() + if focus_owner != null and is_ancestor_of(focus_owner): + focus_owner.release_focus() diff --git a/ui/save_slots_page.gd.uid b/ui/save_slots_page.gd.uid new file mode 100644 index 0000000..9cb483f --- /dev/null +++ b/ui/save_slots_page.gd.uid @@ -0,0 +1 @@ +uid://cytwdvxf22106 diff --git a/ui/save_slots_page.tscn b/ui/save_slots_page.tscn new file mode 100644 index 0000000..5f47c8e --- /dev/null +++ b/ui/save_slots_page.tscn @@ -0,0 +1,350 @@ +[gd_scene load_steps=4 format=3] + +[ext_resource type="Script" path="res://ui/save_slots_page.gd" id="1_script"] +[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"] +[ext_resource type="Script" path="res://ui/components/organizer_tab.gd" id="3_tab"] + +[node name="SaveSlotsPage" type="Control"] +visible = false +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme = ExtResource("2_theme") +script = ExtResource("1_script") + +[node name="MainPanel" type="PanelContainer" parent="."] +unique_name_in_owner = true +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -440.0 +offset_top = -330.0 +offset_right = 440.0 +offset_bottom = 330.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="OuterMargin" type="MarginContainer" parent="MainPanel"] +layout_mode = 2 +theme_override_constants/margin_left = 24 +theme_override_constants/margin_top = 18 +theme_override_constants/margin_right = 24 +theme_override_constants/margin_bottom = 18 + +[node name="Layout" type="VBoxContainer" parent="MainPanel/OuterMargin"] +layout_mode = 2 +theme_override_constants/separation = -26 + +[node name="Heading" type="Label" parent="MainPanel/OuterMargin/Layout"] +custom_minimum_size = Vector2(0, 66) +layout_mode = 2 +theme_override_font_sizes/font_size = 30 +text = "play" +horizontal_alignment = 1 + +[node name="TabBar" type="HBoxContainer" parent="MainPanel/OuterMargin/Layout"] +custom_minimum_size = Vector2(0, 52) +layout_mode = 2 +theme_override_constants/separation = 8 +alignment = 1 + +[node name="SavesTab" type="Button" parent="MainPanel/OuterMargin/Layout/TabBar"] +unique_name_in_owner = true +custom_minimum_size = Vector2(180, 52) +layout_mode = 2 +focus_mode = 2 +text = "save slots" +script = ExtResource("3_tab") + +[node name="NewSlotTab" type="Button" parent="MainPanel/OuterMargin/Layout/TabBar"] +unique_name_in_owner = true +custom_minimum_size = Vector2(180, 52) +layout_mode = 2 +focus_mode = 2 +text = "new slot" +script = ExtResource("3_tab") +palette_index = 1 + +[node name="ContentPanel" type="PanelContainer" parent="MainPanel/OuterMargin/Layout"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 474) +layout_mode = 2 +size_flags_vertical = 3 + +[node name="ContentMargin" type="MarginContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel"] +layout_mode = 2 +theme_override_constants/margin_left = 28 +theme_override_constants/margin_top = 20 +theme_override_constants/margin_right = 28 +theme_override_constants/margin_bottom = 20 + +[node name="PageStack" type="Control" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin"] +layout_mode = 2 + +[node name="SavesPage" type="HBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack"] +unique_name_in_owner = true +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/separation = 20 + +[node name="SlotsColumn" type="VBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage"] +custom_minimum_size = Vector2(340, 0) +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="Title" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/SlotsColumn"] +layout_mode = 2 +theme_override_font_sizes/font_size = 20 +text = "save slots" + +[node name="SlotScroll" type="ScrollContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/SlotsColumn"] +layout_mode = 2 +size_flags_vertical = 3 +horizontal_scroll_mode = 0 +follow_focus = true + +[node name="SlotList" type="VBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/SlotsColumn/SlotScroll"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 8 + +[node name="EmptySlotsLabel" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/SlotsColumn/SlotScroll/SlotList"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +text = "no save slots yet" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="ImportSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/SlotsColumn"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +focus_mode = 2 +text = "import save" + +[node name="Divider" type="VSeparator" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage"] +layout_mode = 2 + +[node name="DetailsColumn" type="VBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 10 + +[node name="Title" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn"] +layout_mode = 2 +theme_override_font_sizes/font_size = 20 +text = "selected slot" + +[node name="SelectedSlotName" type="LineEdit" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +focus_mode = 2 +placeholder_text = "save slot name" +max_length = 32 + +[node name="SlotSummary" type="RichTextLabel" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_vertical = 3 +bbcode_enabled = true +fit_content = true +scroll_active = false +autowrap_mode = 2 +vertical_alignment = 1 + +[node name="Actions" type="GridContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn"] +layout_mode = 2 +theme_override_constants/h_separation = 10 +theme_override_constants/v_separation = 10 +columns = 2 + +[node name="PlaySlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 2 +text = "play" + +[node name="RenameSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 2 +text = "rename" + +[node name="DuplicateSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 2 +text = "duplicate" + +[node name="ExportSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 2 +text = "export" + +[node name="DeleteSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 2 +text = "delete" + +[node name="NewSlotPage" type="VBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack"] +unique_name_in_owner = true +visible = false +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/separation = 9 + +[node name="NewSlotHeading" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/NewSlotPage"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_font_sizes/font_size = 20 +text = "new progression" +horizontal_alignment = 1 + +[node name="NewSlotName" type="LineEdit" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/NewSlotPage"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +focus_mode = 2 +placeholder_text = "save slot name" +alignment = 1 +max_length = 32 + +[node name="WorldButtons" type="HBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/NewSlotPage"] +layout_mode = 2 +theme_override_constants/separation = 14 +alignment = 1 + +[node name="GeneratedButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/NewSlotPage/WorldButtons"] +unique_name_in_owner = true +custom_minimum_size = Vector2(230, 48) +layout_mode = 2 +focus_mode = 2 +toggle_mode = true +text = "generate a world" + +[node name="StarterButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/NewSlotPage/WorldButtons"] +unique_name_in_owner = true +custom_minimum_size = Vector2(230, 48) +layout_mode = 2 +focus_mode = 2 +toggle_mode = true +text = "starter island" + +[node name="WorldDescription" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/NewSlotPage"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 42) +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="SeedSection" type="VBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/NewSlotPage"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_constants/separation = 7 + +[node name="SeedModes" type="HBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/NewSlotPage/SeedSection"] +layout_mode = 2 +theme_override_constants/separation = 14 +alignment = 1 + +[node name="RandomSeedButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/NewSlotPage/SeedSection/SeedModes"] +unique_name_in_owner = true +custom_minimum_size = Vector2(190, 44) +layout_mode = 2 +focus_mode = 2 +toggle_mode = true +text = "random seed" + +[node name="CustomSeedButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/NewSlotPage/SeedSection/SeedModes"] +unique_name_in_owner = true +custom_minimum_size = Vector2(190, 44) +layout_mode = 2 +focus_mode = 2 +toggle_mode = true +text = "enter a seed" + +[node name="SeedEdit" type="LineEdit" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/NewSlotPage/SeedSection"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 42) +layout_mode = 2 +focus_mode = 2 +placeholder_text = "number or words" +alignment = 1 +max_length = 64 + +[node name="SeedHelp" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/NewSlotPage/SeedSection"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 24) +layout_mode = 2 +theme_override_font_sizes/font_size = 13 +horizontal_alignment = 1 + +[node name="Spacer" type="Control" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/NewSlotPage"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="CreateSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/NewSlotPage"] +unique_name_in_owner = true +custom_minimum_size = Vector2(260, 48) +layout_mode = 2 +size_flags_horizontal = 4 +focus_mode = 2 +text = "create and play" + +[node name="FooterGroup" type="MarginContainer" parent="MainPanel/OuterMargin/Layout"] +layout_mode = 2 +theme_override_constants/margin_top = 34 + +[node name="FooterLayout" type="VBoxContainer" parent="MainPanel/OuterMargin/Layout/FooterGroup"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="Status" type="Label" parent="MainPanel/OuterMargin/Layout/FooterGroup/FooterLayout"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 24) +layout_mode = 2 +horizontal_alignment = 1 +vertical_alignment = 1 +text_overrun_behavior = 3 + +[node name="Footer" type="HBoxContainer" parent="MainPanel/OuterMargin/Layout/FooterGroup/FooterLayout"] +layout_mode = 2 +alignment = 2 + +[node name="BackButton" type="Button" parent="MainPanel/OuterMargin/Layout/FooterGroup/FooterLayout/Footer"] +unique_name_in_owner = true +custom_minimum_size = Vector2(150, 46) +layout_mode = 2 +focus_mode = 2 +text = "back" diff --git a/ui/settings_panel.gd b/ui/settings_panel.gd index e93f0a2..51d0c96 100644 --- a/ui/settings_panel.gd +++ b/ui/settings_panel.gd @@ -86,6 +86,7 @@ enum PresentationMode { %ControllerSensitivityValue ) @onready var _invert_y_toggle: Button = %InvertYToggle +@onready var _swap_scroll_toggle: Button = %SwapScrollToggle @onready var _on_screen_keyboard_toggle: Button = %OnScreenKeyboardToggle @onready var _auto_click_toggle: Button = %AutoClickToggle @onready var _auto_click_interval_slider: HSlider = ( @@ -112,6 +113,7 @@ var _auto_click_interval_value: float = 0.20 var _mouse_sensitivity: float = 0.005 var _controller_sensitivity: float = 2.5 var _invert_camera_y: bool = false +var _swap_hotbar_camera_scroll: bool = false var _on_screen_keyboard_enabled: bool = false var _chat_dock_right: bool = false var _chat_mobile_mode: bool = false @@ -125,7 +127,6 @@ var _environment_volume: float = 1.0 var _network_profile: NetworkProfilePreferences var _network_session: NetworkSession var _data_root: PlayerDataRoot -var _progression_saves: PlayerSaveManager var _identity_backups: IdentityBackupService var _player_identity: PlayerIdentityStore var _host_identity: HostIdentityStore @@ -135,8 +136,6 @@ var _controller_mapping_panel: ControllerMappingPanelType var _keyboard_mouse_mapping_manager: KeyboardMouseMappingManagerType var _keyboard_mouse_mapping_panel: KeyboardMouseMappingPanelType var _data_folder_dialog: FileDialog -var _progression_import_dialog: FileDialog -var _progression_export_dialog: FileDialog var _backup_file_dialog: FileDialog var _export_file_dialog: FileDialog var _passphrase_dialog: ConfirmationDialog @@ -146,7 +145,6 @@ var _pending_identity_operation := "" var _pending_identity_type := "" var _pending_identity_path := "" var _pending_import_data: Dictionary = {} -var _pending_progression_path := "" func _notification(what: int) -> void: @@ -239,6 +237,7 @@ func _connect_controls() -> void: _on_controller_sensitivity_changed ) _invert_y_toggle.toggled.connect(_set_invert_y) + _swap_scroll_toggle.toggled.connect(_set_swap_scroll) _on_screen_keyboard_toggle.toggled.connect(_set_on_screen_keyboard) _auto_click_toggle.toggled.connect(_set_auto_click) _auto_click_interval_slider.value_changed.connect( @@ -248,8 +247,6 @@ func _connect_controls() -> void: %KeyboardMapping.pressed.connect(_open_keyboard_mouse_mapping) %OpenDataFolder.pressed.connect(_open_data_folder) %ChangeDataFolder.pressed.connect(_choose_data_folder) - %ExportProgression.pressed.connect(_choose_progression_export) - %ImportProgression.pressed.connect(_choose_progression_import) %ExportPlayerIdentity.pressed.connect( _choose_identity_export.bind("player") ) @@ -343,7 +340,6 @@ func setup_keyboard_mouse_mapping( func setup_data_and_identity( data_root: PlayerDataRoot, - progression_saves: PlayerSaveManager, identity_backups: IdentityBackupService, player_identity: PlayerIdentityStore, host_identity: HostIdentityStore, @@ -351,7 +347,6 @@ func setup_data_and_identity( interface_fonts: InterfaceFontController, ) -> void: _data_root = data_root - _progression_saves = progression_saves _identity_backups = identity_backups _player_identity = player_identity _host_identity = host_identity @@ -619,11 +614,6 @@ func _refresh_data_page() -> void: _data_root.override_active or (_network_session != null and _network_session.is_session_active()) ) - %ExportProgression.disabled = _progression_saves == null - %ImportProgression.disabled = ( - _progression_saves == null - or (_network_session != null and _network_session.is_session_active()) - ) var fingerprint: String = ( _player_identity.fingerprint if _player_identity != null else "" ) @@ -674,122 +664,6 @@ func _copy_player_fingerprint() -> void: _feedback.text = "full player fingerprint copied for server operator setup." -func _choose_progression_export() -> void: - if _progression_saves == null or _data_root == null: - _feedback.text = "progression export is unavailable." - return - var timestamp: String = Time.get_datetime_string_from_system().replace( - ":", "-" - ) - var suggested: String = _data_root.progression_backup_directory().path_join( - "NETfishing-progression-%s%s" - % [timestamp, PlayerSaveManager.ARCHIVE_EXTENSION] - ) - if _progression_export_dialog == null: - _progression_export_dialog = FileDialog.new() - _progression_export_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE - _progression_export_dialog.access = FileDialog.ACCESS_FILESYSTEM - _progression_export_dialog.use_native_dialog = false - _progression_export_dialog.filters = PackedStringArray([ - "*.nfsave ; NETfishing progression archive", - ]) - _progression_export_dialog.file_selected.connect( - _progression_export_file_selected - ) - _interface_fonts.apply_utility_theme(_progression_export_dialog) - add_child(_progression_export_dialog) - _progression_export_dialog.current_dir = suggested.get_base_dir() - _progression_export_dialog.current_file = suggested.get_file() - _interface_fonts.popup_file_dialog(_progression_export_dialog) - - -func _progression_export_file_selected(path: String) -> void: - var destination: String = ( - path - if path.ends_with(PlayerSaveManager.ARCHIVE_EXTENSION) - else path + PlayerSaveManager.ARCHIVE_EXTENSION - ) - var result: Dictionary = _progression_saves.export_progression_archive( - destination - ) - _feedback.text = str( - result.get("message", "progression export failed.") - ) - - -func _choose_progression_import() -> void: - if _progression_saves == null or _data_root == null: - _feedback.text = "progression import is unavailable." - return - if _network_session != null and _network_session.is_session_active(): - _feedback.text = "return to title before importing progression." - return - if _progression_import_dialog == null: - _progression_import_dialog = FileDialog.new() - _progression_import_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE - _progression_import_dialog.access = FileDialog.ACCESS_FILESYSTEM - _progression_import_dialog.use_native_dialog = false - _progression_import_dialog.filters = PackedStringArray([ - "*.nfsave ; NETfishing progression archive", - ]) - _progression_import_dialog.file_selected.connect( - _progression_import_file_selected - ) - _interface_fonts.apply_utility_theme(_progression_import_dialog) - add_child(_progression_import_dialog) - _progression_import_dialog.current_dir = ( - _data_root.progression_backup_directory() - ) - _interface_fonts.popup_file_dialog(_progression_import_dialog) - - -func _progression_import_file_selected(path: String) -> void: - var inspected: Dictionary = ( - _progression_saves.inspect_progression_archive(path) - ) - if not bool(inspected.get("ok", false)): - _feedback.text = str( - inspected.get("message", "progression archive could not be opened.") - ) - return - _pending_progression_path = path - var dialog := ConfirmationDialog.new() - dialog.title = "replace saved progression?" - dialog.ok_button_text = "import progression" - dialog.dialog_text = ( - "this will replace the current progression after making a backup.\n\n" - + "fish: %d\ndiscovered: %d\nworld: %s\nworld seed: %d\n\n" - + "identities, settings, friends, bans, and trusted servers are unchanged." - ) % [ - int(inspected.get("catch_count", 0)), - int(inspected.get("discovered_species_count", 0)), - WorldLayout.display_name(inspected.get( - "world_layout", - String(WorldLayout.GENERATED), - )), - int(inspected.get("world_seed", 0)), - ] - dialog.confirmed.connect(_confirm_progression_import.bind(dialog)) - dialog.canceled.connect(dialog.queue_free) - _interface_fonts.apply_utility_theme(dialog) - add_child(dialog) - dialog.popup_centered(Vector2i(640, 390)) - _configure_confirmation_dialog.call_deferred( - dialog, dialog.get_cancel_button() - ) - - -func _confirm_progression_import(dialog: ConfirmationDialog) -> void: - var result: Dictionary = _progression_saves.import_progression_archive( - _pending_progression_path - ) - _feedback.text = str( - result.get("message", "progression import failed.") - ) - _pending_progression_path = "" - dialog.queue_free() - - func _choose_data_folder() -> void: if _data_root == null or _data_root.override_active: _feedback.text = "the data folder is externally managed." @@ -1067,6 +941,7 @@ func _apply_settings() -> void: edited.mouse_camera_sensitivity = _mouse_sensitivity edited.controller_camera_sensitivity = _controller_sensitivity edited.invert_camera_y = _invert_camera_y + edited.swap_hotbar_camera_scroll = _swap_hotbar_camera_scroll edited.on_screen_keyboard_enabled = _on_screen_keyboard_enabled edited.chat_dock_right = _chat_dock_right edited.chat_mobile_mode = _chat_mobile_mode @@ -1099,6 +974,7 @@ func _load_controls() -> void: _mouse_sensitivity = settings.mouse_camera_sensitivity _controller_sensitivity = settings.controller_camera_sensitivity _invert_camera_y = settings.invert_camera_y + _swap_hotbar_camera_scroll = settings.swap_hotbar_camera_scroll _on_screen_keyboard_enabled = settings.on_screen_keyboard_enabled _chat_dock_right = settings.chat_dock_right _chat_mobile_mode = settings.chat_mobile_mode @@ -1125,6 +1001,7 @@ func _load_controls() -> void: _controller_sensitivity ) _invert_y_toggle.set_pressed_no_signal(_invert_camera_y) + _swap_scroll_toggle.set_pressed_no_signal(_swap_hotbar_camera_scroll) _on_screen_keyboard_toggle.set_pressed_no_signal( _on_screen_keyboard_enabled ) @@ -1142,6 +1019,9 @@ func _refresh_value_labels() -> void: "on" if _fullscreen_toggle.button_pressed else "off" ) _invert_y_toggle.text = "on" if _invert_camera_y else "off" + _swap_scroll_toggle.text = ( + "on" if _swap_hotbar_camera_scroll else "off" + ) _on_screen_keyboard_toggle.text = ( "on" if _on_screen_keyboard_enabled else "off" ) @@ -1255,6 +1135,11 @@ func _set_invert_y(enabled: bool) -> void: _refresh_value_labels() +func _set_swap_scroll(enabled: bool) -> void: + _swap_hotbar_camera_scroll = enabled + _refresh_value_labels() + + func _set_on_screen_keyboard(enabled: bool) -> void: _on_screen_keyboard_enabled = enabled _refresh_value_labels() diff --git a/ui/settings_panel.tscn b/ui/settings_panel.tscn index 94f627c..4504902 100644 --- a/ui/settings_panel.tscn +++ b/ui/settings_panel.tscn @@ -402,12 +402,32 @@ unique_name_in_owner = true custom_minimum_size = Vector2(330, 48) layout_mode = 2 focus_mode = 2 +focus_neighbor_bottom = NodePath("../SwapScrollToggle") toggle_mode = true text = "off" [node name="InvertYSpacer" type="Control" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/ControlsPage/Grid"] layout_mode = 2 +[node name="SwapScrollLabel" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/ControlsPage/Grid"] +custom_minimum_size = Vector2(210, 48) +layout_mode = 2 +text = "swap scroll controls" +vertical_alignment = 1 + +[node name="SwapScrollToggle" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/ControlsPage/Grid"] +unique_name_in_owner = true +custom_minimum_size = Vector2(330, 48) +layout_mode = 2 +focus_mode = 2 +focus_neighbor_top = NodePath("../InvertYToggle") +focus_neighbor_bottom = NodePath("../OnScreenKeyboardToggle") +toggle_mode = true +text = "off" + +[node name="SwapScrollSpacer" type="Control" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/ControlsPage/Grid"] +layout_mode = 2 + [node name="KeyboardLabel" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/ControlsPage/Grid"] custom_minimum_size = Vector2(210, 48) layout_mode = 2 @@ -419,6 +439,7 @@ unique_name_in_owner = true custom_minimum_size = Vector2(330, 48) layout_mode = 2 focus_mode = 2 +focus_neighbor_top = NodePath("../SwapScrollToggle") focus_neighbor_bottom = NodePath("../../BindingButtons/ControllerMapping") toggle_mode = true text = "off" @@ -567,7 +588,7 @@ size_flags_horizontal = 3 focus_mode = 2 focus_neighbor_right = NodePath("../ChangeDataFolder") focus_neighbor_top = NodePath("../../../../../../../../TabBar/DataTab") -focus_neighbor_bottom = NodePath("../../ProgressionRow/ExportProgression") +focus_neighbor_bottom = NodePath("../../CopyPlayerFingerprint") text = "open data folder" [node name="ChangeDataFolder" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/DataPage/DataScroll/DataButtons/FolderRow"] @@ -578,41 +599,15 @@ size_flags_horizontal = 3 focus_mode = 2 focus_neighbor_left = NodePath("../OpenDataFolder") focus_neighbor_top = NodePath("../../../../../../../../TabBar/DataTab") -focus_neighbor_bottom = NodePath("../../ProgressionRow/ImportProgression") +focus_neighbor_bottom = NodePath("../../CopyPlayerFingerprint") text = "change data folder" -[node name="ProgressionRow" type="HBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/DataPage/DataScroll/DataButtons"] -layout_mode = 2 -theme_override_constants/separation = 12 - -[node name="ExportProgression" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/DataPage/DataScroll/DataButtons/ProgressionRow"] -unique_name_in_owner = true -custom_minimum_size = Vector2(0, 44) -layout_mode = 2 -size_flags_horizontal = 3 -focus_mode = 2 -focus_neighbor_right = NodePath("../ImportProgression") -focus_neighbor_top = NodePath("../../FolderRow/OpenDataFolder") -focus_neighbor_bottom = NodePath("../../CopyPlayerFingerprint") -text = "export progression" - -[node name="ImportProgression" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/DataPage/DataScroll/DataButtons/ProgressionRow"] -unique_name_in_owner = true -custom_minimum_size = Vector2(0, 44) -layout_mode = 2 -size_flags_horizontal = 3 -focus_mode = 2 -focus_neighbor_left = NodePath("../ExportProgression") -focus_neighbor_top = NodePath("../../FolderRow/ChangeDataFolder") -focus_neighbor_bottom = NodePath("../../CopyPlayerFingerprint") -text = "import progression" - [node name="CopyPlayerFingerprint" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/DataPage/DataScroll/DataButtons"] unique_name_in_owner = true custom_minimum_size = Vector2(0, 44) layout_mode = 2 focus_mode = 2 -focus_neighbor_top = NodePath("../ProgressionRow/ExportProgression") +focus_neighbor_top = NodePath("../FolderRow/OpenDataFolder") focus_neighbor_bottom = NodePath("../PlayerIdentityRow/ExportPlayerIdentity") text = "copy player fingerprint" diff --git a/ui/surface_drawing_toolbar.tscn b/ui/surface_drawing_toolbar.tscn index 89e19ac..cc579c9 100644 --- a/ui/surface_drawing_toolbar.tscn +++ b/ui/surface_drawing_toolbar.tscn @@ -112,9 +112,9 @@ expand_icon = true [node name="ExportButton" type="Button" parent="TopPanel/Top"] unique_name_in_owner = true layout_mode = 2 -tooltip_text = "export aimed artwork as PNG" +tooltip_text = "aim at artwork, then click to export PNG" focus_mode = 0 -accessibility_name = "export aimed artwork as PNG" +accessibility_name = "export last aimed artwork as PNG" text = "png" [node name="StampButton" type="Button" parent="TopPanel/Top"] diff --git a/ui/the_net_page.gd b/ui/the_net_page.gd index 504128d..ddf2ce5 100644 --- a/ui/the_net_page.gd +++ b/ui/the_net_page.gd @@ -352,6 +352,9 @@ func _build_job_rows(jobs: Array[Dictionary], empty_text: String) -> void: progress_bar.max_value = float(target) progress_bar.value = float(progress) progress_bar.show_percentage = false + var exact_progress: String = "%d / %d" % [progress, target] + progress_bar.tooltip_text = exact_progress + progress_bar.mouse_filter = Control.MOUSE_FILTER_STOP progress_bar.add_theme_stylebox_override( "background", UtilityPageStyle.rounded_style( UtilityPageStyle.OCEAN_PANEL_DEEP, 9 @@ -372,7 +375,8 @@ func _build_job_rows(jobs: Array[Dictionary], empty_text: String) -> void: _compact_integer(progress), _compact_integer(target), ] - count.tooltip_text = "%d / %d" % [progress, target] + count.tooltip_text = exact_progress + count.mouse_filter = Control.MOUSE_FILTER_STOP count.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER count.vertical_alignment = VERTICAL_ALIGNMENT_CENTER count.add_theme_font_size_override("font_size", 14) @@ -469,6 +473,7 @@ func _claim(claim_id: String) -> void: func _make_reward_display(fish_coin: int, experience: int) -> HBoxContainer: var reward := HBoxContainer.new() reward.tooltip_text = "%d fish coins · %d xp" % [fish_coin, experience] + reward.mouse_filter = Control.MOUSE_FILTER_STOP reward.add_theme_constant_override("separation", 7) var currency: CurrencyAmount = ( CurrencyPresentationType.instantiate_amount(fish_coin, 18.0) @@ -498,6 +503,7 @@ func _make_job_reward_display( var reward := VBoxContainer.new() reward.custom_minimum_size.x = 104.0 reward.tooltip_text = "%d fish coins · %d xp" % [fish_coin, experience] + reward.mouse_filter = Control.MOUSE_FILTER_STOP reward.add_theme_constant_override("separation", 1) var currency: CurrencyAmount = ( CurrencyPresentationType.instantiate_amount(fish_coin, 16.0) diff --git a/ui/title_screen.gd b/ui/title_screen.gd index 242b335..6274f7d 100644 --- a/ui/title_screen.gd +++ b/ui/title_screen.gd @@ -27,6 +27,7 @@ const NetworkSessionType = preload("res://network/network_session.gd") const SavedServerStoreType = preload("res://network/saved_server_store.gd") const JoinGamePageType = preload("res://ui/network/join_game_page.gd") const NewGameSetupPageType = preload("res://ui/new_game_setup_page.gd") +const SaveSlotsPageType = preload("res://ui/save_slots_page.gd") const CurrencyPresentationType = preload( "res://ui/currency_presentation.gd" ) @@ -89,6 +90,13 @@ const QUICK_MENU_ENTER_SCALE: float = 0.97 signal new_game_requested(world_layout: StringName, world_seed: int) signal continue_game_requested +signal slot_play_requested(slot_id: String) +signal new_slot_requested( + display_name: String, + world_layout: StringName, + world_seed: int, + duplicate_source_slot_id: String, +) signal quit_requested signal join_game_requested(endpoint: String) @@ -97,7 +105,7 @@ enum ConfirmationAction { DELETE_SAVE, } -@onready var _continue_button: BubbleButtonType = %ContinueButton +@onready var _play_button: BubbleButtonType = %PlayButton @onready var _new_game_button: BubbleButtonType = %NewGameButton @onready var _settings_button: BubbleButtonType = %SettingsButton @onready var _credits_button: BubbleButtonType = %CreditsButton @@ -134,6 +142,7 @@ enum ConfirmationAction { @onready var _join_game_page: JoinGamePageType = %JoinGamePage @onready var _new_game_setup_page: NewGameSetupPageType = %NewGameSetupPage @onready var _credits_page: TitleCreditsPageType = %CreditsPage +@onready var _save_slots_page: SaveSlotsPageType = %SaveSlotsPage var _save_manager: SaveManagerType var _settings_manager: SettingsManagerType @@ -183,17 +192,17 @@ func _ready() -> void: ).strip_edges() if not release_version.is_empty(): _playtest_label.text = "v%s" % release_version - _continue_button.pressed.connect(_on_continue_pressed) - _continue_button.mouse_entered.connect( + _play_button.pressed.connect(_on_continue_pressed) + _play_button.mouse_entered.connect( _on_continue_stats_hover_changed.bind(true) ) - _continue_button.mouse_exited.connect( + _play_button.mouse_exited.connect( _on_continue_stats_hover_changed.bind(false) ) - _continue_button.focus_entered.connect( + _play_button.focus_entered.connect( _on_continue_stats_focus_changed.bind(true) ) - _continue_button.focus_exited.connect( + _play_button.focus_exited.connect( _on_continue_stats_focus_changed.bind(false) ) _new_game_button.pressed.connect(_on_new_game_pressed) @@ -214,6 +223,9 @@ func _ready() -> void: _emit_navigation_bubble_flurry ) _credits_page.back_requested.connect(_close_credits) + _save_slots_page.back_requested.connect(_close_save_slots) + _save_slots_page.play_requested.connect(_on_slot_play_requested) + _save_slots_page.create_requested.connect(_on_new_slot_requested) _bubble_field.configure(_get_title_buttons()) _bubble_field.motion_scale = 0.0 _decorative_fish_timer.timeout.connect(_on_decorative_fish_timer_timeout) @@ -237,14 +249,18 @@ func _ready() -> void: func setup( save_manager: SaveManagerType, + save_slots: PlayerSaveSlotCatalog, settings_manager: SettingsManagerType, network_session: NetworkSessionType, saved_servers: SavedServerStoreType, server_trust: ServerTrustStore, discovery: DiscoveryClient, + data_root: PlayerDataRoot, + interface_fonts: InterfaceFontController, ) -> void: _save_manager = save_manager _settings_manager = settings_manager + _save_slots_page.setup(save_slots, data_root, interface_fonts) _join_game_page.setup( network_session, saved_servers, false, server_trust, discovery ) @@ -276,6 +292,7 @@ func reopen() -> void: _join_game_page.close_page() _new_game_setup_page.close_page() _credits_page.close_page() + _save_slots_page.close_page() _refresh_save_inspection() show() _start_decorative_presentation() @@ -304,11 +321,15 @@ func open_join_game_page(endpoint: String = "") -> void: _confirmation_page.hide_page() _credits_page.close_page() _new_game_setup_page.close_page() + _save_slots_page.close_page() _join_game_page.open_page(endpoint) func report_network_error(message: String) -> void: _join_game_page.set_status(message) + if _save_slots_page.visible: + _save_slots_page.set_status(message) + return if not _join_game_page.visible: _feedback_label.text = _center_feedback_text(message) _feedback_label.show() @@ -321,6 +342,7 @@ func _open_join_game() -> void: or _is_confirmation_active() or _new_game_setup_page.visible or _credits_page.visible + or _save_slots_page.visible ): return open_join_game_page() @@ -342,6 +364,7 @@ func _open_credits() -> void: or _join_game_page.visible or _new_game_setup_page.visible or _credits_page.visible + or _save_slots_page.visible ): return _hide_continue_stats_context() @@ -469,12 +492,10 @@ func _update_responsive_title_stage() -> void: func _get_title_buttons() -> Array[BubbleButton]: return [ - _continue_button, - _new_game_button, + _play_button, _join_game_button, _settings_button, _credits_button, - _delete_button, _quit_button, ] @@ -583,6 +604,11 @@ func _input(event: InputEvent) -> void: _new_game_setup_page.request_back() get_viewport().set_input_as_handled() return + if _save_slots_page.visible: + if event.is_action_pressed("ui_cancel"): + _save_slots_page.request_back() + get_viewport().set_input_as_handled() + return if ( _title_settings_transition_active or _title_entry_transition_active @@ -631,6 +657,7 @@ func _handle_primary_menu_focus_input(event: InputEvent) -> bool: or _settings_panel.visible or _new_game_setup_page.visible or _credits_page.visible + or _save_slots_page.visible ): return false if event is InputEventMouseMotion: @@ -656,7 +683,7 @@ func _handle_primary_menu_focus_input(event: InputEvent) -> bool: func _get_first_available_menu_button() -> Button: - return _continue_button if not _continue_button.disabled else _new_game_button + return _play_button func _primary_menu_has_focus() -> bool: @@ -868,7 +895,7 @@ func _update_continue_stats_visibility() -> void: ) requested_visible = ( requested_visible - and not _continue_button.disabled + and not _play_button.disabled and _inspection != null and _inspection.status == SaveInspectionType.Status.VALID_SUPPORTED and not _awaiting_start_input @@ -940,14 +967,70 @@ func _on_continue_pressed() -> void: _action_in_progress or _is_confirmation_active() or _settings_panel.visible - or _inspection == null - or not _inspection.can_continue() + or _save_slots_page.visible ): return _hide_continue_stats_context() + _modal_restore_navigation_focus = _navigation_focus_active + _set_title_bubbles_interactive(false) + _release_primary_menu_focus() + _presentation_center.hide() + _start_prompt_center.hide() + _join_game_page.close_page() + _credits_page.close_page() + _new_game_setup_page.close_page() + _save_slots_page.open_page() + + +func _close_save_slots() -> void: + _save_slots_page.close_page() + _presentation_center.show() + _button_center.show() + _start_prompt_center.hide() + _set_title_bubbles_interactive(true) + var restore_navigation_focus: bool = _modal_restore_navigation_focus + _modal_restore_navigation_focus = false + if restore_navigation_focus: + _navigation_focus_active = true + _play_button.grab_focus() + else: + _navigation_focus_active = false + _release_title_focus() + _update_continue_stats_visibility() + + +func _on_slot_play_requested(slot_id: String) -> void: + if _action_in_progress or not _save_slots_page.visible: + return _action_in_progress = true - continue_game_requested.emit() + slot_play_requested.emit(slot_id) _action_in_progress = false + if visible: + _save_slots_page.finish_request_if_pending( + "the save slot could not be started." + ) + + +func _on_new_slot_requested( + display_name: String, + world_layout: StringName, + world_seed: int, + duplicate_source_slot_id: String, +) -> void: + if _action_in_progress or not _save_slots_page.visible: + return + _action_in_progress = true + new_slot_requested.emit( + display_name, + world_layout, + world_seed, + duplicate_source_slot_id, + ) + _action_in_progress = false + if visible: + _save_slots_page.finish_request_if_pending( + "the save slot could not be created." + ) func _on_new_game_pressed() -> void: @@ -1252,6 +1335,7 @@ func _open_settings() -> void: or _action_in_progress or _settings_panel.visible or _credits_page.visible + or _save_slots_page.visible or _title_settings_transition_active ): return @@ -1467,12 +1551,12 @@ func _restore_settings_focus() -> void: func _refresh_save_inspection() -> void: _inspection = _save_manager.inspect_save() - _continue_button.disabled = not _inspection.can_continue() + _play_button.disabled = false _delete_button.disabled = not _inspection.can_delete() _feedback_label.text = _center_feedback_text(_inspection.message) if _inspection.status == SaveInspectionType.Status.VALID_SUPPORTED: _feedback_label.text = _get_continue_stats_text() - if _continue_button.disabled: + if not _inspection.can_continue(): _hide_continue_stats_context() else: _update_continue_stats_visibility() @@ -1485,12 +1569,10 @@ func _focus_initial_button() -> void: or _awaiting_start_input or not _button_center.visible or _credits_page.visible + or _save_slots_page.visible ): return - if not _continue_button.disabled: - _continue_button.grab_focus() - else: - _new_game_button.grab_focus() + _play_button.grab_focus() _navigation_focus_active = true _update_continue_stats_visibility() diff --git a/ui/title_screen.tscn b/ui/title_screen.tscn index e0838ed..e969f66 100644 --- a/ui/title_screen.tscn +++ b/ui/title_screen.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=24 format=3] +[gd_scene load_steps=25 format=3] [ext_resource type="Script" path="res://ui/title_screen.gd" id="1_script"] [ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"] @@ -20,6 +20,7 @@ [ext_resource type="Texture2D" path="res://ui/icons/main_menu/credits.png" id="18_credits"] [ext_resource type="Texture2D" path="res://ui/icons/main_menu/delete_save.png" id="19_delete_save"] [ext_resource type="PackedScene" path="res://ui/new_game_setup_page.tscn" id="20_new_game_setup"] +[ext_resource type="PackedScene" path="res://ui/save_slots_page.tscn" id="21_save_slots"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_title_water"] shader = ExtResource("4_water_shader") @@ -260,21 +261,21 @@ offset_bottom = 318.0 script = ExtResource("8_bubble_cluster") profile = ExtResource("9_bubble_profile") -[node name="ContinueButton" type="Button" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField"] +[node name="PlayButton" type="Button" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField"] unique_name_in_owner = true -offset_left = 123.0 -offset_top = 86.0 -offset_right = 307.0 -offset_bottom = 264.0 +offset_left = 106.0 +offset_top = 71.0 +offset_right = 290.0 +offset_bottom = 249.0 texture_filter = 1 icon = ExtResource("16_continue") -tooltip_text = "continue" -accessibility_name = "continue" +tooltip_text = "play" +accessibility_name = "play" script = ExtResource("7_bubble_button") profile = ExtResource("9_bubble_profile") neutral_size = Vector2(184, 178) -desktop_anchor = Vector2(215, 175) -compact_anchor = Vector2(215, 175) +desktop_anchor = Vector2(198, 160) +compact_anchor = Vector2(198, 164) minimum_font_size = 19 maximum_font_size = 30 horizontal_amplitude = 1.8 @@ -285,6 +286,8 @@ deformation_period = 6.7 [node name="NewGameButton" type="Button" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField"] unique_name_in_owner = true +visible = false +focus_mode = 0 offset_left = 42.0 offset_top = 4.0 offset_right = 170.0 @@ -310,18 +313,18 @@ deformation_period = 5.9 [node name="SettingsButton" type="Button" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField"] unique_name_in_owner = true -offset_left = 264.0 -offset_top = 17.0 -offset_right = 384.0 -offset_bottom = 133.0 +offset_left = 254.0 +offset_top = 12.0 +offset_right = 374.0 +offset_bottom = 128.0 texture_filter = 1 icon = ExtResource("13_settings_dark") tooltip_text = "settings" accessibility_name = "settings" script = ExtResource("7_bubble_button") profile = ExtResource("9_bubble_profile") -desktop_anchor = Vector2(324, 75) -compact_anchor = Vector2(324, 68) +desktop_anchor = Vector2(314, 70) +compact_anchor = Vector2(333, 75) compact_minimum_size = Vector2(94, 90) minimum_font_size = 15 maximum_font_size = 22 @@ -332,10 +335,10 @@ deformation_period = 6.3 [node name="CreditsButton" type="Button" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField"] unique_name_in_owner = true -offset_left = 330.0 -offset_top = 125.0 -offset_right = 414.0 -offset_bottom = 207.0 +offset_left = 54.0 +offset_top = 209.0 +offset_right = 138.0 +offset_bottom = 291.0 texture_filter = 1 icon = ExtResource("18_credits") tooltip_text = "credits" @@ -343,8 +346,8 @@ accessibility_name = "credits" script = ExtResource("7_bubble_button") profile = ExtResource("9_bubble_profile") neutral_size = Vector2(84, 82) -desktop_anchor = Vector2(372, 166) -compact_anchor = Vector2(372, 166) +desktop_anchor = Vector2(96, 250) +compact_anchor = Vector2(82, 258) compact_minimum_size = Vector2(62, 62) minimum_font_size = 12 maximum_font_size = 17 @@ -357,10 +360,10 @@ deformation_period = 5.6 [node name="JoinGameButton" type="Button" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField"] unique_name_in_owner = true -offset_left = 126.0 -offset_top = 224.0 -offset_right = 248.0 -offset_bottom = 342.0 +offset_left = 21.0 +offset_top = 11.0 +offset_right = 143.0 +offset_bottom = 129.0 texture_filter = 1 icon = ExtResource("12_online_dark") tooltip_text = "join game" @@ -368,8 +371,8 @@ accessibility_name = "join game" script = ExtResource("7_bubble_button") profile = ExtResource("9_bubble_profile") neutral_size = Vector2(122, 118) -desktop_anchor = Vector2(187, 268) -compact_anchor = Vector2(187, 268) +desktop_anchor = Vector2(82, 70) +compact_anchor = Vector2(63, 75) compact_minimum_size = Vector2(82, 80) minimum_font_size = 15 maximum_font_size = 22 @@ -382,6 +385,8 @@ deformation_period = 5.7 [node name="DeleteSaveButton" type="Button" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField"] unique_name_in_owner = true +visible = false +focus_mode = 0 offset_left = -11.0 offset_top = 122.0 offset_right = 123.0 @@ -406,10 +411,10 @@ deformation_period = 5.4 [node name="QuitButton" type="Button" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField"] unique_name_in_owner = true -offset_left = 275.0 -offset_top = 217.0 -offset_right = 369.0 -offset_bottom = 309.0 +offset_left = 255.0 +offset_top = 204.0 +offset_right = 349.0 +offset_bottom = 296.0 texture_filter = 1 icon = ExtResource("14_x_dark") tooltip_text = "quit" @@ -417,8 +422,8 @@ accessibility_name = "quit" script = ExtResource("7_bubble_button") profile = ExtResource("9_bubble_profile") neutral_size = Vector2(94, 92) -desktop_anchor = Vector2(322, 263) -compact_anchor = Vector2(322, 263) +desktop_anchor = Vector2(302, 250) +compact_anchor = Vector2(317, 254) compact_minimum_size = Vector2(68, 68) minimum_font_size = 14 maximum_font_size = 18 @@ -497,3 +502,13 @@ anchor_right = 1.0 anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 + +[node name="SaveSlotsPage" parent="ResponsiveTitleStage/TitlePresentationScaleRoot" instance=ExtResource("21_save_slots")] +unique_name_in_owner = true +z_index = 220 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 diff --git a/ui/ui_pixelation_presenter.gd b/ui/ui_pixelation_presenter.gd index 9123a7c..476b5b5 100644 --- a/ui/ui_pixelation_presenter.gd +++ b/ui/ui_pixelation_presenter.gd @@ -46,6 +46,7 @@ func _ready() -> void: _game_ui.set_controller_text_entry_request( Callable(_on_screen_keyboard, "request_for_control"), Callable(_on_screen_keyboard, "is_open"), + Callable(_on_screen_keyboard, "close_for_control"), ) var controller_focus_presentation := ControllerFocusPresentationType.new() _ui_root.add_child(controller_focus_presentation) diff --git a/world/generation/generated_world_region.gd b/world/generation/generated_world_region.gd index 95cf6d3..5adf173 100644 --- a/world/generation/generated_world_region.gd +++ b/world/generation/generated_world_region.gd @@ -79,8 +79,6 @@ func _ready() -> void: _validate_biome_catalog() _configure_static_water() _build_shoreline_reference() - if not generate_world(initial_seed): - push_error("The initial generated world could not be built.") func generate_world(seed: int) -> bool: @@ -100,6 +98,10 @@ func get_generation_seed() -> int: return _current_seed +func is_world_generated() -> bool: + return is_instance_valid(_generator.get_generated_chunks_root()) + + func get_playable_half_extents() -> Vector2: var size := Vector2( float(_generator.grid_size.x), diff --git a/world/generation/terrain_chunk_generator.gd b/world/generation/terrain_chunk_generator.gd index 1098caf..ee79821 100644 --- a/world/generation/terrain_chunk_generator.gd +++ b/world/generation/terrain_chunk_generator.gd @@ -25,6 +25,7 @@ const MAX_PACKED_SOLVER_VARIANTS := 62 @export var generation_seed := 13001 @export var generate_on_ready := true @export var build_collision := true +@export_range(1, 16, 1) var collision_batch_size := 4 @export var show_chunk_labels := false @export var force_center_chunk_id: StringName = &"chunk_0000" @export var required_chunk_ids := PackedStringArray() @@ -3928,9 +3929,102 @@ func _build_solution_root() -> Node3D: if not _add_stacked_elevated_chunks(solution_root): solution_root.free() return null + if build_collision and collision_batch_size > 1: + _batch_generated_terrain_collision(solution_root) return solution_root +func _batch_generated_terrain_collision(solution_root: Node3D) -> void: + var shapes_by_batch: Dictionary[Vector2i, Array] = {} + var host_by_batch: Dictionary[Vector2i, Node3D] = {} + var bodies_to_remove: Array[StaticBody3D] = [] + for chunk_node: Node in solution_root.get_children(): + var chunk_root := chunk_node as Node3D + if chunk_root == null: + continue + var coordinate: Vector2i = chunk_root.get_meta( + &"terrain_chunk_coordinate", + Vector2i.ZERO, + ) + var batch_coordinate := Vector2i( + floori(float(coordinate.x) / float(collision_batch_size)), + floori(float(coordinate.y) / float(collision_batch_size)), + ) + if not host_by_batch.has(batch_coordinate): + host_by_batch[batch_coordinate] = chunk_root + var batch_shapes: Array = shapes_by_batch.get( + batch_coordinate, + [], + ) + for value: Node in chunk_root.find_children( + "TerrainShape", + "CollisionShape3D", + true, + false, + ): + var collision_shape := value as CollisionShape3D + if collision_shape == null: + continue + var collision_body := collision_shape.get_parent() as StaticBody3D + if collision_shape.shape == null or collision_body == null: + continue + var shape_to_solution := _transform_to_ancestor( + collision_shape, + solution_root, + ) + batch_shapes.append({ + "shape": collision_shape.shape, + "transform": shape_to_solution, + }) + if collision_body not in bodies_to_remove: + bodies_to_remove.append(collision_body) + shapes_by_batch[batch_coordinate] = batch_shapes + while not bodies_to_remove.is_empty(): + var collision_body: StaticBody3D = bodies_to_remove.pop_back() + collision_body.free() + for batch_coordinate: Vector2i in shapes_by_batch: + var batch_shapes: Array = shapes_by_batch[batch_coordinate] + if batch_shapes.is_empty(): + continue + var host_root: Node3D = host_by_batch.get(batch_coordinate) + if host_root == null: + continue + var body := StaticBody3D.new() + body.name = "TerrainCollisionBatch_%d_%d" % [ + batch_coordinate.x, + batch_coordinate.y, + ] + body.collision_layer = 1 + body.collision_mask = 0 + body.set_meta(&"terrain_collision_batch", batch_coordinate) + host_root.add_child(body) + var solution_to_body: Transform3D = host_root.transform.affine_inverse() + for shape_index: int in batch_shapes.size(): + var record: Dictionary = batch_shapes[shape_index] + var collision := CollisionShape3D.new() + collision.name = "TerrainShape_%d" % shape_index + collision.shape = record.get("shape") as Shape3D + collision.transform = ( + solution_to_body + * (record.get("transform", Transform3D.IDENTITY) as Transform3D) + ) + body.add_child(collision) + + +static func _transform_to_ancestor( + node: Node3D, + ancestor: Node3D, +) -> Transform3D: + var result := Transform3D.IDENTITY + var current: Node3D = node + while current != ancestor: + result = current.transform * result + current = current.get_parent() as Node3D + if current == null: + return Transform3D.IDENTITY + return result + + func _add_stacked_elevated_chunks(solution_root: Node3D) -> bool: for index: int in _stacked_elevated_placements.size(): var record := _stacked_elevated_placements[index] diff --git a/world/player_water_trigger.gd b/world/player_water_trigger.gd index 93096dd..10fab14 100644 --- a/world/player_water_trigger.gd +++ b/world/player_water_trigger.gd @@ -40,6 +40,10 @@ func _ready() -> void: return body_entered.connect(_on_body_entered) body_exited.connect(_on_body_exited) + # Generated worlds can contain many independent water volumes. Keep an + # empty trigger completely asleep instead of polling an empty array on every + # physics tick. + set_physics_process(false) func _physics_process(_delta: float) -> void: @@ -57,6 +61,8 @@ func _physics_process(_delta: float) -> void: if entry_height <= active_surface_height - entry_depth_threshold: _triggered_players[player_key] = true recovery_requested.emit(player, active_surface_height) + if _tracked_players.is_empty(): + set_physics_process(false) func get_surface_height() -> float: @@ -80,6 +86,7 @@ func _on_body_entered(body: Node3D) -> void: if player == null or player in _tracked_players: return _tracked_players.append(player) + set_physics_process(true) func _on_body_exited(body: Node3D) -> void: @@ -88,3 +95,5 @@ func _on_body_exited(body: Node3D) -> void: return _tracked_players.erase(player) _triggered_players.erase(StringName(str(player.get_instance_id()))) + if _tracked_players.is_empty(): + set_physics_process(false) diff --git a/world/test_world.gd b/world/test_world.gd index af75018..d5ac83c 100644 --- a/world/test_world.gd +++ b/world/test_world.gd @@ -30,6 +30,7 @@ const WORLD_BOUNDARY_SHORELINE_CLEARANCE := 18.0 var _world_layout: StringName = WorldLayoutType.GENERATED var _world_seed: int = PlayerSaveManager.DEFAULT_WORLD_SEED var _light_performance_profile: bool = false +var _dedicated_simulation: bool = false func get_player_water_triggers() -> Array[PlayerWaterTrigger]: @@ -74,6 +75,62 @@ func set_light_performance_profile(enabled: bool) -> void: _active_region.set_light_performance_profile(enabled) +func set_dedicated_simulation(enabled: bool) -> void: + _dedicated_simulation = enabled + if _active_region != null: + _set_region_presentation_enabled(_active_region, not enabled) + _world_environment.process_mode = ( + Node.PROCESS_MODE_DISABLED if enabled else Node.PROCESS_MODE_INHERIT + ) + _sun.visible = not enabled + _sun.process_mode = ( + Node.PROCESS_MODE_DISABLED if enabled else Node.PROCESS_MODE_INHERIT + ) + + +func _set_region_presentation_enabled( + region: WorldRegion, + enabled: bool, +) -> void: + for class_name_value: String in [ + "WaterSurfaceMotion", + "LocalStormCloudLayer", + "WorldCharacterDisplay", + ]: + for value: Node in region.find_children( + "*", class_name_value, true, false + ): + value.process_mode = ( + Node.PROCESS_MODE_INHERIT + if enabled + else Node.PROCESS_MODE_DISABLED + ) + for value: Node in region.find_children("*", "GeometryInstance3D", true, false): + (value as GeometryInstance3D).visible = enabled + for value: Node in region.find_children("*", "GPUParticles3D", true, false): + var particles := value as GPUParticles3D + particles.emitting = enabled + particles.process_mode = ( + Node.PROCESS_MODE_INHERIT if enabled else Node.PROCESS_MODE_DISABLED + ) + for value: Node in region.find_children("*", "AnimationPlayer", true, false): + var animation_player := value as AnimationPlayer + animation_player.active = enabled + animation_player.process_mode = ( + Node.PROCESS_MODE_INHERIT if enabled else Node.PROCESS_MODE_DISABLED + ) + for class_name_value: String in ["AudioStreamPlayer", "AudioStreamPlayer3D"]: + for value: Node in region.find_children( + "*", class_name_value, true, false + ): + value.call("stop") + value.process_mode = ( + Node.PROCESS_MODE_INHERIT + if enabled + else Node.PROCESS_MODE_DISABLED + ) + + func get_fishable_water_regions() -> Array[FishableWaterRegion]: return ( _active_region.get_fishable_water_regions() @@ -129,6 +186,17 @@ func get_generation_seed() -> int: return _world_seed +func is_world_ready() -> bool: + if _active_region == null: + return false + if _world_layout != WorldLayoutType.GENERATED: + return true + return ( + _active_region.has_method(&"is_world_generated") + and bool(_active_region.call(&"is_world_generated")) + ) + + func get_diggable_area_triangles( area_id: StringName, ) -> Array[PackedVector3Array]: @@ -182,6 +250,8 @@ func _replace_active_region(layout: StringName, seed: int) -> bool: _active_region = replacement _regions_root.add_child(_active_region) _active_region.set_light_performance_profile(_light_performance_profile) + if _dedicated_simulation: + _set_region_presentation_enabled(_active_region, false) return true