From 5c3eba1ad22e0fdbb9bc15f131608fb74099dcb9 Mon Sep 17 00:00:00 2001 From: woofmeow Date: Sun, 23 Aug 2026 05:22:29 +0000 Subject: [PATCH 01/44] Update README.md added links --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index ac3635b..50ac991 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,15 @@ save migrations may continue to change before a stable release. NETfishing is developed and published by **Woofmeow**, the independent team of co-owners publicly credited as Voyager and Endeavour. +## Links +Patreon: https://www.patreon.com/cw/woofmeowgames +Steam: https://store.steampowered.com/app/5068950/NETfishing/ +Itch.io: https://sol6-vi.itch.io/netfishing +Discord: https://discord.gg/5gP22447kc +Matrix: https://matrix.to/#/#netfishing:matrix.makearmy.io +Repo: https://forge.makearmy.io/woofmeow/netfishing +Releases: https://forge.makearmy.io/woofmeow/netfishing/releases + ## Installing release builds Download and extract the archive for your platform from the official release From 2aeaf1d44d6ea1afbccb28f1749b5f86836b1310 Mon Sep 17 00:00:00 2001 From: Voyager Date: Sun, 23 Aug 2026 01:23:00 -0400 Subject: [PATCH 02/44] fix: synchronize late-joining players --- network/network_chat_service.gd | 50 ++++++- network/network_profile_service.gd | 63 ++++---- network/network_session.gd | 95 +++++++++--- scripts/run_validations.sh | 74 +++++++++- tests/late_join_multiplayer_validation.gd | 137 ++++++++++++++++++ tests/late_join_multiplayer_validation.gd.uid | 1 + 6 files changed, 371 insertions(+), 49 deletions(-) create mode 100644 tests/late_join_multiplayer_validation.gd create mode 100644 tests/late_join_multiplayer_validation.gd.uid diff --git a/network/network_chat_service.gd b/network/network_chat_service.gd index b24d558..55ba768 100644 --- a/network/network_chat_service.gd +++ b/network/network_chat_service.gd @@ -5,6 +5,7 @@ const BURST_COUNT: int = 3 const WINDOW_COUNT: int = 5 const WINDOW_SECONDS: float = 10.0 const CALL_COOLDOWN_MILLISECONDS: int = 90 +const MAX_PENDING_MESSAGES_PER_PEER: int = 8 const DEDICATED_CHAT_WARNING: String = ( "Privacy notice: This dedicated server records and retains chat messages." ) @@ -40,6 +41,7 @@ var _last_call_msec: Dictionary[int, int] = {} var _call_variant_indices: Dictionary[int, int] = {} var _sequence: int = 0 var _peer_names: Dictionary[int, String] = {} +var _pending_peer_messages: Dictionary[int, Array] = {} var _relationships: PlayerRelationshipStore var _dedicated_history_enabled: bool = false var _chat_log_path: String = "" @@ -418,9 +420,11 @@ func _apply_message( else: var sender_id := int(data["sender_peer_id"]) var record := _session.get_peer_record(sender_id) + if record == null: + _queue_pending_peer_message(sender_id, data, emit_live_signals) + return valid_signature = ( - record != null - and record.identity_fingerprint == str(data["sender_fingerprint"]) + record.identity_fingerprint == str(data["sender_fingerprint"]) and _session.verify_peer_action( sender_id, "chat_send", @@ -466,7 +470,47 @@ func receive_chat_rejection(message: String) -> void: send_rejected.emit(message.left(80)) +func _queue_pending_peer_message( + peer_id: int, + message: Dictionary, + emit_live_signals: bool, +) -> void: + if peer_id <= 0: + return + var pending: Array = _pending_peer_messages.get(peer_id, []) + var message_id := str(message.get("message_id", "")) + for value: Variant in pending: + if ( + typeof(value) == TYPE_DICTIONARY + and str(value.get("message", {}).get("message_id", "")) == message_id + ): + return + pending.append({ + "message": message.duplicate(true), + "emit_live_signals": emit_live_signals, + }) + while pending.size() > MAX_PENDING_MESSAGES_PER_PEER: + pending.pop_front() + _pending_peer_messages[peer_id] = pending + + +func _flush_pending_peer_messages(peer_id: int) -> void: + var pending: Array = _pending_peer_messages.get(peer_id, []) + _pending_peer_messages.erase(peer_id) + for value: Variant in pending: + if typeof(value) != TYPE_DICTIONARY: + continue + var queued: Dictionary = value + if typeof(queued.get("message")) != TYPE_DICTIONARY: + continue + _apply_message( + queued["message"], + bool(queued.get("emit_live_signals", true)), + ) + + func _on_peer_authenticated(peer_id: int, display_name: String) -> void: + _flush_pending_peer_messages(peer_id) if not _session.is_host(): return _peer_names[peer_id] = display_name @@ -500,6 +544,7 @@ func _on_peer_removed(peer_id: int) -> void: _rate_times.erase(peer_id) _last_call_msec.erase(peer_id) _call_variant_indices.erase(peer_id) + _pending_peer_messages.erase(peer_id) if not _session.is_host(): return var display_name: String = _peer_names.get(peer_id, "Player") @@ -601,6 +646,7 @@ func _on_session_state_changed(state: NetworkSession.State) -> void: _last_call_msec.clear() _call_variant_indices.clear() _peer_names.clear() + _pending_peer_messages.clear() _sequence = 0 history_replaced.emit([]) diff --git a/network/network_profile_service.gd b/network/network_profile_service.gd index da26d86..e4732a9 100644 --- a/network/network_profile_service.gd +++ b/network/network_profile_service.gd @@ -97,6 +97,10 @@ func preview_appearance(appearance: Dictionary) -> bool: _preferences.display_name, snapshot, ) + var authorization := _make_appearance_preview_authorization(snapshot) + var local_record := _session.get_peer_record(local_peer_id) + if local_record != null: + local_record.profile_authorization = authorization.duplicate(true) if _session.is_host(): _broadcast_appearance_preview_to_supported_peers( local_peer_id, @@ -105,20 +109,27 @@ func preview_appearance(appearance: Dictionary) -> bool: elif _session.supports_server_capability( NetworkProtocol.APPEARANCE_PREVIEW_CAPABILITY ): - var request: Dictionary = { - "request_id": _new_id(), - "session_id": _session.get_session_id(), - "appearance": snapshot.duplicate(true), - "sender_fingerprint": _session.get_local_identity_fingerprint(), - } - request["sender_signature"] = _session.sign_local_action( - "appearance_preview", - NetworkProfileProtocol.appearance_preview_signature_fields(request), - ) - submit_appearance_preview.rpc_id(1, request) + submit_appearance_preview.rpc_id(1, authorization) return true +func _make_appearance_preview_authorization( + appearance: Dictionary, +) -> Dictionary: + var request: Dictionary = { + "domain": "appearance_preview", + "request_id": _new_id(), + "session_id": _session.get_session_id(), + "appearance": appearance.duplicate(true), + "sender_fingerprint": _session.get_local_identity_fingerprint(), + } + request["sender_signature"] = _session.sign_local_action( + "appearance_preview", + NetworkProfileProtocol.appearance_preview_signature_fields(request), + ) + return request + + func restore_persisted_appearance() -> void: preview_appearance(_appearance_store.get_snapshot()) @@ -274,6 +285,9 @@ func submit_appearance_preview(data: Dictionary) -> void: var appearance := CharacterCustomizationCatalog.sanitized_snapshot( data["appearance"] ) + var authorization := data.duplicate(true) + authorization["domain"] = "appearance_preview" + record.profile_authorization = authorization _session.apply_canonical_profile( sender_id, record.display_name, @@ -482,23 +496,16 @@ func broadcast_profile_snapshot( or not CharacterCustomizationCatalog.validate_snapshot(appearance) ): return - if not authorization.is_empty(): - var signed := authorization.duplicate(true) - signed["display_name"] = display_name - signed["appearance"] = appearance - var record := _session.get_peer_record(peer_id) - if ( - record == null - or record.identity_fingerprint - != str(signed.get("sender_fingerprint", "")) - or not _session.verify_peer_action( - peer_id, - "profile_update", - NetworkProfileProtocol.signature_fields(signed), - signed.get("sender_signature", PackedByteArray()), - ) - ): - return + if ( + not authorization.is_empty() + and not _session.verify_profile_authorization( + peer_id, + display_name, + appearance, + authorization, + ) + ): + return _session.apply_canonical_profile(peer_id, display_name, appearance) _apply_to_avatar(peer_id, appearance) profile_snapshot_changed.emit(peer_id, display_name, appearance.duplicate(true)) diff --git a/network/network_session.gd b/network/network_session.gd index 21d400c..4d38629 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -1559,8 +1559,9 @@ func _apply_spawn_entry(entry: Dictionary) -> void: own_snapshot["visual_yaw"] = float(entry["yaw"]) own_avatar.apply_network_teleport(own_snapshot) return + var peer_was_added: bool = false if not _registry.has_peer(peer_id): - _registry.add_peer( + if not _registry.add_peer( peer_id, entry["profile_id"], entry["display_name"], @@ -1568,8 +1569,14 @@ func _apply_spawn_entry(entry: Dictionary) -> void: str(entry.get("identity_fingerprint", "")), str(entry.get("identity_public_key", "")), _sanitized_capabilities(entry.get("capability_flags", [])), - ) + ): + return + peer_was_added = true var added_record := _registry.get_peer(peer_id) + if added_record != null: + added_record.profile_authorization = Dictionary( + entry.get("profile_authorization", {}) + ).duplicate(true) if added_record != null and added_record.identity_authenticated: _archive_authenticated_identity(added_record) var status := _known_players.observe( @@ -1596,6 +1603,8 @@ 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) + if peer_was_added and record != null: + peer_authenticated.emit(peer_id, record.display_name) func _build_spawn_list() -> Array[Dictionary]: @@ -1695,25 +1704,62 @@ func _update_local_operator() -> void: _local_operator = bool(_operator_peer_ids.get(local_peer_id, false)) -func _verify_spawn_identity(entry: Dictionary) -> bool: - var fingerprint := str(entry.get("identity_fingerprint", "")) - var public_pem := NetworkIdentityCrypto.normalize_public_pem( - str(entry.get("identity_public_key", "")) - ) - if ( - NetworkIdentityCrypto.fingerprint_public_pem(public_pem) != fingerprint - or typeof(entry.get("profile_authorization")) != TYPE_DICTIONARY - ): +func verify_profile_authorization( + peer_id: int, + display_name: String, + appearance: Dictionary, + authorization: Dictionary, +) -> bool: + var record := _registry.get_peer(peer_id) + if record == null: + return false + return _verify_profile_authorization_fields( + peer_id, + record.profile_id, + display_name, + appearance, + record.identity_fingerprint, + record.identity_public_key, + authorization, + ) + + +func _verify_spawn_identity(entry: Dictionary) -> bool: + if typeof(entry.get("profile_authorization")) != TYPE_DICTIONARY: + return false + return _verify_profile_authorization_fields( + int(entry.get("peer_id", 0)), + str(entry.get("profile_id", "")), + str(entry.get("display_name", "")), + Dictionary(entry.get("appearance", {})), + str(entry.get("identity_fingerprint", "")), + str(entry.get("identity_public_key", "")), + Dictionary(entry.get("profile_authorization", {})), + ) + + +func _verify_profile_authorization_fields( + peer_id: int, + profile_id: String, + display_name: String, + appearance: Dictionary, + fingerprint: String, + identity_public_key: String, + authorization: Dictionary, +) -> bool: + var public_pem := NetworkIdentityCrypto.normalize_public_pem( + identity_public_key + ) + if NetworkIdentityCrypto.fingerprint_public_pem(public_pem) != fingerprint: return false - var authorization: Dictionary = entry["profile_authorization"] if authorization.is_empty(): - return int(entry.get("peer_id", 0)) == 1 + return peer_id == 1 if authorization.get("domain") == "handshake_client_profile": var hello := NetworkProtocol.make_client_hello( - str(entry.get("profile_id", "")), - str(entry.get("display_name", "")), + profile_id, + display_name, str(authorization.get("client_nonce", "")), - Dictionary(entry.get("appearance", {})), + appearance, fingerprint, authorization.get("signature", PackedByteArray()), ) @@ -1723,10 +1769,23 @@ func _verify_spawn_identity(entry: Dictionary) -> bool: NetworkProtocol.client_profile_fields(hello), hello["identity_signature"], ) + if authorization.get("domain") == "appearance_preview": + if str(authorization.get("sender_fingerprint", "")) != fingerprint: + return false + var preview := authorization.duplicate(true) + preview["appearance"] = appearance + return NetworkIdentityCrypto.verify_fields( + NetworkIdentityCrypto.load_public_key(public_pem), + "appearance_preview", + NetworkProfileProtocol.appearance_preview_signature_fields(preview), + preview.get("sender_signature", PackedByteArray()), + ) if authorization.has("sender_signature"): + if str(authorization.get("sender_fingerprint", "")) != fingerprint: + return false var signed := authorization.duplicate(true) - signed["display_name"] = entry.get("display_name", "") - signed["appearance"] = entry.get("appearance", {}) + signed["display_name"] = display_name + signed["appearance"] = appearance return NetworkIdentityCrypto.verify_fields( NetworkIdentityCrypto.load_public_key(public_pem), "profile_update", diff --git a/scripts/run_validations.sh b/scripts/run_validations.sh index 64fbfc3..1368afc 100755 --- a/scripts/run_validations.sh +++ b/scripts/run_validations.sh @@ -96,6 +96,7 @@ readonly -a NETWORK_TESTS=( ) readonly SESSION_SWITCH_NETWORK_TEST="tests/session_switch_multiplayer_validation.gd" +readonly LATE_JOIN_NETWORK_TEST="tests/late_join_multiplayer_validation.gd" cleanup() { rm -rf -- "${RUN_ROOT}" @@ -104,7 +105,7 @@ trap cleanup EXIT INT TERM usage() { printf \ - 'Usage: %s {quick|full|host|network|session-switch|all|--list}\n' \ + 'Usage: %s {quick|full|host|network|session-switch|late-join|all|--list}\n' \ "$0" } @@ -290,6 +291,71 @@ run_session_switch_network_test() { fi } + +run_late_join_network_test() { + local script="${LATE_JOIN_NETWORK_TEST}" + local name="${script#tests/}" + name="${name%.gd}" + local host_root first_root late_root + local host_pid first_pid late_pid host_status first_status late_status + host_root="$(prepare_root "${name}-host")" + first_root="$(prepare_root "${name}-first")" + late_root="$(prepare_root "${name}-late")" + printf '\n==> %s (host + first client + late client)\n' "${script}" + XDG_DATA_HOME="${host_root}/data" \ + XDG_CONFIG_HOME="${host_root}/config" \ + XDG_CACHE_HOME="${host_root}/cache" \ + timeout "${TEST_TIMEOUT_SECONDS}s" "${GODOT_BIN}" \ + --headless --path "${PROJECT_ROOT}" --script "${script}" -- host \ + >"${host_root}/output.log" 2>&1 & + host_pid=$! + if ! wait_for_network_host "${script}" "${host_pid}"; then + set +e + wait "${host_pid}" + host_status=$? + set -e + printf '%s\n' '-- host output --' + sed -n '1,240p' "${host_root}/output.log" + printf 'error: host did not become ready (exit %d)\n' \ + "${host_status}" >&2 + return 1 + fi + XDG_DATA_HOME="${first_root}/data" \ + XDG_CONFIG_HOME="${first_root}/config" \ + XDG_CACHE_HOME="${first_root}/cache" \ + timeout "${TEST_TIMEOUT_SECONDS}s" "${GODOT_BIN}" \ + --headless --path "${PROJECT_ROOT}" --script "${script}" -- first \ + >"${first_root}/output.log" 2>&1 & + first_pid=$! + sleep 4 + XDG_DATA_HOME="${late_root}/data" \ + XDG_CONFIG_HOME="${late_root}/config" \ + XDG_CACHE_HOME="${late_root}/cache" \ + timeout "${TEST_TIMEOUT_SECONDS}s" "${GODOT_BIN}" \ + --headless --path "${PROJECT_ROOT}" --script "${script}" -- late \ + >"${late_root}/output.log" 2>&1 & + late_pid=$! + set +e + wait "${late_pid}" + late_status=$? + wait "${first_pid}" + first_status=$? + wait "${host_pid}" + host_status=$? + set -e + printf '%s\n' '-- host output --' + sed -n '1,240p' "${host_root}/output.log" + printf '%s\n' '-- first client output --' + sed -n '1,240p' "${first_root}/output.log" + printf '%s\n' '-- late client output --' + sed -n '1,240p' "${late_root}/output.log" + if ((host_status != 0 || first_status != 0 || late_status != 0)); then + printf 'error: host exited %d; clients exited %d/%d\n' \ + "${host_status}" "${first_status}" "${late_status}" >&2 + return 1 + fi +} + run_quick() { local test_script for test_script in "${QUICK_TESTS[@]}"; do @@ -317,6 +383,7 @@ run_network() { for test_script in "${NETWORK_TESTS[@]}"; do run_network_test "${test_script}" done + run_late_join_network_test run_session_switch_network_test } @@ -331,6 +398,8 @@ list_tests() { printf ' %s\n' "${NETWORK_TESTS[@]}" printf '%s\n' 'Two-host session-switch network test:' printf ' %s\n' "${SESSION_SWITCH_NETWORK_TEST}" + printf '%s\n' 'Three-peer late-join network test:' + printf ' %s\n' "${LATE_JOIN_NETWORK_TEST}" } case "${1:-}" in @@ -349,6 +418,9 @@ case "${1:-}" in session-switch) run_session_switch_network_test ;; + late-join) + run_late_join_network_test + ;; all) run_full run_host diff --git a/tests/late_join_multiplayer_validation.gd b/tests/late_join_multiplayer_validation.gd new file mode 100644 index 0000000..f726dbc --- /dev/null +++ b/tests/late_join_multiplayer_validation.gd @@ -0,0 +1,137 @@ +extends SceneTree + +const MainScene: PackedScene = preload("res://main/main.tscn") +const TEST_PORT: int = 18194 +const LATE_MESSAGE: String = "late join visibility check" + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var arguments: PackedStringArray = OS.get_cmdline_user_args() + if arguments.has("host"): + await _run_host() + elif arguments.has("first"): + await _run_first_client() + elif arguments.has("late"): + await _run_late_client() + else: + push_error("Late-join validation needs host, first, or late mode.") + quit(1) + + +func _run_host() -> void: + var main: Node = await _create_initialized_main() + var session := main.get_node("%NetworkSession") as NetworkSession + var save_manager := main.get("_save_manager") as PlayerSaveManager + assert(session.start_private_host(TEST_PORT)) + assert(save_manager.initialize_new_game()) + main.call("_enter_gameplay") + for _frame: int in 4: + await physics_frame + assert(session.set_host_open(true)) + 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 + print("Late-join multiplayer host validation: PASS") + await _cleanup(main, session) + + +func _run_first_client() -> void: + var main: Node = await _create_initialized_main() + var session := await _join(main) + var profile := main.get_node("%NetworkProfileService") as NetworkProfileService + 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 chat := main.get_node("%NetworkChatService") as NetworkChatService + var deadline: int = Time.get_ticks_msec() + 15000 + while Time.get_ticks_msec() < deadline: + await process_frame + if ( + session.get_authenticated_peer_ids().size() == 3 + and _history_contains(chat, LATE_MESSAGE) + ): + break + assert(session.get_authenticated_peer_ids().size() == 3) + assert(_history_contains(chat, LATE_MESSAGE)) + var spawn := main.get_node("%PlayerSpawnService") as PlayerSpawnService + assert(spawn.get_peer_ids().size() == 3) + print("Late-join first-client validation: PASS") + await _cleanup(main, session) + + +func _run_late_client() -> void: + var main: Node = await _create_initialized_main() + var session := await _join(main) + var deadline: int = Time.get_ticks_msec() + 8000 + while ( + Time.get_ticks_msec() < deadline + and session.get_authenticated_peer_ids().size() < 3 + ): + await process_frame + assert(session.get_authenticated_peer_ids().size() == 3) + var spawn := main.get_node("%PlayerSpawnService") as PlayerSpawnService + assert(spawn.get_peer_ids().size() == 3) + var chat := main.get_node("%NetworkChatService") as NetworkChatService + assert(chat.send_local_message(LATE_MESSAGE)) + await create_timer(1.0).timeout + print("Late-join late-client validation: PASS") + await _cleanup(main, session) + + +func _join(main: Node) -> NetworkSession: + main.call("_on_title_join_game_requested", "127.0.0.1:%d" % TEST_PORT) + var session := main.get_node("%NetworkSession") as NetworkSession + var deadline: int = Time.get_ticks_msec() + 20000 + while Time.get_ticks_msec() < 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")): + return session + assert(false, "Client did not join in time.") + return session + + +func _wait_for_registry_size(session: NetworkSession, expected: int) -> bool: + var deadline: int = Time.get_ticks_msec() + 25000 + while Time.get_ticks_msec() < deadline: + await process_frame + if session.get_authenticated_peer_ids().size() == expected: + return true + return false + + +func _history_contains(service: NetworkChatService, body: String) -> bool: + return service.get_history().any( + func(message: Dictionary) -> bool: + return str(message.get("body", "")) == body + ) + + +func _create_initialized_main() -> Node: + root.size = Vector2i(1280, 720) + var main: Node = MainScene.instantiate() + root.add_child(main) + for _frame: int in 4: + await process_frame + if not bool(main.get("_application_initialized")): + main.call("_activate_selected_data_path", "", true) + for _frame: int in 8: + await process_frame + assert(bool(main.get("_application_initialized"))) + return main + + +func _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/late_join_multiplayer_validation.gd.uid b/tests/late_join_multiplayer_validation.gd.uid new file mode 100644 index 0000000..5d33548 --- /dev/null +++ b/tests/late_join_multiplayer_validation.gd.uid @@ -0,0 +1 @@ +uid://dnuwxkoeeykt3 From 6f083f3fdf8404012e96b785d0f0c2635ca0db8e Mon Sep 17 00:00:00 2001 From: Voyager Date: Sun, 23 Aug 2026 01:27:53 -0400 Subject: [PATCH 03/44] chore: prepare v0.16.2-alpha release --- docs/README-PLAYTEST.txt | 4 ++-- export_presets.cfg | 26 +++++++++++++------------- project.godot | 2 +- scripts/build_playtest.sh | 6 +++--- ui/title_screen.tscn | 2 +- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/README-PLAYTEST.txt b/docs/README-PLAYTEST.txt index f42ce3c..ca32217 100644 --- a/docs/README-PLAYTEST.txt +++ b/docs/README-PLAYTEST.txt @@ -1,6 +1,6 @@ NETfishing -v0.16.1-alpha -Alpha 0.16.1 +v0.16.2-alpha +Alpha 0.16.2 Thank you for trying this early private playtest. diff --git a/export_presets.cfg b/export_presets.cfg index b7e8c1a..d41ca53 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -9,7 +9,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.16.1-alpha/windows-x86_64/NETfishing.exe" +export_path="builds/v0.16.2-alpha/windows-x86_64/NETfishing.exe" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -35,11 +35,11 @@ application/modify_resources=true application/icon="res://art/exported/system_icons/netfishing.ico" application/console_wrapper_icon="" application/icon_interpolation=4 -application/file_version="0.16.1.0" -application/product_version="0.16.1.0" +application/file_version="0.16.2.0" +application/product_version="0.16.2.0" application/company_name="Woofmeow" application/product_name="NETfishing" -application/file_description="NETfishing v0.16.1-alpha" +application/file_description="NETfishing v0.16.2-alpha" application/copyright="Copyright © 2026 Woofmeow" application/trademarks="NETfishing and Woofmeow branding is reserved; see TRADEMARKS.md" application/export_angle=0 @@ -62,7 +62,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.16.1-alpha/linux-arm64/NETfishing.arm64" +export_path="builds/v0.16.2-alpha/linux-arm64/NETfishing.arm64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -92,7 +92,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.16.1-alpha/macos/NETfishing.zip" +export_path="builds/v0.16.2-alpha/macos/NETfishing.zip" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -108,8 +108,8 @@ custom_template/release="" application/bundle_identifier="io.woofmeow.netfishing" application/icon="res://art/exported/system_icons/netfishing_1024.png" application/icon_interpolation=0 -application/short_version="0.16.1" -application/version="0.16.1" +application/short_version="0.16.2" +application/version="0.16.2" application/architecture="universal" codesign/enable=false notarization/enable=false @@ -125,7 +125,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*,tests/*" -export_path="builds/v0.16.1-alpha/server-linux-x86_64/NETfishingServer.x86_64" +export_path="builds/v0.16.2-alpha/server-linux-x86_64/NETfishingServer.x86_64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -155,7 +155,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.16.1-alpha/android/NETfishing.apk" +export_path="builds/v0.16.2-alpha/android/NETfishing.apk" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -176,8 +176,8 @@ architectures/armeabi-v7a=false architectures/arm64-v8a=true architectures/x86=false architectures/x86_64=false -version/code=160100 -version/name="v0.16.1-alpha" +version/code=160200 +version/name="v0.16.2-alpha" package/unique_name="io.woofmeow.netfishing" package/name="NETfishing" package/signed=true @@ -214,7 +214,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.16.1-alpha/linux-x86_64/NETfishing.x86_64" +export_path="builds/v0.16.2-alpha/linux-x86_64/NETfishing.x86_64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" diff --git a/project.godot b/project.godot index 9794217..2530796 100644 --- a/project.godot +++ b/project.godot @@ -11,7 +11,7 @@ config_version=5 [application] config/name="NETFISHING" -config/version="0.16.1-alpha" +config/version="0.16.2-alpha" run/main_scene="res://main/main.tscn" config/features=PackedStringArray("4.7", "GL Compatibility") config/icon="res://art/exported/system_icons/netfishing_256.png" diff --git a/scripts/build_playtest.sh b/scripts/build_playtest.sh index dae3d29..9fa21c1 100755 --- a/scripts/build_playtest.sh +++ b/scripts/build_playtest.sh @@ -4,14 +4,14 @@ set -euo pipefail readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" -readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.16.1-alpha" +readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.16.2-alpha" readonly WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64" readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64" readonly README_SOURCE="${PROJECT_ROOT}/docs/README-PLAYTEST.txt" readonly SOURCE_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)" readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/netfishing" -readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.16.1-alpha-windows-x86_64.zip" -readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.16.1-alpha-linux-x86_64.zip" +readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.16.2-alpha-windows-x86_64.zip" +readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.16.2-alpha-linux-x86_64.zip" readonly GODOT_BIN="${GODOT_BIN:-godot}" if [[ ! -f "${PROJECT_ROOT}/project.godot" ]]; then diff --git a/ui/title_screen.tscn b/ui/title_screen.tscn index 33731c4..e0838ed 100644 --- a/ui/title_screen.tscn +++ b/ui/title_screen.tscn @@ -226,7 +226,7 @@ unique_name_in_owner = true layout_mode = 2 theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1) theme_override_font_sizes/font_size = 22 -text = "v0.16.1-alpha" +text = "v0.16.2-alpha" horizontal_alignment = 1 [node name="Spacer" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"] From 1db1a5b754d3d777b050e871baa6fec11e25d57b Mon Sep 17 00:00:00 2001 From: woofmeow Date: Sun, 23 Aug 2026 17:52:48 +0000 Subject: [PATCH 04/44] Update README.md --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 50ac991..132b3f8 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,20 @@ co-owners publicly credited as Voyager and Endeavour. ## Links Patreon: https://www.patreon.com/cw/woofmeowgames + Steam: https://store.steampowered.com/app/5068950/NETfishing/ + Itch.io: https://sol6-vi.itch.io/netfishing + Discord: https://discord.gg/5gP22447kc + Matrix: https://matrix.to/#/#netfishing:matrix.makearmy.io + Repo: https://forge.makearmy.io/woofmeow/netfishing + Releases: https://forge.makearmy.io/woofmeow/netfishing/releases + ## Installing release builds Download and extract the archive for your platform from the official release From 3b84bfe3a04682e05055d89a15131f8e1f82a7a3 Mon Sep 17 00:00:00 2001 From: Voyager Date: Sun, 23 Aug 2026 20:48:38 -0400 Subject: [PATCH 05/44] feat: expand progression and multiplayer systems Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements. --- collection/collection_log.gd | 58 ++ docs/DEVELOPMENT.md | 18 +- docs/README-PLAYTEST.txt | 1 + drawing/surface_drawing_protocol.gd | 2 +- fishing/catch_controller.gd | 2 +- fishing/fishing_spot.gd | 23 +- fishing/remote_fishing_presentation.gd | 50 +- main/main.gd | 388 +++++++++- network/discovery_client.gd | 505 +++++++++++- network/network_fish_showcase_protocol.gd | 2 +- network/network_fish_showcase_service.gd | 35 + network/network_fishing_protocol.gd | 14 +- network/network_fishing_service.gd | 238 +++++- network/network_item_use_service.gd | 7 + network/network_player_list_service.gd | 450 +++++++++++ network/network_protocol.gd | 27 +- network/network_sale_service.gd | 28 + network/network_session.gd | 638 +++++++++++++-- network/network_surface_drawing_service.gd | 24 +- network/network_transport.gd | 19 + network/network_world_spawn_protocol.gd | 4 +- network/network_world_spawn_service.gd | 219 +++++- network/player_data_root.gd | 3 +- network/player_list_entry.gd | 2 + network/player_relationship_store.gd | 185 ++++- network/player_spawn_service.gd | 8 +- network/portable_data_migration.gd | 43 +- player/player.gd | 431 +++++++++-- save/player_save_inspection.gd | 4 + save/player_save_manager.gd | 210 ++++- save/player_save_slot_catalog.gd | 591 ++++++++++++++ save/player_save_slot_catalog.gd.uid | 1 + scripts/run_validations.sh | 2 + settings/keyboard_mouse_mapping_manager.gd | 11 + settings/player_settings.gd | 2 + settings/player_settings_manager.gd | 10 + tests/art_tools_validation.gd | 65 +- tests/camera_drag_validation.gd | 20 + tests/chat_privacy_multiplayer_validation.gd | 4 + ...ontroller_menu_accessibility_validation.gd | 177 +++-- tests/economy_regression_validation.gd | 11 +- tests/fish_hotbar_showcase_validation.gd | 20 +- tests/fish_quality_validation.gd | 58 +- tests/fish_showcase_multiplayer_validation.gd | 4 + tests/fishing_authority_validation.gd | 16 + tests/fishing_surface_validation.gd | 18 +- tests/friend_multiplayer_validation.gd | 161 ++++ tests/friend_multiplayer_validation.gd.uid | 1 + tests/friend_relationship_validation.gd | 76 ++ tests/friend_relationship_validation.gd.uid | 1 + tests/generated_world_runtime_validation.gd | 37 +- tests/job_multiplayer_validation.gd | 4 + tests/job_system_validation.gd | 48 ++ tests/keyboard_mouse_mapping_validation.gd | 15 + tests/late_join_multiplayer_validation.gd | 216 +++++- tests/light_performance_profile_validation.gd | 12 + tests/logbook_validation.gd | 5 +- tests/movement_multiplayer_validation.gd | 186 ++++- tests/on_screen_keyboard_validation.gd | 3 + tests/operator_multiplayer_validation.gd | 22 +- tests/profile_multiplayer_validation.gd | 4 + tests/progression_archive_validation.gd | 81 +- .../session_switch_multiplayer_validation.gd | 4 + .../surface_drawing_multiplayer_validation.gd | 4 + tests/surface_drawing_runtime_validation.gd | 14 +- tests/world_layout_validation.gd | 5 + tests/world_spawn_multiplayer_validation.gd | 4 + tests/world_spawn_protocol_validation.gd | 2 +- tests/world_time_multiplayer_validation.gd | 4 + ui/chat_ui.gd | 169 +++- ui/game_ui.gd | 191 ++++- ui/game_ui.tscn | 14 +- ui/hotbar.gd | 109 +-- ui/hotbar.tscn | 30 - ui/icons/player_options/clean.png | Bin 0 -> 39617 bytes ui/icons/player_options/clean.png.import | 40 + ui/icons/player_options/friends.png | Bin 0 -> 39562 bytes ui/icons/player_options/friends.png.import | 40 + ui/logbook_page.gd | 7 +- ui/network/join_game_page.gd | 378 ++++++++- ui/network/join_game_page.tscn | 445 +++++------ ui/on_screen_keyboard.gd | 34 +- ui/players_page.gd | 162 +++- ui/save_slots_page.gd | 731 ++++++++++++++++++ ui/save_slots_page.gd.uid | 1 + ui/save_slots_page.tscn | 350 +++++++++ ui/settings_panel.gd | 143 +--- ui/settings_panel.tscn | 53 +- ui/surface_drawing_toolbar.tscn | 4 +- ui/the_net_page.gd | 8 +- ui/title_screen.gd | 122 ++- ui/title_screen.tscn | 83 +- ui/ui_pixelation_presenter.gd | 1 + world/generation/generated_world_region.gd | 6 +- world/generation/terrain_chunk_generator.gd | 94 +++ world/player_water_trigger.gd | 9 + world/test_world.gd | 70 ++ 97 files changed, 7869 insertions(+), 982 deletions(-) create mode 100644 save/player_save_slot_catalog.gd create mode 100644 save/player_save_slot_catalog.gd.uid create mode 100644 tests/friend_multiplayer_validation.gd create mode 100644 tests/friend_multiplayer_validation.gd.uid create mode 100644 tests/friend_relationship_validation.gd create mode 100644 tests/friend_relationship_validation.gd.uid create mode 100644 ui/icons/player_options/clean.png create mode 100644 ui/icons/player_options/clean.png.import create mode 100644 ui/icons/player_options/friends.png create mode 100644 ui/icons/player_options/friends.png.import create mode 100644 ui/save_slots_page.gd create mode 100644 ui/save_slots_page.gd.uid create mode 100644 ui/save_slots_page.tscn 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 0000000000000000000000000000000000000000..1caf9188dc404d10efbe69177947d31acee05aca GIT binary patch literal 39617 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 UvAp=l?dT=)Zb#eVowQ&74?~M4-T(jq literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e0cbc74b07e11bb443472e30a1b5551ac96e65f8 GIT binary patch literal 39562 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 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 From 348ae261e1aaf10648b14e5598d3774d9a3a8208 Mon Sep 17 00:00:00 2001 From: Voyager Date: Sun, 23 Aug 2026 20:57:16 -0400 Subject: [PATCH 06/44] chore: prepare v0.17.0-alpha release --- docs/README-PLAYTEST.txt | 4 ++-- export_presets.cfg | 26 +++++++++++++------------- project.godot | 2 +- scripts/build_playtest.sh | 6 +++--- ui/title_screen.tscn | 2 +- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/README-PLAYTEST.txt b/docs/README-PLAYTEST.txt index 6b8fb4d..dbaa032 100644 --- a/docs/README-PLAYTEST.txt +++ b/docs/README-PLAYTEST.txt @@ -1,6 +1,6 @@ NETfishing -v0.16.2-alpha -Alpha 0.16.2 +v0.17.0-alpha +Alpha 0.17.0 Thank you for trying this early private playtest. diff --git a/export_presets.cfg b/export_presets.cfg index d41ca53..c484b38 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -9,7 +9,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.16.2-alpha/windows-x86_64/NETfishing.exe" +export_path="builds/v0.17.0-alpha/windows-x86_64/NETfishing.exe" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -35,11 +35,11 @@ application/modify_resources=true application/icon="res://art/exported/system_icons/netfishing.ico" application/console_wrapper_icon="" application/icon_interpolation=4 -application/file_version="0.16.2.0" -application/product_version="0.16.2.0" +application/file_version="0.17.0.0" +application/product_version="0.17.0.0" application/company_name="Woofmeow" application/product_name="NETfishing" -application/file_description="NETfishing v0.16.2-alpha" +application/file_description="NETfishing v0.17.0-alpha" application/copyright="Copyright © 2026 Woofmeow" application/trademarks="NETfishing and Woofmeow branding is reserved; see TRADEMARKS.md" application/export_angle=0 @@ -62,7 +62,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.16.2-alpha/linux-arm64/NETfishing.arm64" +export_path="builds/v0.17.0-alpha/linux-arm64/NETfishing.arm64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -92,7 +92,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.16.2-alpha/macos/NETfishing.zip" +export_path="builds/v0.17.0-alpha/macos/NETfishing.zip" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -108,8 +108,8 @@ custom_template/release="" application/bundle_identifier="io.woofmeow.netfishing" application/icon="res://art/exported/system_icons/netfishing_1024.png" application/icon_interpolation=0 -application/short_version="0.16.2" -application/version="0.16.2" +application/short_version="0.17.0" +application/version="0.17.0" application/architecture="universal" codesign/enable=false notarization/enable=false @@ -125,7 +125,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*,tests/*" -export_path="builds/v0.16.2-alpha/server-linux-x86_64/NETfishingServer.x86_64" +export_path="builds/v0.17.0-alpha/server-linux-x86_64/NETfishingServer.x86_64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -155,7 +155,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.16.2-alpha/android/NETfishing.apk" +export_path="builds/v0.17.0-alpha/android/NETfishing.apk" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -176,8 +176,8 @@ architectures/armeabi-v7a=false architectures/arm64-v8a=true architectures/x86=false architectures/x86_64=false -version/code=160200 -version/name="v0.16.2-alpha" +version/code=170000 +version/name="v0.17.0-alpha" package/unique_name="io.woofmeow.netfishing" package/name="NETfishing" package/signed=true @@ -214,7 +214,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.16.2-alpha/linux-x86_64/NETfishing.x86_64" +export_path="builds/v0.17.0-alpha/linux-x86_64/NETfishing.x86_64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" diff --git a/project.godot b/project.godot index 2530796..3b69d42 100644 --- a/project.godot +++ b/project.godot @@ -11,7 +11,7 @@ config_version=5 [application] config/name="NETFISHING" -config/version="0.16.2-alpha" +config/version="0.17.0-alpha" run/main_scene="res://main/main.tscn" config/features=PackedStringArray("4.7", "GL Compatibility") config/icon="res://art/exported/system_icons/netfishing_256.png" diff --git a/scripts/build_playtest.sh b/scripts/build_playtest.sh index 9fa21c1..74a0c8c 100755 --- a/scripts/build_playtest.sh +++ b/scripts/build_playtest.sh @@ -4,14 +4,14 @@ set -euo pipefail readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" -readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.16.2-alpha" +readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.17.0-alpha" readonly WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64" readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64" readonly README_SOURCE="${PROJECT_ROOT}/docs/README-PLAYTEST.txt" readonly SOURCE_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)" readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/netfishing" -readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.16.2-alpha-windows-x86_64.zip" -readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.16.2-alpha-linux-x86_64.zip" +readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.17.0-alpha-windows-x86_64.zip" +readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.17.0-alpha-linux-x86_64.zip" readonly GODOT_BIN="${GODOT_BIN:-godot}" if [[ ! -f "${PROJECT_ROOT}/project.godot" ]]; then diff --git a/ui/title_screen.tscn b/ui/title_screen.tscn index e969f66..03275f3 100644 --- a/ui/title_screen.tscn +++ b/ui/title_screen.tscn @@ -227,7 +227,7 @@ unique_name_in_owner = true layout_mode = 2 theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1) theme_override_font_sizes/font_size = 22 -text = "v0.16.2-alpha" +text = "v0.17.0-alpha" horizontal_alignment = 1 [node name="Spacer" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"] From a3aea989827847c5c302144b11ef83c0363197c4 Mon Sep 17 00:00:00 2001 From: Voyager Date: Sun, 23 Aug 2026 23:04:14 -0400 Subject: [PATCH 07/44] Improve multiplayer movement reconciliation --- network/network_session.gd | 34 +++- player/player.gd | 222 +++++++++++++++-------- tests/movement_multiplayer_validation.gd | 73 +++++++- 3 files changed, 240 insertions(+), 89 deletions(-) diff --git a/network/network_session.gd b/network/network_session.gd index 11217d7..b889678 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -13,6 +13,7 @@ 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 OWNER_SNAPSHOT_DIVISOR: int = 3 const NEAR_REMOTE_SNAPSHOT_DIVISOR: int = 2 const FAR_REMOTE_SNAPSHOT_DIVISOR: int = 6 const DISTANT_REMOTE_SNAPSHOT_DIVISOR: int = 8 @@ -122,6 +123,7 @@ 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 _last_local_snapshot_received_msec: 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] = {} @@ -2248,13 +2250,16 @@ func _broadcast_movement_snapshots() -> void: 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( + if subject_id == recipient_id: + # The owner already simulates locally. Its authoritative state is + # an audit and acknowledgement, not a presentation stream, so it + # does not need the full 30 Hz observer snapshot rate. + if _movement_snapshot_tick % OWNER_SNAPSHOT_DIVISOR != 0: + continue + elif 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) @@ -2538,8 +2543,10 @@ func receive_movement_snapshots(encoded_snapshots: Array) -> void: ) avatar.apply_local_prediction_correction( snapshot, - _pending_movement_inputs, + _input_sequence, INPUT_INTERVAL, + estimated_transit_seconds, + _local_snapshot_delta_seconds(), ) else: avatar.push_network_snapshot( @@ -2557,6 +2564,20 @@ func _discard_acknowledged_movement_inputs(acknowledged_sequence: int) -> void: _pending_movement_inputs.pop_front() +func _local_snapshot_delta_seconds() -> float: + var now_msec: int = Time.get_ticks_msec() + if _last_local_snapshot_received_msec <= 0: + _last_local_snapshot_received_msec = now_msec + return 0.0 + var elapsed_seconds: float = clampf( + float(now_msec - _last_local_snapshot_received_msec) / 1000.0, + 0.0, + Player.LOCAL_PREDICTION_MAX_AUDIT_DELTA_SECONDS, + ) + _last_local_snapshot_received_msec = now_msec + return elapsed_seconds + + @rpc( "authority", "call_remote", @@ -2861,6 +2882,7 @@ func _teardown_peer() -> void: _pending_movement_inputs.clear() _snapshot_accumulator = 0.0 _movement_snapshot_tick = 0 + _last_local_snapshot_received_msec = 0 _animation_refresh_accumulator = 0.0 _last_animation_state_by_peer.clear() _pending_animation_state_by_peer.clear() diff --git a/player/player.gd b/player/player.gd index 6ec490f..860caf3 100644 --- a/player/player.gd +++ b/player/player.gd @@ -145,9 +145,14 @@ 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 -const LOCAL_PREDICTION_SNAP_DISTANCE: float = 2.0 -const LOCAL_PREDICTION_CORRECTION_WEIGHT: float = 0.18 +const LOCAL_PREDICTION_CORRECTION_THRESHOLD: float = 1.5 +const LOCAL_PREDICTION_SNAP_DISTANCE: float = 6.0 +const LOCAL_PREDICTION_CORRECTION_DELAY_SECONDS: float = 0.35 +const LOCAL_PREDICTION_MIN_CORRECTION_AUDITS: int = 3 +const LOCAL_PREDICTION_SOFT_CORRECTION_RATE: float = 2.5 +const LOCAL_PREDICTION_MAX_SOFT_CORRECTION_STEP: float = 0.15 +const LOCAL_PREDICTION_MAX_AUDIT_DELTA_SECONDS: float = 0.15 +const LOCAL_RECONCILIATION_PRESENTATION_RECENTER_RATE: float = 5.0 # The target Android handheld exposes its physical right trigger through # Godot's left-trigger axis. Keep the role named here so the platform mapping # remains isolated from camera behavior. @@ -529,6 +534,13 @@ 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 _local_reconciliation_camera_offset: Vector3 = Vector3.ZERO +var _local_prediction_error_seconds: float = 0.0 +var _local_prediction_error_audits: int = 0 +var _local_prediction_error_direction: Vector3 = Vector3.ZERO +var _local_prediction_soft_corrections: int = 0 +var _local_prediction_hard_corrections: int = 0 +var _local_prediction_largest_error: float = 0.0 var _authoritative_movement_history: Array[Dictionary] = [] var _local_network_jump_intent_pending: bool = false var _local_network_jump_intent_sequence: int = -1 @@ -1995,6 +2007,11 @@ func reset_network_movement_state() -> void: _network_input_stale_timeout_seconds = NETWORK_INPUT_STALE_TIMEOUT_SECONDS _network_jump_intent_active = false _last_network_input_sequence = 0 + _reset_local_prediction_error() + _clear_local_reconciliation_offsets() + _local_prediction_soft_corrections = 0 + _local_prediction_hard_corrections = 0 + _local_prediction_largest_error = 0.0 func capture_network_input(sequence: int) -> Dictionary: @@ -2235,8 +2252,10 @@ func push_network_snapshot( func apply_local_prediction_correction( snapshot: Dictionary, - pending_inputs: Array[Dictionary] = [], + latest_input_sequence: int = 0, input_interval_seconds: float = 0.0, + estimated_transit_seconds: float = -1.0, + audit_delta_seconds: float = 0.0, ) -> void: var parsed: Dictionary = _parse_network_snapshot(snapshot) if parsed.is_empty(): @@ -2258,92 +2277,143 @@ 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, + ) + if transit_seconds > 0.0: + authoritative_position += ( + (parsed["velocity"] as Vector3) * transit_seconds + ) + var error_offset: Vector3 = authoritative_position - global_position + var error_distance: float = error_offset.length() + _local_prediction_largest_error = maxf( + _local_prediction_largest_error, + error_distance, + ) + if error_distance <= LOCAL_PREDICTION_CORRECTION_THRESHOLD: + _reset_local_prediction_error() + return + if error_distance >= LOCAL_PREDICTION_SNAP_DISTANCE: + _clear_local_reconciliation_offsets() + global_position = authoritative_position + velocity = parsed["velocity"] + _local_prediction_hard_corrections += 1 + _reset_local_prediction_error() + return + var error_direction: Vector3 = error_offset.normalized() + if ( + not _local_prediction_error_direction.is_zero_approx() + and _local_prediction_error_direction.dot(error_direction) < 0.5 + ): + _reset_local_prediction_error() + _local_prediction_error_direction = error_direction + _local_prediction_error_audits += 1 + _local_prediction_error_seconds += clampf( + audit_delta_seconds, + 0.0, + LOCAL_PREDICTION_MAX_AUDIT_DELTA_SECONDS, + ) + if ( + _local_prediction_error_audits + < LOCAL_PREDICTION_MIN_CORRECTION_AUDITS + or _local_prediction_error_seconds + < LOCAL_PREDICTION_CORRECTION_DELAY_SECONDS + ): + return + var correction_weight: float = 1.0 - exp( + -LOCAL_PREDICTION_SOFT_CORRECTION_RATE + * clampf( + audit_delta_seconds, + 0.0, + LOCAL_PREDICTION_MAX_AUDIT_DELTA_SECONDS, + ) + ) + var correction: Vector3 = error_offset * correction_weight + if correction.length() > LOCAL_PREDICTION_MAX_SOFT_CORRECTION_STEP: + correction = ( + correction.normalized() + * LOCAL_PREDICTION_MAX_SOFT_CORRECTION_STEP + ) + _apply_camera_safe_local_correction(correction) + _local_prediction_soft_corrections += 1 + + +func _apply_camera_safe_local_correction(correction: Vector3) -> void: + if correction.is_zero_approx(): + return var previous_visual_position: Vector3 = _visuals.global_position + var previous_camera_position: Vector3 = _camera_yaw.global_position var base_visual_local_position: Vector3 = ( _visuals.position - _local_reconciliation_visual_offset ) - 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 + var base_camera_local_position: Vector3 = ( + _camera_yaw.position - _local_reconciliation_camera_offset + ) + global_position += correction + _visuals.global_position = previous_visual_position + _camera_yaw.global_position = previous_camera_position + _local_reconciliation_visual_offset = ( + _visuals.position - base_visual_local_position + ) + _local_reconciliation_camera_offset = ( + _camera_yaw.position - base_camera_local_position ) - 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 _reset_local_prediction_error() -> void: + _local_prediction_error_seconds = 0.0 + _local_prediction_error_audits = 0 + _local_prediction_error_direction = Vector3.ZERO + + +func _clear_local_reconciliation_offsets() -> void: + if not _local_reconciliation_visual_offset.is_zero_approx(): + _visuals.position -= _local_reconciliation_visual_offset + if not _local_reconciliation_camera_offset.is_zero_approx(): + _camera_yaw.position -= _local_reconciliation_camera_offset + _local_reconciliation_visual_offset = Vector3.ZERO + _local_reconciliation_camera_offset = Vector3.ZERO func _update_local_reconciliation_visuals(delta: float) -> void: - if _local_reconciliation_visual_offset.is_zero_approx(): + if ( + _local_reconciliation_visual_offset.is_zero_approx() + and _local_reconciliation_camera_offset.is_zero_approx() + ): _local_reconciliation_visual_offset = Vector3.ZERO + _local_reconciliation_camera_offset = Vector3.ZERO return - var retained_ratio: float = exp(-14.0 * delta) - var retained_offset: Vector3 = ( + var retained_ratio: float = exp( + -LOCAL_RECONCILIATION_PRESENTATION_RECENTER_RATE * delta + ) + var retained_visual_offset: Vector3 = ( _local_reconciliation_visual_offset * retained_ratio ) - _visuals.position += retained_offset - _local_reconciliation_visual_offset - _local_reconciliation_visual_offset = retained_offset + var retained_camera_offset: Vector3 = ( + _local_reconciliation_camera_offset * retained_ratio + ) + _visuals.position += ( + retained_visual_offset - _local_reconciliation_visual_offset + ) + _camera_yaw.position += ( + retained_camera_offset - _local_reconciliation_camera_offset + ) + _local_reconciliation_visual_offset = retained_visual_offset + _local_reconciliation_camera_offset = retained_camera_offset + + +func get_local_prediction_metrics() -> Dictionary: + return { + "soft_corrections": _local_prediction_soft_corrections, + "hard_corrections": _local_prediction_hard_corrections, + "largest_error": _local_prediction_largest_error, + "out_of_bounds_audits": _local_prediction_error_audits, + "out_of_bounds_seconds": _local_prediction_error_seconds, + } static func resolve_network_input_stale_timeout_seconds( diff --git a/tests/movement_multiplayer_validation.gd b/tests/movement_multiplayer_validation.gd index f331534..c6a98e5 100644 --- a/tests/movement_multiplayer_validation.gd +++ b/tests/movement_multiplayer_validation.gd @@ -71,6 +71,7 @@ func _validate_compact_snapshot_encoding() -> void: 1, ) assert(NetworkSession.MOVEMENT_SNAPSHOT_BATCH_SIZE == 8) + assert(NetworkSession.OWNER_SNAPSHOT_DIVISOR == 3) var encoded_snapshots: Array = [] for _peer: int in NetworkSession.MOVEMENT_SNAPSHOT_BATCH_SIZE: encoded_snapshots.append( @@ -283,16 +284,72 @@ 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, - pending_inputs, + 2, 1.0 / 30.0, + 0.1, + 0.1, + ) + # Ordinary host/client disagreement is expected while a packet is in flight. + # It must never tug the locally controlled body or camera around. + assert(is_equal_approx(avatar.global_position.x, 0.8)) + assert( + int(avatar.get("_local_prediction_soft_corrections")) == 0 + ) + assert( + int(avatar.get("_local_prediction_hard_corrections")) == 0 + ) + + # A larger but still plausible mismatch must persist across multiple audits + # before a small correction is allowed. Preserve both visible character and + # camera positions while the collision body catches up. + avatar.global_position = Vector3(3.0, 0.0, 0.0) + var visual_position: Vector3 = avatar.get_node("Visuals").global_position + var camera_position: Vector3 = avatar.get_node("CameraYaw").global_position + var drift_snapshot: Dictionary = _network_snapshot( + Vector3.ZERO, + Vector3.ZERO, + 20, + ) + for _audit: int in 3: + avatar.apply_local_prediction_correction( + drift_snapshot, + 20, + 1.0 / 30.0, + 0.0, + 0.1, + ) + assert(is_equal_approx(avatar.global_position.x, 3.0)) + avatar.apply_local_prediction_correction( + drift_snapshot, + 20, + 1.0 / 30.0, + 0.0, + 0.1, + ) + assert(avatar.global_position.x < 3.0) + assert(avatar.global_position.x >= 2.85 - 0.001) + assert(avatar.get_node("Visuals").global_position == visual_position) + assert(avatar.get_node("CameraYaw").global_position == camera_position) + assert( + int(avatar.get("_local_prediction_soft_corrections")) == 1 + ) + + # Genuine divergence still recovers immediately, as do the separate reliable + # teleport and water-recovery paths used by gameplay transitions. + avatar.global_position = Vector3(10.0, 0.0, 0.0) + avatar.apply_local_prediction_correction( + drift_snapshot, + 20, + 1.0 / 30.0, + 0.0, + 0.1, + ) + assert(avatar.global_position == Vector3.ZERO) + assert( + int(avatar.get("_local_prediction_hard_corrections")) == 1 ) - assert(avatar.global_position.x < 0.8) - assert(avatar.global_position.x > 0.0) func _validate_reliable_jump_intent(avatar: Player) -> void: @@ -305,8 +362,10 @@ 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), - [], + 20, 1.0 / 30.0, + 0.0, + 0.1, ) assert(not bool(avatar.capture_network_input(22)["jump"])) From 867fd431af0343760401c058f1b641bd299948ce Mon Sep 17 00:00:00 2001 From: Voyager Date: Sun, 23 Aug 2026 23:04:15 -0400 Subject: [PATCH 08/44] Polish title online and save slot interfaces --- project.godot | 2 +- ...ontroller_menu_accessibility_validation.gd | 106 ++++++++- ui/network/join_game_page.gd | 202 +++++++++++++++++- ui/network/join_game_page.tscn | 41 +++- ui/players_page.gd | 134 ++++++------ ui/save_slots_page.gd | 16 +- ui/save_slots_page.tscn | 22 +- ui/title_screen.gd | 26 ++- 8 files changed, 463 insertions(+), 86 deletions(-) diff --git a/project.godot b/project.godot index 3b69d42..8a49214 100644 --- a/project.godot +++ b/project.godot @@ -30,8 +30,8 @@ enabled=PackedStringArray("res://addons/netfishing_shoreline_baker/plugin.cfg") [gui] -theme/custom="res://ui/game_theme.tres" timers/tooltip_delay_sec=0.0 +theme/custom="res://ui/game_theme.tres" [input] diff --git a/tests/controller_menu_accessibility_validation.gd b/tests/controller_menu_accessibility_validation.gd index bf856d3..70d2095 100644 --- a/tests/controller_menu_accessibility_validation.gd +++ b/tests/controller_menu_accessibility_validation.gd @@ -130,7 +130,28 @@ func _validate_primary_menu_navigation() -> void: 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) + _assert_neighbor( + title.get_node("%JoinGameButton") as Control, + &"focus_neighbor_right", + play_button, + ) + _assert_neighbor( + title.get_node("%SettingsButton") as Control, + &"focus_neighbor_left", + play_button, + ) + _assert_neighbor( + title.get_node("%CreditsButton") as Control, + &"focus_neighbor_right", + play_button, + ) + _assert_neighbor( + title.get_node("%QuitButton") as Control, + &"focus_neighbor_left", + play_button, + ) + for title_control: Control in title_controls: + _assert_directionally_reachable(title_control, title_controls) title.queue_free() await process_frame @@ -195,6 +216,13 @@ func _validate_save_slots_navigation() -> void: page.call("_select_page", &"saves", false) await process_frame var import_button := page.get_node("%ImportSlotButton") as Button + var actions := page.get_node("%Actions") as VBoxContainer + var secondary_actions := page.get_node("%SecondaryActions") as GridContainer + var play_slot := page.get_node("%PlaySlotButton") as Button + var rename_slot := page.get_node("%RenameSlotButton") as Button + var duplicate_slot := page.get_node("%DuplicateSlotButton") as Button + var export_slot := page.get_node("%ExportSlotButton") as Button + var delete_slot := page.get_node("%DeleteSlotButton") 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.", @@ -204,6 +232,29 @@ func _validate_save_slots_navigation() -> void: == page.get_node("%BackButton"), "An empty Save Slots page does not lead from Import Save to Back.", ) + _expect( + is_equal_approx(play_slot.size.x, actions.size.x), + "Save-slot Play does not span the full action width.", + ) + _expect( + secondary_actions.columns == 2 + and is_equal_approx(rename_slot.size.x, duplicate_slot.size.x) + and is_equal_approx(export_slot.size.x, delete_slot.size.x) + and is_equal_approx(rename_slot.position.y, duplicate_slot.position.y) + and is_equal_approx(export_slot.position.y, delete_slot.position.y) + and export_slot.position.y > rename_slot.position.y, + "Save-slot secondary actions are not arranged as a 2 by 2 grid.", + ) + var play_style := play_slot.get_theme_stylebox("normal") as StyleBoxFlat + _expect( + play_style != null and play_style.bg_color == UtilityPageStyle.GREEN, + "Save-slot Play does not use the green primary-action style.", + ) + _assert_neighbor(play_slot, &"focus_neighbor_bottom", rename_slot) + _assert_neighbor(rename_slot, &"focus_neighbor_right", duplicate_slot) + _assert_neighbor(rename_slot, &"focus_neighbor_bottom", export_slot) + _assert_neighbor(duplicate_slot, &"focus_neighbor_bottom", delete_slot) + _assert_neighbor(export_slot, &"focus_neighbor_right", delete_slot) page.queue_free() await process_frame @@ -222,6 +273,9 @@ func _validate_join_game_navigation() -> void: var address := page.get_node("%Address") as LineEdit var name_edit := page.get_node("%NameEdit") as LineEdit var server_list := page.get_node("%ServerList") as ItemList + var presence := page.get_node("%PresenceButton") as Button + var room_open := page.get_node("%RoomOpenButton") as Button + var room_listing := page.get_node("%RoomListingButton") as Button var refresh := page.get_node("%RefreshButton") as Button var join := page.get_node("%JoinButton") as Button var save := page.get_node("%SaveButton") as Button @@ -233,6 +287,7 @@ func _validate_join_game_navigation() -> void: 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 actions := page.get_node("%Actions") as HBoxContainer var modes: Array[Control] = [discover, friends, direct, saved, recent] var tab_overlap: float = ( discover.get_global_rect().end.y @@ -265,9 +320,36 @@ func _validate_join_game_navigation() -> void: _assert_neighbor(refresh, &"focus_neighbor_right", join) _assert_neighbor(join, &"focus_neighbor_bottom", back) _assert_neighbor(back, &"focus_neighbor_top", join) + _expect( + is_equal_approx( + join.get_global_rect().end.x, + actions.get_global_rect().end.x, + ), + "Discover Refresh and Join are not right-justified.", + ) var discover_controls: Array[Control] = modes.duplicate() discover_controls.append_array([server_list, refresh, join, back]) _assert_directionally_reachable(discover, discover_controls) + room_open.show() + room_listing.show() + _set_button_state(room_open, true) + _set_button_state(room_listing, true) + page.call("_configure_controller_navigation") + await process_frame + _assert_neighbor(room_open, &"focus_neighbor_right", room_listing) + _assert_neighbor(room_open, &"focus_neighbor_left", room_open) + _assert_neighbor(room_listing, &"focus_neighbor_left", room_open) + _assert_neighbor(room_listing, &"focus_neighbor_bottom", server_list) + var hosted_discover_controls: Array[Control] = modes.duplicate() + hosted_discover_controls.append_array([ + room_open, + room_listing, + server_list, + refresh, + join, + back, + ]) + _assert_directionally_reachable(discover, hosted_discover_controls) var rooms: Array[Dictionary] = [ { "room_id": "controller-default-room", @@ -293,16 +375,36 @@ func _validate_join_game_navigation() -> void: ) page.set("_mode", JoinGamePage.Mode.FRIENDS) + (page.get_node("%OnlineControls") as Control).hide() + presence.show() page.call("_configure_controller_navigation") await process_frame _assert_neighbor(friends, &"focus_neighbor_bottom", server_list) _assert_neighbor(server_list, &"focus_neighbor_top", friends) + _assert_neighbor(server_list, &"focus_neighbor_bottom", presence) + _assert_neighbor(presence, &"focus_neighbor_right", refresh) + _assert_neighbor(refresh, &"focus_neighbor_right", join) + _expect( + is_equal_approx( + presence.get_global_rect().position.x, + actions.get_global_rect().position.x, + ), + "Friend presence is not left-justified in the action row.", + ) + _expect( + is_equal_approx( + join.get_global_rect().end.x, + actions.get_global_rect().end.x, + ), + "Friends Refresh and Join are not right-justified.", + ) var friend_controls: Array[Control] = modes.duplicate() - friend_controls.append_array([server_list, refresh, join, back]) + friend_controls.append_array([server_list, presence, refresh, join, back]) _assert_directionally_reachable(friends, friend_controls) list_content.hide() direct_content.show() + presence.hide() address.show() address.editable = true server_list.hide() diff --git a/ui/network/join_game_page.gd b/ui/network/join_game_page.gd index 72bf19e..93ba793 100644 --- a/ui/network/join_game_page.gd +++ b/ui/network/join_game_page.gd @@ -32,6 +32,10 @@ const DIRECT_WORKFLOW_HELP: String = ( @onready var _direct_content: Control = %DirectContent @onready var _list_content: Control = %ListContent @onready var _list_title: Label = %ListTitle +@onready var _online_controls: Control = %OnlineControls +@onready var _presence_button: Button = %PresenceButton +@onready var _room_open_button: Button = %RoomOpenButton +@onready var _room_listing_button: Button = %RoomListingButton @onready var _details_panel: PanelContainer = %DetailsPanel @onready var _address: LineEdit = %Address @onready var _address_label: Label = %AddressLabel @@ -89,6 +93,9 @@ func _ready() -> void: _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)) + _presence_button.pressed.connect(_on_presence_pressed) + _room_open_button.pressed.connect(_on_room_open_pressed) + _room_listing_button.pressed.connect(_on_room_listing_pressed) _refresh_button.pressed.connect(_request_discovery_refresh) _join_button.pressed.connect(_request_join) _save_button.pressed.connect(_on_save_pressed) @@ -238,6 +245,10 @@ func setup( _on_peer_count_changed ): _network_session.peer_count_changed.connect(_on_peer_count_changed) + if not _network_session.host_openness_changed.is_connected( + _on_host_openness_changed + ): + _network_session.host_openness_changed.connect(_on_host_openness_changed) if ( _saved_servers != null and not _saved_servers.data_changed.is_connected(_on_store_changed) @@ -278,6 +289,22 @@ func setup( _discovery.social_status_changed.connect( _on_social_status_changed ) + if not _discovery.host_settings_changed.is_connected( + _on_host_settings_changed + ): + _discovery.host_settings_changed.connect( + _on_host_settings_changed + ) + if not _discovery.host_status_changed.is_connected( + _on_host_status_changed + ): + _discovery.host_status_changed.connect(_on_host_status_changed) + if not _discovery.presence_sharing_changed.is_connected( + _on_presence_sharing_changed + ): + _discovery.presence_sharing_changed.connect( + _on_presence_sharing_changed + ) _friend_entries = _discovery.get_friend_presence() _refresh() @@ -792,6 +819,7 @@ func _refresh() -> void: _direct_content.visible = direct_content_visible _list_content.visible = list_content_visible _list_title.text = _current_list_title() + _refresh_online_controls(discovery_mode, friends_mode, connecting) _address.visible = direct or _name_entry_active _address_label.visible = _address.visible _address_helper.visible = _address.visible @@ -916,6 +944,66 @@ func _current_list_title() -> String: return "public rooms" +func _refresh_online_controls( + discovery_mode: bool, + friends_mode: bool, + connecting: bool, +) -> void: + _online_controls.visible = discovery_mode and not _name_entry_active + _presence_button.visible = friends_mode and not _name_entry_active + var presence_enabled: bool = ( + _discovery != null and _discovery.is_presence_sharing() + ) + _presence_button.text = ( + "friends: online" if presence_enabled else "friends: offline" + ) + _presence_button.disabled = ( + connecting or _discovery == null or not _discovery.is_configured() + ) + _presence_button.tooltip_text = ( + "Stay visible to friends until you switch this off. " + + "Your current room is shared only while it is publicly joinable." + if presence_enabled + else "Show as online to friends until you switch this off." + ) + if not _online_controls.visible: + return + var local_host: bool = _network_session.is_host() + _room_open_button.visible = local_host + _room_listing_button.visible = local_host + if not local_host: + return + var room_open: bool = _network_session.is_open_host() + var room_listed: bool = ( + _discovery != null and _discovery.is_discoverable() + ) + _room_open_button.text = "room: open" if room_open else "room: closed" + _room_open_button.disabled = connecting + _room_open_button.tooltip_text = ( + "Close this room to new connections." + if room_open + else "Open this room so other players can connect." + ) + _room_listing_button.text = ( + "listing: on" if room_listed else "listing: off" + ) + _room_listing_button.disabled = ( + connecting + or _discovery == null + or not _discovery.is_configured() + or not room_open + ) + _room_listing_button.tooltip_text = ( + "Remove this room from the public room browser." + if room_listed + else "List this open room in the public room browser." + if room_open and _discovery != null and _discovery.is_configured() + else "Open the room before enabling its public listing." + if _discovery != null and _discovery.is_configured() + else "Room discovery is not configured in this build." + ) + + func _configure_controller_navigation() -> void: var mode_buttons: Array[Control] = [ _discover_button, @@ -924,12 +1012,24 @@ func _configure_controller_navigation() -> void: _saved_button, _recent_button, ] + var online_controls: Array[Control] = [] + for control: Control in [ + _room_open_button, + _room_listing_button, + ]: + if _controller_focus_eligible(control): + online_controls.append(control) var content_controls: Array[Control] = [] - for control: Control in [_address, _name_edit, _server_list]: + for control: Control in [ + _address, + _name_edit, + _server_list, + ]: if _controller_focus_eligible(control): content_controls.append(control) var action_controls: Array[Control] = [] for control: Control in [ + _presence_button, _refresh_button, _join_button, _save_button, @@ -942,12 +1042,16 @@ func _configure_controller_navigation() -> void: action_controls.append(control) var all_controls: Array[Control] = [] all_controls.append_array(mode_buttons) + all_controls.append_array(online_controls) 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 [ + _presence_button, + _room_open_button, + _room_listing_button, _address, _name_edit, _server_list, @@ -963,7 +1067,9 @@ func _configure_controller_navigation() -> void: control.focus_mode = Control.FOCUS_NONE _back_button.focus_mode = Control.FOCUS_ALL var primary_content: Control = ( - content_controls.front() + online_controls.front() + if not online_controls.is_empty() + else content_controls.front() if not content_controls.is_empty() else action_controls.front() if not action_controls.is_empty() @@ -978,11 +1084,29 @@ func _configure_controller_navigation() -> void: mode_button, primary_content, ) + for index: int in online_controls.size(): + var online_control: Control = online_controls[index] + var below: Control = ( + content_controls.front() + if not content_controls.is_empty() + else action_controls.front() + if not action_controls.is_empty() + else _back_button + ) + _set_controller_neighbors( + online_control, + online_controls[maxi(index - 1, 0)], + online_controls[mini(index + 1, online_controls.size() - 1)], + mode_buttons[int(_mode)], + below, + ) for index: int in content_controls.size(): var content: Control = content_controls[index] var above: Control = ( content_controls[index - 1] if index > 0 + else online_controls.front() + if not online_controls.is_empty() else mode_buttons[int(_mode)] ) var below: Control = ( @@ -1001,6 +1125,8 @@ func _configure_controller_navigation() -> void: action_controls[mini(index + 1, action_controls.size() - 1)], content_controls.back() if not content_controls.is_empty() + else online_controls.back() + if not online_controls.is_empty() else mode_buttons[int(_mode)], _back_button, ) @@ -1009,6 +1135,8 @@ func _configure_controller_navigation() -> void: if not action_controls.is_empty() else content_controls.back() if not content_controls.is_empty() + else online_controls.back() + if not online_controls.is_empty() else mode_buttons[int(_mode)] ) _set_controller_neighbors( @@ -1270,6 +1398,76 @@ func _on_social_status_changed(message: String, is_error: bool) -> void: _set_status(message, is_error) +func _on_presence_pressed() -> void: + if _discovery == null: + _set_status("Friend presence is not available.", true) + return + var enabling: bool = not _discovery.is_presence_sharing() + if not _discovery.set_presence_sharing(enabling): + return + _set_status( + "You will remain online to friends until you switch this off." + if enabling + else "You now appear offline to friends." + ) + _refresh() + + +func _on_room_open_pressed() -> void: + if _network_session == null or not _network_session.is_host(): + return + var opening: bool = not _network_session.is_open_host() + if not _network_session.set_host_open(opening): + _set_status("The room could not be updated.", true) + return + _set_status( + "The room is open to new connections." + if opening + else "The room is closed to new connections." + ) + _refresh() + + +func _on_room_listing_pressed() -> void: + if _discovery == null: + return + var enabling: bool = not _discovery.is_discoverable() + if not _discovery.set_discoverable(enabling): + return + _set_status( + "The room is being listed publicly." + if enabling + else "The room is no longer listed publicly." + ) + _refresh() + + +func _on_host_openness_changed(_is_open: bool) -> void: + _refresh() + + +func _on_host_settings_changed( + _room_name: String, + _discoverable: bool, +) -> void: + _refresh() + + +func _on_host_status_changed(message: String, is_error: bool) -> void: + if ( + _mode == Mode.DISCOVER + and is_visible_in_tree() + and _network_session != null + and _network_session.is_host() + ): + _set_status(message, is_error) + _refresh() + + +func _on_presence_sharing_changed(_enabled: bool) -> void: + _refresh() + + 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 54f354d..9374a85 100644 --- a/ui/network/join_game_page.tscn +++ b/ui/network/join_game_page.tscn @@ -138,9 +138,35 @@ layout_mode = 2 theme_override_font_sizes/font_size = 20 text = "public rooms" +[node name="OnlineControls" type="HBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/ListContent"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 42) +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="OnlineControlsSpacer" type="Control" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/ListContent/OnlineControls"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="RoomOpenButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/ListContent/OnlineControls"] +unique_name_in_owner = true +visible = false +custom_minimum_size = Vector2(160, 42) +layout_mode = 2 +focus_mode = 2 +text = "room: closed" + +[node name="RoomListingButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/ListContent/OnlineControls"] +unique_name_in_owner = true +visible = false +custom_minimum_size = Vector2(180, 42) +layout_mode = 2 +focus_mode = 2 +text = "listing: off" + [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) +custom_minimum_size = Vector2(0, 140) layout_mode = 2 size_flags_vertical = 3 theme_override_font_sizes/font_size = 17 @@ -235,7 +261,18 @@ unique_name_in_owner = true custom_minimum_size = Vector2(0, 46) layout_mode = 2 theme_override_constants/separation = 10 -alignment = 1 + +[node name="PresenceButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/Actions"] +unique_name_in_owner = true +visible = false +custom_minimum_size = Vector2(210, 46) +layout_mode = 2 +focus_mode = 2 +text = "friends: offline" + +[node name="ActionsSpacer" type="Control" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/Actions"] +layout_mode = 2 +size_flags_horizontal = 3 [node name="RefreshButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/Actions"] unique_name_in_owner = true diff --git a/ui/players_page.gd b/ui/players_page.gd index 27c142c..68f25c6 100644 --- a/ui/players_page.gd +++ b/ui/players_page.gd @@ -44,6 +44,7 @@ var _online_state_label: Label var _discoverable_toggle: Button var _discoverable_state_label: Label var _host_discovery_status: Label +var _reset_artwork_button: Button var _current_tab := 0 var _active: bool = false var _interactive: bool = false @@ -295,6 +296,9 @@ func _build_host_settings(root: VBoxContainer) -> void: "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY ) access_row.add_child(_host_discovery_status) + _reset_artwork_button = Button.new() + _configure_session_artwork_reset_button(_reset_artwork_button) + access_row.add_child(_reset_artwork_button) func _build_host_toggle(label_text: String, row: HBoxContainer) -> Button: @@ -389,7 +393,10 @@ func _refresh_host_settings() -> void: return var host_visible: bool = _service.is_local_host() and _current_tab == 0 _host_settings_panel.visible = host_visible - if not host_visible or _discovery == null: + if not host_visible: + return + _update_session_artwork_reset_button(_reset_artwork_button) + if _discovery == null: return if not _room_name_edit.has_focus(): _room_name_edit.text = _discovery.get_room_name() @@ -481,7 +488,9 @@ func _on_host_status_changed(message: String, is_error: bool) -> void: "Open the game before enabling discovery.", ] _host_discovery_status.text = "" if tooltip_only else message - _host_discovery_status.visible = not _host_discovery_status.text.is_empty() + # Keep this expanding label in the row even when there is no status text so + # the session reset action remains pinned to the far-right edge. + _host_discovery_status.visible = true _host_discovery_status.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_DANGER @@ -491,7 +500,7 @@ func _on_host_status_changed(message: String, is_error: bool) -> void: func _build_active_rows() -> void: - if _service.is_local_moderator(): + if _service.is_local_moderator() and not _service.is_local_host(): _build_session_artwork_controls() var entries := _service.get_entries() if entries.is_empty(): @@ -502,35 +511,29 @@ func _build_active_rows() -> void: func _build_active_player_row(entry: PlayerListEntry) -> void: - var row := _make_active_player_row() - var identity_row := HBoxContainer.new() - identity_row.add_theme_constant_override("separation", 8) - row.add_child(identity_row) - + var row := _make_row() var identity := Label.new() identity.clip_text = true identity.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS identity.size_flags_horizontal = Control.SIZE_EXPAND_FILL - var markers: Array[String] = [] - if entry.is_host: - markers.append("host") + identity.text = entry.display_name + var roles: Array[String] = ["host" if entry.is_host else "player"] if entry.is_operator: - markers.append("operator") + roles.append("operator") if entry.is_local_player: - markers.append("You") - identity.text = "%s%s · %s\n%s" % [ - entry.display_name, - " [%s]" % ", ".join(markers) if not markers.is_empty() else "", - entry.compact_fingerprint, + roles.append("you") + _configure_identity_tooltip( + identity, + "identity fingerprint:\n%s\nrole: %s\nidentity status: %s" % [ + NetworkIdentityCrypto.format_fingerprint(entry.full_fingerprint), + ", ".join(roles), entry.continuity_state, - ] - identity.tooltip_text = "Full identity fingerprint:\n%s" % ( - entry.full_fingerprint + ], ) identity.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY ) - identity_row.add_child(identity) + row.add_child(identity) var ping := Label.new() ping.custom_minimum_size.x = 72 ping.text = ( @@ -542,7 +545,7 @@ func _build_active_player_row(entry: PlayerListEntry) -> void: "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY ) ping.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT - identity_row.add_child(ping) + row.add_child(ping) var actions := HBoxContainer.new() actions.alignment = BoxContainer.ALIGNMENT_END @@ -634,31 +637,38 @@ func _configure_moderation_button( func _build_session_artwork_controls() -> void: - var counts: Vector2i = _service.get_session_artwork_counts() var row := _make_row() - 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 = "session artwork · %d layers · %d painted pixels" % [ - counts.x, counts.y, - ] - label.add_theme_color_override( - "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY - ) - row.add_child(label) + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + spacer.mouse_filter = Control.MOUSE_FILTER_IGNORE + row.add_child(spacer) var reset := Button.new() - reset.text = "reset paint" - reset.disabled = counts.x == 0 - reset.tooltip_text = "Clears all shared artwork from this session." - reset.pressed.connect(func() -> void: + _configure_session_artwork_reset_button(reset) + _update_session_artwork_reset_button(reset) + row.add_child(reset) + + +func _configure_session_artwork_reset_button(button: Button) -> void: + button.text = "reset paint" + button.tooltip_text = "Clears all shared artwork from this session." + button.pressed.connect(func() -> void: _confirm( "Clear all shared paint from this session?\nThis cannot be undone.", _service.reset_session_artwork, ) ) - UtilityPageStyle.apply_compact_ocean_button(reset) - row.add_child(reset) + UtilityPageStyle.apply_compact_ocean_button(button) + + +func _update_session_artwork_reset_button(button: Button) -> void: + if button == null or _service == null: + return + button.disabled = _service.get_session_artwork_counts().x == 0 + + +func _configure_identity_tooltip(label: Label, text: String) -> void: + label.mouse_filter = Control.MOUSE_FILTER_STOP + label.tooltip_text = text func _build_relationship_rows() -> void: @@ -673,12 +683,16 @@ func _build_relationship_rows() -> void: label.clip_text = true label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS var fingerprint := str(record["fingerprint"]) - label.text = "%s · %s %s" % [ + label.text = "%s %s" % [ str(record.get("last_known_display_name", "Player")), - NetworkIdentityCrypto.compact_suffix(fingerprint), "Blocked" if bool(record.get("blocked", false)) else "Muted", ] - label.tooltip_text = NetworkIdentityCrypto.format_fingerprint(fingerprint) + _configure_identity_tooltip( + label, + "identity fingerprint:\n%s" % ( + NetworkIdentityCrypto.format_fingerprint(fingerprint) + ), + ) label.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY ) @@ -724,16 +738,20 @@ func _build_friend_rows() -> void: 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" % [ + label.text = "%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) + _configure_identity_tooltip( + label, + "identity fingerprint:\n%s" % ( + NetworkIdentityCrypto.format_fingerprint(fingerprint) + ), + ) label.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY ) @@ -811,12 +829,16 @@ func _build_ban_rows() -> void: label.size_flags_horizontal = Control.SIZE_EXPAND_FILL label.clip_text = true label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS - label.text = "%s · %s banned %s" % [ + label.text = "%s banned %s" % [ str(record.get("last_known_display_name", "Player")), - NetworkIdentityCrypto.compact_suffix(fingerprint), Time.get_date_string_from_unix_time(int(record.get("banned_unix", 0))), ] - label.tooltip_text = NetworkIdentityCrypto.format_fingerprint(fingerprint) + _configure_identity_tooltip( + label, + "identity fingerprint:\n%s" % ( + NetworkIdentityCrypto.format_fingerprint(fingerprint) + ), + ) label.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY ) @@ -841,20 +863,6 @@ func _make_row() -> HBoxContainer: return row -func _make_active_player_row() -> VBoxContainer: - var panel := PanelContainer.new() - panel.clip_contents = true - panel.add_theme_stylebox_override( - "panel", UtilityPageStyle.row_style(false) - ) - _list.add_child(panel) - var row := VBoxContainer.new() - row.custom_minimum_size.y = 88 - row.add_theme_constant_override("separation", 4) - panel.add_child(row) - return row - - func _add_empty(text: String) -> void: var label := Label.new() label.text = text diff --git a/ui/save_slots_page.gd b/ui/save_slots_page.gd index e50c8ee..ba142f7 100644 --- a/ui/save_slots_page.gd +++ b/ui/save_slots_page.gd @@ -124,6 +124,12 @@ func _configure_style() -> void: if node is OrganizerTab: continue UtilityPageStyle.apply_ocean_button(node as BaseButton) + _play_button.add_theme_stylebox_override( + "normal", + UtilityPageStyle.ocean_button_style( + UtilityPageStyle.GREEN, + ), + ) _delete_button.add_theme_stylebox_override( "normal", UtilityPageStyle.ocean_button_style( @@ -639,11 +645,11 @@ func _configure_controller_focus() -> void: _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(_play_button, _import_button, _play_button, _slot_name_edit, _rename_button) + _set_neighbors(_rename_button, _import_button, _duplicate_button, _play_button, _export_button) + _set_neighbors(_duplicate_button, _rename_button, _duplicate_button, _play_button, _delete_button) + _set_neighbors(_export_button, _import_button, _delete_button, _rename_button, _back_button) + _set_neighbors(_delete_button, _export_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) diff --git a/ui/save_slots_page.tscn b/ui/save_slots_page.tscn index 5f47c8e..87d5f93 100644 --- a/ui/save_slots_page.tscn +++ b/ui/save_slots_page.tscn @@ -165,11 +165,10 @@ scroll_active = false autowrap_mode = 2 vertical_alignment = 1 -[node name="Actions" type="GridContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn"] +[node name="Actions" type="VBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn"] +unique_name_in_owner = true layout_mode = 2 -theme_override_constants/h_separation = 10 -theme_override_constants/v_separation = 10 -columns = 2 +theme_override_constants/separation = 10 [node name="PlaySlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions"] unique_name_in_owner = true @@ -179,7 +178,14 @@ size_flags_horizontal = 3 focus_mode = 2 text = "play" -[node name="RenameSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions"] +[node name="SecondaryActions" type="GridContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_constants/h_separation = 10 +theme_override_constants/v_separation = 10 +columns = 2 + +[node name="RenameSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions/SecondaryActions"] unique_name_in_owner = true custom_minimum_size = Vector2(0, 46) layout_mode = 2 @@ -187,7 +193,7 @@ size_flags_horizontal = 3 focus_mode = 2 text = "rename" -[node name="DuplicateSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions"] +[node name="DuplicateSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions/SecondaryActions"] unique_name_in_owner = true custom_minimum_size = Vector2(0, 46) layout_mode = 2 @@ -195,7 +201,7 @@ size_flags_horizontal = 3 focus_mode = 2 text = "duplicate" -[node name="ExportSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions"] +[node name="ExportSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions/SecondaryActions"] unique_name_in_owner = true custom_minimum_size = Vector2(0, 46) layout_mode = 2 @@ -203,7 +209,7 @@ size_flags_horizontal = 3 focus_mode = 2 text = "export" -[node name="DeleteSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions"] +[node name="DeleteSlotButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/SavesPage/DetailsColumn/Actions/SecondaryActions"] unique_name_in_owner = true custom_minimum_size = Vector2(0, 46) layout_mode = 2 diff --git a/ui/title_screen.gd b/ui/title_screen.gd index 6274f7d..07a61fd 100644 --- a/ui/title_screen.gd +++ b/ui/title_screen.gd @@ -464,6 +464,7 @@ func _update_title_layout() -> void: Vector2(field_width, field_height), compact_layout ) + _configure_title_controller_navigation() if not _title_settings_transition_active: call_deferred("_capture_title_bubble_rest_position") if _awaiting_start_input and not _title_entry_transition_active: @@ -1483,9 +1484,28 @@ func _set_title_bubbles_interactive(interactive: bool) -> void: else Control.MOUSE_FILTER_IGNORE ) if interactive: - ControllerFocusNavigationType.configure_spatial_neighbors( - _get_title_buttons() - ) + _configure_title_controller_navigation() + + +func _configure_title_controller_navigation() -> void: + ControllerFocusNavigationType.configure_spatial_neighbors( + _get_title_buttons() + ) + # The large Play bubble overlaps the inward edge of every surrounding + # bubble. Center-only spatial scoring otherwise links the four outside + # bubbles into a ring with no route back into Play. + _join_game_button.focus_neighbor_right = ( + _join_game_button.get_path_to(_play_button) + ) + _settings_button.focus_neighbor_left = ( + _settings_button.get_path_to(_play_button) + ) + _credits_button.focus_neighbor_right = ( + _credits_button.get_path_to(_play_button) + ) + _quit_button.focus_neighbor_left = ( + _quit_button.get_path_to(_play_button) + ) func _capture_title_bubble_rest_position() -> void: From 219dbb5abcf186be7e7709ab35da9ce1c3380468 Mon Sep 17 00:00:00 2001 From: Voyager Date: Sun, 23 Aug 2026 23:11:30 -0400 Subject: [PATCH 09/44] chore: prepare v0.17.1-alpha release --- docs/README-PLAYTEST.txt | 4 ++-- export_presets.cfg | 26 +++++++++++++------------- project.godot | 2 +- scripts/build_playtest.sh | 6 +++--- ui/title_screen.tscn | 2 +- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/README-PLAYTEST.txt b/docs/README-PLAYTEST.txt index dbaa032..7b7e285 100644 --- a/docs/README-PLAYTEST.txt +++ b/docs/README-PLAYTEST.txt @@ -1,6 +1,6 @@ NETfishing -v0.17.0-alpha -Alpha 0.17.0 +v0.17.1-alpha +Alpha 0.17.1 Thank you for trying this early private playtest. diff --git a/export_presets.cfg b/export_presets.cfg index c484b38..cc5d56a 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -9,7 +9,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.17.0-alpha/windows-x86_64/NETfishing.exe" +export_path="builds/v0.17.1-alpha/windows-x86_64/NETfishing.exe" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -35,11 +35,11 @@ application/modify_resources=true application/icon="res://art/exported/system_icons/netfishing.ico" application/console_wrapper_icon="" application/icon_interpolation=4 -application/file_version="0.17.0.0" -application/product_version="0.17.0.0" +application/file_version="0.17.1.0" +application/product_version="0.17.1.0" application/company_name="Woofmeow" application/product_name="NETfishing" -application/file_description="NETfishing v0.17.0-alpha" +application/file_description="NETfishing v0.17.1-alpha" application/copyright="Copyright © 2026 Woofmeow" application/trademarks="NETfishing and Woofmeow branding is reserved; see TRADEMARKS.md" application/export_angle=0 @@ -62,7 +62,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.17.0-alpha/linux-arm64/NETfishing.arm64" +export_path="builds/v0.17.1-alpha/linux-arm64/NETfishing.arm64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -92,7 +92,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.17.0-alpha/macos/NETfishing.zip" +export_path="builds/v0.17.1-alpha/macos/NETfishing.zip" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -108,8 +108,8 @@ custom_template/release="" application/bundle_identifier="io.woofmeow.netfishing" application/icon="res://art/exported/system_icons/netfishing_1024.png" application/icon_interpolation=0 -application/short_version="0.17.0" -application/version="0.17.0" +application/short_version="0.17.1" +application/version="0.17.1" application/architecture="universal" codesign/enable=false notarization/enable=false @@ -125,7 +125,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*,tests/*" -export_path="builds/v0.17.0-alpha/server-linux-x86_64/NETfishingServer.x86_64" +export_path="builds/v0.17.1-alpha/server-linux-x86_64/NETfishingServer.x86_64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -155,7 +155,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.17.0-alpha/android/NETfishing.apk" +export_path="builds/v0.17.1-alpha/android/NETfishing.apk" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -176,8 +176,8 @@ architectures/armeabi-v7a=false architectures/arm64-v8a=true architectures/x86=false architectures/x86_64=false -version/code=170000 -version/name="v0.17.0-alpha" +version/code=170100 +version/name="v0.17.1-alpha" package/unique_name="io.woofmeow.netfishing" package/name="NETfishing" package/signed=true @@ -214,7 +214,7 @@ custom_features="" export_filter="all_resources" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" exclude_filter="builds/*,scripts/*" -export_path="builds/v0.17.0-alpha/linux-x86_64/NETfishing.x86_64" +export_path="builds/v0.17.1-alpha/linux-x86_64/NETfishing.x86_64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" diff --git a/project.godot b/project.godot index 8a49214..c0a7537 100644 --- a/project.godot +++ b/project.godot @@ -11,7 +11,7 @@ config_version=5 [application] config/name="NETFISHING" -config/version="0.17.0-alpha" +config/version="0.17.1-alpha" run/main_scene="res://main/main.tscn" config/features=PackedStringArray("4.7", "GL Compatibility") config/icon="res://art/exported/system_icons/netfishing_256.png" diff --git a/scripts/build_playtest.sh b/scripts/build_playtest.sh index 74a0c8c..ff0628c 100755 --- a/scripts/build_playtest.sh +++ b/scripts/build_playtest.sh @@ -4,14 +4,14 @@ set -euo pipefail readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" -readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.17.0-alpha" +readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.17.1-alpha" readonly WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64" readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64" readonly README_SOURCE="${PROJECT_ROOT}/docs/README-PLAYTEST.txt" readonly SOURCE_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)" readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/netfishing" -readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.17.0-alpha-windows-x86_64.zip" -readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.17.0-alpha-linux-x86_64.zip" +readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.17.1-alpha-windows-x86_64.zip" +readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.17.1-alpha-linux-x86_64.zip" readonly GODOT_BIN="${GODOT_BIN:-godot}" if [[ ! -f "${PROJECT_ROOT}/project.godot" ]]; then diff --git a/ui/title_screen.tscn b/ui/title_screen.tscn index 03275f3..c69f87b 100644 --- a/ui/title_screen.tscn +++ b/ui/title_screen.tscn @@ -227,7 +227,7 @@ unique_name_in_owner = true layout_mode = 2 theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1) theme_override_font_sizes/font_size = 22 -text = "v0.17.0-alpha" +text = "v0.17.1-alpha" horizontal_alignment = 1 [node name="Spacer" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"] From a1ea341e8c8ac952e026df32d17fa4fd44f8646f Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 24 Aug 2026 10:36:16 -0400 Subject: [PATCH 10/44] Fix standby lure chat input ownership --- fishing/fishing_spot.gd | 7 +++++-- tests/art_tools_validation.gd | 11 +++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index 0fdc5f7..2559af5 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -781,7 +781,10 @@ func confirm_pending_bite() -> void: func _set_bite_confirmation_pending(is_pending: bool) -> void: if is_pending: - _set_fishing_input_priority(true) + # The Standby deliberately pauses before the fight begins. Keep chat and + # menus available during that pause; fishing only owns input once the + # player confirms the bite and enters the actual minigame. + _set_fishing_input_priority(false) if _bite_confirmation_pending == is_pending: if not is_pending: _bite_confirmation_requested = false @@ -1142,7 +1145,6 @@ 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") @@ -1155,6 +1157,7 @@ func _activate_bite(confirmation_override: bool = false) -> void: _presentation.show_bite() return + _set_fishing_input_priority(true) _set_bite_confirmation_pending(false) _pending_catch = _fish_selector.create_catch(_selected_fish) if _pending_catch == null or not _pending_catch.is_valid(): diff --git a/tests/art_tools_validation.gd b/tests/art_tools_validation.gd index 3bc4633..3b9d6a3 100644 --- a/tests/art_tools_validation.gd +++ b/tests/art_tools_validation.gd @@ -305,6 +305,17 @@ func _run() -> void: assert(on_screen_keyboard.is_open()) on_screen_keyboard.call("_close_keyboard", true) assert(typed_chat_entry.has_focus()) + + # A deferred Standby bite has not started the fishing minigame yet. It must + # release fishing's input ownership so chat remains available until the + # player confirms the bite. + chat_fishing_spot.call("_set_fishing_input_priority", true) + chat_fishing_spot.call("_set_bite_confirmation_pending", true) + for _frame: int in 4: + await process_frame + assert(not chat_fishing_spot.is_fishing_input_priority_active()) + assert(chat_ui.is_open()) + chat_fishing_spot.call("_set_bite_confirmation_pending", false) var left_bumper := InputEventJoypadButton.new() left_bumper.button_index = JOY_BUTTON_LEFT_SHOULDER left_bumper.pressed = true From c65176b99fc4baad321acc476920e65c629efc18 Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 24 Aug 2026 10:36:17 -0400 Subject: [PATCH 11/44] Fix airborne sitting and remote animation playback --- network/network_session.gd | 4 +- player/player.gd | 62 +++++++++++++++++++++--- tests/movement_multiplayer_validation.gd | 48 ++++++++++++++++++ 3 files changed, 105 insertions(+), 9 deletions(-) diff --git a/network/network_session.gd b/network/network_session.gd index b889678..2711976 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -2069,13 +2069,13 @@ func _maybe_send_local_animation_action() -> void: str(action["id"]), int(action["sequence"]), bool(action.get("paused", false)), - avatar.get_network_sitting_state(), + avatar.get_network_sitting_intent(), ] if signature == _last_local_animation_action_signature: return var encoded: Array = _encode_movement_animation_action( action, - avatar.get_network_sitting_state(), + avatar.get_network_sitting_intent(), ) if encoded.is_empty(): return diff --git a/player/player.gd b/player/player.gd index 860caf3..5ae4cac 100644 --- a/player/player.gd +++ b/player/player.gd @@ -1036,7 +1036,13 @@ func _simulate_movement_physics(delta: float) -> void: or (_network_authoritative_simulation and _network_jump_pending) ) ) - if _sit_after_landing and is_on_floor(): + # Sitting deliberately stops movement processing, so never allow a stale + # grounded flag or a reconciled network state to leave an airborne avatar in + # that early-return path. Preserve the intent and sit once landing is real. + if _sitting and not _can_begin_sitting(): + _sit_after_landing = true + _set_sitting(false) + if _sit_after_landing and not jump_requested and _can_begin_sitting(): _sit_after_landing = false _set_sitting(true, local_control_enabled) if _sitting: @@ -1489,8 +1495,20 @@ func _update_character_animation() -> void: _presented_animation_action_id = &"" _presented_animation_action_sequence = -1 _presented_animation_action_paused = false - if _character_animation_name == next_animation and not action_changed: + if ( + _character_animation_playback_matches( + next_animation, + action_selected and animation_action_paused, + ) + and not action_changed + ): return + # The animation name is only a selection cache. A remote player's + # AnimationPlayer can occasionally stop or lose its assigned animation while + # its replicated locomotion state remains valid. Reassert the selected + # presentation here instead of waiting for another network state transition; + # movement simulation and replication remain untouched. + _character_animation_player.active = true _character_animation_player.play(next_animation) _character_animation_name = next_animation if action_selected: @@ -1513,6 +1531,26 @@ func _update_character_animation() -> void: _character_animation_player.pause() +func _character_animation_playback_matches( + animation_name: StringName, + paused_action_selected: bool, +) -> bool: + if ( + _character_animation_name != animation_name + or _character_animation_player.assigned_animation != animation_name + or not _character_animation_player.active + ): + return false + if paused_action_selected: + return true + var animation := _character_animation_player.get_animation(animation_name) + if animation == null or animation.loop_mode == Animation.LOOP_NONE: + # Completed one-shot actions deliberately keep their final pose until the + # authoritative action state advances; they must not be restarted here. + return true + return _character_animation_player.is_playing() + + func _on_character_animation_finished(animation_name: StringName) -> void: if animation_name == CHARACTER_NET_STRIKE_ANIMATION: if local_control_enabled: @@ -1610,7 +1648,7 @@ func toggle_sitting() -> void: ]) ): return - if not is_on_floor(): + if not _can_begin_sitting(): _sit_after_landing = true return _set_sitting(should_sit, local_control_enabled) @@ -1645,7 +1683,15 @@ func is_sitting() -> bool: return _sitting +func _can_begin_sitting() -> bool: + return is_on_floor() and velocity.y <= 0.0 + + func get_network_sitting_state() -> bool: + return _sitting + + +func get_network_sitting_intent() -> bool: return _sitting or _sit_after_landing @@ -1654,7 +1700,9 @@ func apply_network_sitting_state(should_sit: bool) -> void: func apply_authoritative_network_sitting_state(should_sit: bool) -> void: - if should_sit and not is_on_floor(): + if should_sit and ( + not _can_begin_sitting() or _network_jump_pending + ): _sit_after_landing = true _set_sitting(false) return @@ -2038,7 +2086,7 @@ func capture_network_input(sequence: int) -> Dictionary: "sprint": false, "sneak": false, "slow_walk": false, - "sitting": get_network_sitting_state(), + "sitting": get_network_sitting_intent(), "casting": ( _fishing_visual_phase == FishingVisualPhase.CASTING ), @@ -2063,7 +2111,7 @@ 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": get_network_sitting_state(), + "sitting": get_network_sitting_intent(), "casting": _fishing_visual_phase == FishingVisualPhase.CASTING, "animation_action": _make_animation_action_state(), } @@ -2111,7 +2159,7 @@ func get_network_input_state_hash() -> int: Input.is_action_pressed("sprint"), Input.is_action_pressed("sneak"), Input.is_action_pressed("slow_walk"), - get_network_sitting_state(), + get_network_sitting_intent(), _fishing_visual_phase == FishingVisualPhase.CASTING, _animation_action_id, _animation_action_sequence, diff --git a/tests/movement_multiplayer_validation.gd b/tests/movement_multiplayer_validation.gd index c6a98e5..c9af294 100644 --- a/tests/movement_multiplayer_validation.gd +++ b/tests/movement_multiplayer_validation.gd @@ -38,7 +38,9 @@ func _validate_latency_smoothing() -> void: _validate_animation_action_ordering(avatar) _validate_transit_estimation() _validate_remote_snapshot_smoothing(avatar) + _validate_remote_locomotion_playback_recovery(avatar) _validate_reliable_jump_intent(avatar) + _validate_airborne_sitting(avatar) _validate_stale_input_expiry(avatar) avatar.queue_free() await process_frame @@ -141,6 +143,26 @@ func _validate_compact_animation_encoding() -> void: ) +func _validate_airborne_sitting(avatar: Player) -> void: + avatar.reset_network_movement_state() + avatar.set("_sit_after_landing", true) + assert(avatar.get_network_sitting_intent()) + assert(not avatar.get_network_sitting_state()) + assert(not bool(avatar.make_network_snapshot(2)["sitting"])) + assert(bool(avatar.capture_network_input(1)["sitting"])) + + # Even a malformed or stale reconciliation that marks an airborne avatar as + # seated must not bypass gravity and freeze it in place. + avatar.set("_sit_after_landing", false) + avatar.call("_set_sitting", true) + avatar.velocity = Vector3(0.0, 4.0, 0.0) + avatar.call("_simulate_movement_physics", 0.1) + assert(not avatar.is_sitting()) + assert(bool(avatar.get("_sit_after_landing"))) + assert(avatar.velocity.y < 4.0) + avatar.reset_network_movement_state() + + func _validate_animation_action_ordering(avatar: Player) -> void: var draw := NetworkPlayerAnimationProtocol.make_action_state( &"draw", 5, 0.2 @@ -352,6 +374,32 @@ func _validate_remote_snapshot_smoothing(avatar: Player) -> void: ) +func _validate_remote_locomotion_playback_recovery(avatar: Player) -> void: + avatar.configure_network_remote(false) + avatar.push_network_snapshot( + _network_snapshot(Vector3.ZERO, Vector3(4.5, 0.0, 0.0), 21) + ) + avatar.call("_update_character_animation") + var animation_player := avatar.get_node( + "Visuals/CharacterRig/AnimationPlayer" + ) as AnimationPlayer + assert(animation_player.assigned_animation == &"running") + assert(animation_player.is_playing()) + + # Reproduce issue #111: locomotion replication still says RUNNING (and the + # dust therefore still emits), but the rig playback has fallen idle. The + # local presentation pass must repair that state without another packet. + animation_player.stop() + assert(not animation_player.is_playing()) + assert( + int(avatar.get("_network_target_locomotion_state")) + == Player.LocomotionState.RUNNING + ) + avatar.call("_update_character_animation") + assert(animation_player.assigned_animation == &"running") + assert(animation_player.is_playing()) + + func _validate_reliable_jump_intent(avatar: Player) -> void: avatar.set_local_control(true) avatar.call("_queue_local_network_jump_intent") From f1c952f5d6fb2113cb5a77783765dd243495d821 Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 24 Aug 2026 10:36:17 -0400 Subject: [PATCH 12/44] Clarify hotbar zoom control labels --- settings/controller_mapping_manager.gd | 4 ++-- settings/keyboard_mouse_mapping_manager.gd | 4 ++-- tests/controller_mapping_validation.gd | 16 ++++++++++++++++ tests/keyboard_mouse_mapping_validation.gd | 10 ++++++++++ 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/settings/controller_mapping_manager.gd b/settings/controller_mapping_manager.gd index 8c17e7f..278022e 100644 --- a/settings/controller_mapping_manager.gd +++ b/settings/controller_mapping_manager.gd @@ -84,7 +84,7 @@ const ROLE_LABELS: Dictionary = { ROLE_LB: "focus chat or world", ROLE_RB: "primary action", ROLE_POINTER_MODIFIER: "virtual mouse modifier", - ROLE_CAMERA_ZOOM: "camera zoom", + ROLE_CAMERA_ZOOM: "zoom modifier (+RT)", ROLE_SELECT: "chat", ROLE_START: "pause", ROLE_LEFT_STICK_CLICK: "sprint", @@ -96,7 +96,7 @@ const ROLE_LABELS: Dictionary = { ROLE_LEFT_STICK_X: "move left / right", ROLE_LEFT_STICK_Y: "move up / down", ROLE_RIGHT_STICK_X: "camera left / right", - ROLE_RIGHT_STICK_Y: "camera up / down", + ROLE_RIGHT_STICK_Y: "camera up / down (+RT to zoom)", } const ROLE_PROMPTS: Dictionary = { diff --git a/settings/keyboard_mouse_mapping_manager.gd b/settings/keyboard_mouse_mapping_manager.gd index 591bc52..54dacc8 100644 --- a/settings/keyboard_mouse_mapping_manager.gd +++ b/settings/keyboard_mouse_mapping_manager.gd @@ -86,8 +86,8 @@ const ROLE_LABELS: Dictionary = { ROLE_PRIMARY_ACTION: "primary action", ROLE_ALTERNATE_REEL: "alternate reel", ROLE_CAMERA_DRAG: "rotate camera", - ROLE_CAMERA_ZOOM_IN: "camera zoom in", - ROLE_CAMERA_ZOOM_OUT: "camera zoom out", + ROLE_CAMERA_ZOOM_IN: "hotbar select (+SHIFT to zoom)", + ROLE_CAMERA_ZOOM_OUT: "hotbar select (+SHIFT to zoom)", ROLE_PLAYER_MENU: "player menu", ROLE_TACKLE_BOX: "tackle box", ROLE_PROP_BOOK: "prop book", diff --git a/tests/controller_mapping_validation.gd b/tests/controller_mapping_validation.gd index b93d475..3a4645a 100644 --- a/tests/controller_mapping_validation.gd +++ b/tests/controller_mapping_validation.gd @@ -14,6 +14,22 @@ func _init() -> void: func _run() -> void: + if ( + ControllerMappingManagerType.ROLE_LABELS[ + ControllerMappingManagerType.ROLE_CAMERA_ZOOM + ] != "zoom modifier (+RT)" + ): + push_error("controller zoom modifier label is misleading") + quit(1) + return + if ( + ControllerMappingManagerType.ROLE_LABELS[ + ControllerMappingManagerType.ROLE_RIGHT_STICK_Y + ] != "camera up / down (+RT to zoom)" + ): + push_error("controller vertical camera label omits the RT zoom chord") + quit(1) + return var manager := ControllerMappingManagerType.new() root.add_child(manager) await process_frame diff --git a/tests/keyboard_mouse_mapping_validation.gd b/tests/keyboard_mouse_mapping_validation.gd index db0bb71..1b95e01 100644 --- a/tests/keyboard_mouse_mapping_validation.gd +++ b/tests/keyboard_mouse_mapping_validation.gd @@ -38,6 +38,16 @@ func _run() -> void: KeyboardMouseMappingManagerType.ROLE_INTERACT ) == "e" ) + assert( + KeyboardMouseMappingManagerType.ROLE_LABELS[ + KeyboardMouseMappingManagerType.ROLE_CAMERA_ZOOM_IN + ] == "hotbar select (+SHIFT to zoom)" + ) + assert( + KeyboardMouseMappingManagerType.ROLE_LABELS[ + KeyboardMouseMappingManagerType.ROLE_CAMERA_ZOOM_OUT + ] == "hotbar select (+SHIFT to zoom)" + ) assert( str(defaults[ str(KeyboardMouseMappingManagerType.ROLE_PRIMARY_ACTION) From 5ed4cccf76320bb8f5b01ef612c994633553d049 Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 24 Aug 2026 10:36:45 -0400 Subject: [PATCH 13/44] Anchor beetles to generated tree surfaces --- gathering/catalog/beetle_stag_common.tres | 2 + gathering/gatherable_data.gd | 24 ++ network/network_world_spawn_service.gd | 15 +- tests/generated_world_runtime_validation.gd | 47 ++-- tests/tree_gathering_prototype_validation.gd | 67 +++++ tests/world_spawn_protocol_validation.gd | 2 + world/generation/generated_world_region.gd | 48 ++-- .../generation/mesh_surface_anchor_sampler.gd | 260 ++++++++++++++++++ .../mesh_surface_anchor_sampler.gd.uid | 1 + .../props/definitions/prop_palm.tres | 1 + .../props/definitions/prop_pine.tres | 2 +- .../props/definitions/prop_pine_large.tres | 2 +- .../props/definitions/prop_tree_1.tres | 2 +- .../props/definitions/prop_tree_2.tres | 2 +- .../props/definitions/prop_tree_3.tres | 2 +- .../props/definitions/prop_tree_large.tres | 2 +- world/generation/terrain_prop_catalog.gd | 7 +- world/generation/terrain_prop_definition.gd | 31 ++- 18 files changed, 446 insertions(+), 71 deletions(-) create mode 100644 world/generation/mesh_surface_anchor_sampler.gd create mode 100644 world/generation/mesh_surface_anchor_sampler.gd.uid diff --git a/gathering/catalog/beetle_stag_common.tres b/gathering/catalog/beetle_stag_common.tres index 118d733..65b15df 100644 --- a/gathering/catalog/beetle_stag_common.tres +++ b/gathering/catalog/beetle_stag_common.tres @@ -10,6 +10,8 @@ catch_data = ExtResource("2_catch") required_tool_id = &"crab_net" spawn_anchor_set_id = &"starter_reachable_tree_trunks" population = 3 +spawn_anchor_occupancy_ratio = 0.35 +maximum_anchor_population = 64 requires_sneaking = false movement_speed = 0.0 roam_radius = 0.1 diff --git a/gathering/gatherable_data.gd b/gathering/gatherable_data.gd index 9f984cf..d60dcc9 100644 --- a/gathering/gatherable_data.gd +++ b/gathering/gatherable_data.gd @@ -18,6 +18,11 @@ enum PresentationMode { @export var spawn_anchor_set_id: StringName @export_range(-100.0, 100.0, 0.01) var minimum_surface_y: float = 0.08 @export_range(0, 64, 1) var population: int = 0 +## Anchored gatherables can scale with authored/generated attachment geometry. +## Zero preserves the fixed population above. +@export_range(0.0, 1.0, 0.01) var spawn_anchor_occupancy_ratio := 0.0 +## Zero leaves the anchor count as the only ceiling. +@export_range(0, 256, 1) var maximum_anchor_population := 0 @export var presentation_mode: PresentationMode = PresentationMode.VISIBLE_CREATURE @export var requires_sneaking: bool = true @export_range(0.0, 120.0, 0.1) var active_lifetime_seconds: float = 0.0 @@ -78,6 +83,10 @@ func is_valid() -> bool: == FishDataType.CollectionMethod.DIGGING ) and population > 0 + and ( + maximum_anchor_population == 0 + or maximum_anchor_population >= population + ) and movement_parameters_valid and _quality_multipliers_are_valid( quality_movement_speed_multipliers @@ -102,6 +111,21 @@ func is_stationary_spawn() -> bool: return is_stationary_hotspot() or not spawn_anchor_set_id.is_empty() +func target_population_for_anchor_count(anchor_count: int) -> int: + if spawn_anchor_set_id.is_empty() or spawn_anchor_occupancy_ratio <= 0.0: + return population + if anchor_count <= 0: + return 0 + var target := maxi( + population, + ceili(float(anchor_count) * spawn_anchor_occupancy_ratio), + ) + target = mini(target, anchor_count) + if maximum_anchor_population > 0: + target = mini(target, maximum_anchor_population) + return target + + func can_be_scared() -> bool: return not is_stationary_spawn() and scare_radius > 0.0 diff --git a/network/network_world_spawn_service.gd b/network/network_world_spawn_service.gd index 87814ee..b27dd80 100644 --- a/network/network_world_spawn_service.gd +++ b/network/network_world_spawn_service.gd @@ -294,7 +294,7 @@ func _begin_population_if_ready() -> void: _current_season() ): _cache_spawn_surface(entry) - for _spawn_index: int in entry.population: + for _spawn_index: int in _target_population(entry): _spawn_entity(entry) @@ -1383,11 +1383,22 @@ func _reconcile_seasonal_population() -> void: for entry: GatherableDataType in _catalog.get_available_entries(season): _cache_spawn_surface(entry) var current_population: int = _population_for_type(entry.type_id) - while current_population < entry.population: + var target_population := _target_population(entry) + while current_population < target_population: _spawn_entity(entry) current_population += 1 +func _target_population(entry: GatherableDataType) -> int: + if entry == null or entry.spawn_anchor_set_id.is_empty(): + return entry.population if entry != null else 0 + var anchors: PackedVector3Array = _spawn_anchor_positions.get( + entry.type_id, + PackedVector3Array(), + ) + return entry.target_population_for_anchor_count(anchors.size()) + + func _population_for_type(type_id: StringName) -> int: var count: int = 0 for state: Dictionary in _entities.values(): diff --git a/tests/generated_world_runtime_validation.gd b/tests/generated_world_runtime_validation.gd index 64b6ee1..6e03df7 100644 --- a/tests/generated_world_runtime_validation.gd +++ b/tests/generated_world_runtime_validation.gd @@ -1131,49 +1131,36 @@ func _validate_tree_gatherable_anchors( decorations: Node3D, anchors: GatherableAnchorSet3D, ) -> void: - var eligible_props: Array[Node3D] = [] + var eligible_props: Dictionary[StringName, Node3D] = {} for child: Node in decorations.get_children(): var prop := child as Node3D if prop == null: continue var prop_id := StringName(prop.get_meta(&"terrain_prop_id", &"")) var definition := region.get_prop_catalog().definition_for_id(prop_id) - if definition != null and definition.gatherable_anchor_height > 0.0: - eligible_props.append(prop) + if definition != null and definition.has_gatherable_surface(): + eligible_props[prop.name] = prop var positions := anchors.get_spawn_positions() assert(positions.size() == eligible_props.size()) - for prop: Node3D in eligible_props: + assert(positions.size() >= 12) + for child: Node in anchors.get_children(): + var anchor := child as Marker3D + assert(anchor != null) + assert(bool(anchor.get_meta(&"mesh_surface_sampled", false))) + var prop_name := StringName(anchor.get_meta(&"terrain_prop_name", &"")) + assert(eligible_props.has(prop_name)) + var prop: Node3D = eligible_props[prop_name] var prop_id := StringName(prop.get_meta(&"terrain_prop_id", &"")) var definition := region.get_prop_catalog().definition_for_id(prop_id) - var visual_scale := float( - prop.get_meta(&"terrain_prop_visual_scale", 1.0) - ) - var nearest_anchor := Vector3(INF, INF, INF) - var nearest_distance_squared := INF - for position: Vector3 in positions: - var distance_squared := position.distance_squared_to( - prop.global_position - ) - if distance_squared < nearest_distance_squared: - nearest_distance_squared = distance_squared - nearest_anchor = position - assert(nearest_anchor.is_finite()) - var horizontal_distance := Vector2( - nearest_anchor.x - prop.global_position.x, - nearest_anchor.z - prop.global_position.z, - ).length() + assert(definition != null and definition.has_gatherable_surface()) + var local_anchor := prop.to_local(anchor.global_position) assert( - horizontal_distance - >= definition.gatherable_anchor_surface_radius() * visual_scale + local_anchor.y + >= definition.gatherable_surface_minimum_height - 0.001 ) assert( - absf( - nearest_anchor.y - - ( - prop.global_position.y - + definition.gatherable_anchor_height * visual_scale - ) - ) <= 0.001 + local_anchor.y + <= definition.gatherable_surface_maximum_height + 0.001 ) diff --git a/tests/tree_gathering_prototype_validation.gd b/tests/tree_gathering_prototype_validation.gd index d97f67e..bb6d1df 100644 --- a/tests/tree_gathering_prototype_validation.gd +++ b/tests/tree_gathering_prototype_validation.gd @@ -7,6 +7,9 @@ const Gatherables: GatherableCatalog = preload( "res://gathering/catalog/gatherable_catalog.tres" ) const FishCatalog: FishPool = preload("res://fish/pools/fish_catalog.tres") +const MeshSurfaceAnchorSamplerType = preload( + "res://world/generation/mesh_surface_anchor_sampler.gd" +) func _initialize() -> void: @@ -15,6 +18,7 @@ func _initialize() -> void: func _run() -> void: _validate_beetle_data() + await _validate_mesh_surface_sampler() await _validate_tree_anchors() _validate_anchored_presentation() _validate_three_dimensional_targeting() @@ -32,11 +36,74 @@ func _validate_beetle_data() -> void: assert(beetle.required_tool_id == &"crab_net") assert(beetle.spawn_anchor_set_id == &"starter_reachable_tree_trunks") assert(beetle.population == 3) + assert(is_equal_approx(beetle.spawn_anchor_occupancy_ratio, 0.35)) + assert(beetle.maximum_anchor_population == 64) + assert(beetle.target_population_for_anchor_count(8) == 3) + assert(beetle.target_population_for_anchor_count(40) == 14) + assert(beetle.target_population_for_anchor_count(400) == 64) assert(is_equal_approx(beetle.sprite_pixel_size, 0.005)) assert(beetle.is_stationary_spawn()) assert(not beetle.can_be_scared()) +func _validate_mesh_surface_sampler() -> void: + var prop := Node3D.new() + # The accessibility band is local to the planted prop, so the same tree on + # a raised cliff remains reachable from that cliff's walkable surface. + prop.position = Vector3(3.0, 12.0, -4.0) + root.add_child(prop) + var visual := MeshInstance3D.new() + var box := BoxMesh.new() + box.size = Vector3(2.0, 4.0, 2.0) + var wood := StandardMaterial3D.new() + wood.resource_name = "wood" + box.material = wood + visual.mesh = box + visual.position.y = 2.0 + prop.add_child(visual) + await process_frame + var random := RandomNumberGenerator.new() + random.seed = 115 + var sample: Dictionary = MeshSurfaceAnchorSamplerType.sample_vertical_surface( + prop, + prop, + PackedStringArray(["wood"]), + 0.7, + 1.5, + 0.35, + 0.025, + random, + ) + assert(not sample.is_empty()) + var surface_position: Vector3 = sample["surface_position"] + var anchor_position: Vector3 = sample["position"] + assert(surface_position.y >= 0.7 and surface_position.y <= 1.5) + var world_anchor_position := prop.to_global(anchor_position) + assert( + world_anchor_position.y >= 12.7 + and world_anchor_position.y <= 13.5 + ) + assert(is_equal_approx( + maxf(absf(surface_position.x), absf(surface_position.z)), + 1.0, + )) + assert(is_equal_approx( + anchor_position.distance_to(surface_position), + 0.025, + )) + assert(MeshSurfaceAnchorSamplerType.sample_vertical_surface( + prop, + prop, + PackedStringArray(["leaf"]), + 0.7, + 1.5, + 0.35, + 0.025, + random, + ).is_empty()) + prop.queue_free() + + func _validate_tree_anchors() -> void: var region := StarterIslandScene.instantiate() as WorldRegion root.add_child(region) diff --git a/tests/world_spawn_protocol_validation.gd b/tests/world_spawn_protocol_validation.gd index f529389..29f7fde 100644 --- a/tests/world_spawn_protocol_validation.gd +++ b/tests/world_spawn_protocol_validation.gd @@ -73,6 +73,8 @@ func _validate_catalog_statuses() -> void: assert(beetle.catch_data.collection_method == FishData.CollectionMethod.NET) assert(beetle.required_tool_id == &"crab_net") assert(beetle.spawn_anchor_set_id == &"starter_reachable_tree_trunks") + assert(is_equal_approx(beetle.spawn_anchor_occupancy_ratio, 0.35)) + assert(beetle.maximum_anchor_population == 64) assert(beetle.is_stationary_spawn()) assert(not beetle.is_stationary_hotspot()) assert(not beetle.requires_sneaking) diff --git a/world/generation/generated_world_region.gd b/world/generation/generated_world_region.gd index 5adf173..06406de 100644 --- a/world/generation/generated_world_region.gd +++ b/world/generation/generated_world_region.gd @@ -9,6 +9,9 @@ const FishingShopInteractionType = preload( const PlayerStorageInteractionType = preload( "res://world/player_storage_interaction.gd" ) +const MeshSurfaceAnchorSamplerType = preload( + "res://world/generation/mesh_surface_anchor_sampler.gd" +) const WATER_BODY_SCENE: PackedScene = preload("res://world/water_body.tscn") const SALT_WATER_MATERIAL: Material = preload( "res://world/materials/stylized_water.tres" @@ -35,7 +38,6 @@ const PROP_CLUSTER_PLACEMENT_ATTEMPTS := 10 const PROP_MINIMUM_GROUND_CLEARANCE := 0.05 const PROP_CHANCE_SCALE := 10000 const PROP_SELECTION_WEIGHT_SCALE := 1000 -const GATHERABLE_ANCHOR_SURFACE_CLEARANCE := 0.02 const PROCEDURAL_PROP_GROUPS: Array[StringName] = [ &"grass_tree", &"grass_detail", @@ -681,25 +683,37 @@ func _instantiate_prop( definition.clearance_radius * visual_scale ) _placed_prop_groups.append(definition.procedural_group) - if definition.gatherable_anchor_height > 0.0: - var anchor := Marker3D.new() - anchor.name = "TreeAnchor_%d" % _tree_anchors.get_child_count() - var local_anchor_position := ( - definition.collision_offset * visual_scale - + Vector3( - 0.0, - definition.gatherable_anchor_height * visual_scale, - -( - definition.gatherable_anchor_surface_radius() - * visual_scale - + GATHERABLE_ANCHOR_SURFACE_CLEARANCE - ), + if definition.has_gatherable_surface(): + var surface_sample: Dictionary = ( + MeshSurfaceAnchorSamplerType.sample_vertical_surface( + visual_root, + prop, + definition.gatherable_surface_material_names, + definition.gatherable_surface_minimum_height, + definition.gatherable_surface_maximum_height, + definition.gatherable_surface_maximum_up_dot, + definition.gatherable_surface_clearance, + random, ) ) - anchor.position = prop.position + local_anchor_position.rotated( - Vector3.UP, - yaw, + if surface_sample.is_empty(): + push_warning( + "No reachable gatherable mesh surface found on %s." + % definition.stable_id + ) + return true + var anchor := Marker3D.new() + anchor.name = "TreeAnchor_%d" % _tree_anchors.get_child_count() + var local_anchor_position: Vector3 = surface_sample["position"] + anchor.position = _tree_anchors.to_local( + prop.to_global(local_anchor_position) ) + anchor.set_meta( + &"terrain_prop_id", + definition.stable_id, + ) + anchor.set_meta(&"terrain_prop_name", prop.name) + anchor.set_meta(&"mesh_surface_sampled", true) _tree_anchors.add_child(anchor) return true diff --git a/world/generation/mesh_surface_anchor_sampler.gd b/world/generation/mesh_surface_anchor_sampler.gd new file mode 100644 index 0000000..3f41f56 --- /dev/null +++ b/world/generation/mesh_surface_anchor_sampler.gd @@ -0,0 +1,260 @@ +class_name MeshSurfaceAnchorSampler +extends RefCounted + +const HEIGHT_EPSILON := 0.0001 + + +static func sample_vertical_surface( + visual_root: Node3D, + relative_root: Node3D, + material_names: PackedStringArray, + minimum_height: float, + maximum_height: float, + maximum_up_dot: float, + clearance: float, + random: RandomNumberGenerator, +) -> Dictionary: + if ( + visual_root == null + or relative_root == null + or material_names.is_empty() + or maximum_height <= minimum_height + or random == null + ): + return {} + var candidates: Array[Dictionary] = [] + _collect_candidates( + visual_root, + relative_root, + material_names, + minimum_height, + maximum_height, + clampf(maximum_up_dot, 0.0, 1.0), + candidates, + ) + while not candidates.is_empty(): + var candidate_index := _weighted_candidate_index(candidates, random) + var candidate: Dictionary = candidates[candidate_index] + candidates.remove_at(candidate_index) + var point := _sample_triangle_height_slice( + candidate["a"], + candidate["b"], + candidate["c"], + minimum_height, + maximum_height, + random, + ) + if not point.is_finite(): + continue + var normal: Vector3 = candidate["normal"] + var surface_normal := Vector3(normal.x, 0.0, normal.z).normalized() + if surface_normal.is_zero_approx(): + continue + return { + "position": point + surface_normal * maxf(clearance, 0.0), + "surface_position": point, + "surface_normal": surface_normal, + } + return {} + + +static func _collect_candidates( + node: Node, + relative_root: Node3D, + material_names: PackedStringArray, + minimum_height: float, + maximum_height: float, + maximum_up_dot: float, + candidates: Array[Dictionary], +) -> void: + var mesh_instance := node as MeshInstance3D + if mesh_instance != null and mesh_instance.mesh != null: + _collect_mesh_candidates( + mesh_instance, + relative_root, + material_names, + minimum_height, + maximum_height, + maximum_up_dot, + candidates, + ) + for child: Node in node.get_children(): + _collect_candidates( + child, + relative_root, + material_names, + minimum_height, + maximum_height, + maximum_up_dot, + candidates, + ) + + +static func _collect_mesh_candidates( + mesh_instance: MeshInstance3D, + relative_root: Node3D, + material_names: PackedStringArray, + minimum_height: float, + maximum_height: float, + maximum_up_dot: float, + candidates: Array[Dictionary], +) -> void: + var mesh := mesh_instance.mesh + var to_relative := ( + relative_root.global_transform.affine_inverse() + * mesh_instance.global_transform + ) + for surface_index: int in mesh.get_surface_count(): + if ( + mesh is ArrayMesh + and (mesh as ArrayMesh).surface_get_primitive_type(surface_index) + != Mesh.PRIMITIVE_TRIANGLES + ): + continue + var material := mesh.surface_get_material(surface_index) + if not _material_matches(material, material_names): + continue + var arrays := mesh.surface_get_arrays(surface_index) + var vertices := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array + var indices := arrays[Mesh.ARRAY_INDEX] as PackedInt32Array + if indices.is_empty(): + for vertex_index: int in range(0, vertices.size() - 2, 3): + _add_triangle_candidate( + to_relative * vertices[vertex_index], + to_relative * vertices[vertex_index + 1], + to_relative * vertices[vertex_index + 2], + minimum_height, + maximum_height, + maximum_up_dot, + candidates, + ) + continue + for index_offset: int in range(0, indices.size() - 2, 3): + _add_triangle_candidate( + to_relative * vertices[indices[index_offset]], + to_relative * vertices[indices[index_offset + 1]], + to_relative * vertices[indices[index_offset + 2]], + minimum_height, + maximum_height, + maximum_up_dot, + candidates, + ) + + +static func _material_matches( + material: Material, + material_names: PackedStringArray, +) -> bool: + if material == null: + return false + var candidate_name := material.resource_name.to_lower() + for configured_name: String in material_names: + if candidate_name == configured_name.to_lower(): + return true + return false + + +static func _add_triangle_candidate( + a: Vector3, + b: Vector3, + c: Vector3, + minimum_height: float, + maximum_height: float, + maximum_up_dot: float, + candidates: Array[Dictionary], +) -> void: + var cross := (b - a).cross(c - a) + var doubled_area := cross.length() + if doubled_area <= HEIGHT_EPSILON: + return + var normal := cross / doubled_area + if absf(normal.dot(Vector3.UP)) > maximum_up_dot: + return + var triangle_minimum := minf(a.y, minf(b.y, c.y)) + var triangle_maximum := maxf(a.y, maxf(b.y, c.y)) + var overlap := ( + minf(triangle_maximum, maximum_height) + - maxf(triangle_minimum, minimum_height) + ) + if overlap <= HEIGHT_EPSILON: + return + candidates.append({ + "a": a, + "b": b, + "c": c, + "normal": normal, + "weight": doubled_area * 0.5 * overlap, + }) + + +static func _weighted_candidate_index( + candidates: Array[Dictionary], + random: RandomNumberGenerator, +) -> int: + var total_weight := 0.0 + for candidate: Dictionary in candidates: + total_weight += float(candidate.get("weight", 0.0)) + if total_weight <= 0.0: + return random.randi_range(0, candidates.size() - 1) + var roll := random.randf() * total_weight + var cumulative := 0.0 + for index: int in candidates.size(): + cumulative += float(candidates[index].get("weight", 0.0)) + if roll <= cumulative: + return index + return candidates.size() - 1 + + +static func _sample_triangle_height_slice( + a: Vector3, + b: Vector3, + c: Vector3, + minimum_height: float, + maximum_height: float, + random: RandomNumberGenerator, +) -> Vector3: + var slice_minimum := maxf(minimum_height, minf(a.y, minf(b.y, c.y))) + var slice_maximum := minf(maximum_height, maxf(a.y, maxf(b.y, c.y))) + if slice_maximum - slice_minimum <= HEIGHT_EPSILON: + return Vector3(INF, INF, INF) + var target_height := random.randf_range(slice_minimum, slice_maximum) + var intersections := PackedVector3Array() + _append_edge_intersection(a, b, target_height, intersections) + _append_edge_intersection(b, c, target_height, intersections) + _append_edge_intersection(c, a, target_height, intersections) + if intersections.size() < 2: + return Vector3(INF, INF, INF) + var first := intersections[0] + var second := intersections[1] + var greatest_distance := first.distance_squared_to(second) + for first_index: int in intersections.size(): + for second_index: int in range(first_index + 1, intersections.size()): + var distance := intersections[first_index].distance_squared_to( + intersections[second_index] + ) + if distance > greatest_distance: + greatest_distance = distance + first = intersections[first_index] + second = intersections[second_index] + return first.lerp(second, random.randf()) + + +static func _append_edge_intersection( + a: Vector3, + b: Vector3, + height: float, + intersections: PackedVector3Array, +) -> void: + var minimum := minf(a.y, b.y) + var maximum := maxf(a.y, b.y) + if height < minimum - HEIGHT_EPSILON or height > maximum + HEIGHT_EPSILON: + return + var height_delta := b.y - a.y + if absf(height_delta) <= HEIGHT_EPSILON: + return + var weight := clampf((height - a.y) / height_delta, 0.0, 1.0) + var point := a.lerp(b, weight) + for existing: Vector3 in intersections: + if existing.distance_squared_to(point) <= HEIGHT_EPSILON * HEIGHT_EPSILON: + return + intersections.append(point) diff --git a/world/generation/mesh_surface_anchor_sampler.gd.uid b/world/generation/mesh_surface_anchor_sampler.gd.uid new file mode 100644 index 0000000..a56caf8 --- /dev/null +++ b/world/generation/mesh_surface_anchor_sampler.gd.uid @@ -0,0 +1 @@ +uid://dlen7b42nxpjv diff --git a/world/generation/props/definitions/prop_palm.tres b/world/generation/props/definitions/prop_palm.tres index 4d1d344..e586f3d 100644 --- a/world/generation/props/definitions/prop_palm.tres +++ b/world/generation/props/definitions/prop_palm.tres @@ -23,3 +23,4 @@ local_overhang_direction = Vector2(-0.883, 0.469) ocean_facing_spread_degrees = 55.0 collision_radius = 0.4 collision_height = 6.0 +gatherable_surface_material_names = PackedStringArray("wood_light") diff --git a/world/generation/props/definitions/prop_pine.tres b/world/generation/props/definitions/prop_pine.tres index 26e9cb0..062dd3b 100644 --- a/world/generation/props/definitions/prop_pine.tres +++ b/world/generation/props/definitions/prop_pine.tres @@ -17,4 +17,4 @@ minimum_visual_scale = 0.65 maximum_visual_scale = 1.2 collision_radius = 0.5 collision_height = 4.0 -gatherable_anchor_height = 2.15 +gatherable_surface_material_names = PackedStringArray("wood") diff --git a/world/generation/props/definitions/prop_pine_large.tres b/world/generation/props/definitions/prop_pine_large.tres index 47c8577..5f5d584 100644 --- a/world/generation/props/definitions/prop_pine_large.tres +++ b/world/generation/props/definitions/prop_pine_large.tres @@ -17,4 +17,4 @@ minimum_visual_scale = 0.75 maximum_visual_scale = 1.2 collision_radius = 0.65 collision_height = 9.5 -gatherable_anchor_height = 2.15 +gatherable_surface_material_names = PackedStringArray("wood") diff --git a/world/generation/props/definitions/prop_tree_1.tres b/world/generation/props/definitions/prop_tree_1.tres index bb5d2af..3553988 100644 --- a/world/generation/props/definitions/prop_tree_1.tres +++ b/world/generation/props/definitions/prop_tree_1.tres @@ -27,4 +27,4 @@ secondary_variant_material_slot_names = PackedStringArray("wood", "wood_light", secondary_material_variants = Array[Material]([ExtResource("6_wood_light"), ExtResource("7_wood_mid"), ExtResource("8_wood_dark")]) collision_radius = 0.4 collision_height = 3.2 -gatherable_anchor_height = 2.15 +gatherable_surface_material_names = PackedStringArray("wood", "wood_light", "wood_mid", "wood_dark") diff --git a/world/generation/props/definitions/prop_tree_2.tres b/world/generation/props/definitions/prop_tree_2.tres index a6931be..868e044 100644 --- a/world/generation/props/definitions/prop_tree_2.tres +++ b/world/generation/props/definitions/prop_tree_2.tres @@ -27,4 +27,4 @@ secondary_variant_material_slot_names = PackedStringArray("wood", "wood_light", secondary_material_variants = Array[Material]([ExtResource("6_wood_light"), ExtResource("7_wood_mid"), ExtResource("8_wood_dark")]) collision_radius = 0.5 collision_height = 3.8 -gatherable_anchor_height = 2.15 +gatherable_surface_material_names = PackedStringArray("wood", "wood_light", "wood_mid", "wood_dark") diff --git a/world/generation/props/definitions/prop_tree_3.tres b/world/generation/props/definitions/prop_tree_3.tres index 382117e..58e3549 100644 --- a/world/generation/props/definitions/prop_tree_3.tres +++ b/world/generation/props/definitions/prop_tree_3.tres @@ -27,4 +27,4 @@ secondary_variant_material_slot_names = PackedStringArray("wood", "wood_light", secondary_material_variants = Array[Material]([ExtResource("6_wood_light"), ExtResource("7_wood_mid"), ExtResource("8_wood_dark")]) collision_radius = 0.55 collision_height = 4.2 -gatherable_anchor_height = 2.15 +gatherable_surface_material_names = PackedStringArray("wood", "wood_light", "wood_mid", "wood_dark") diff --git a/world/generation/props/definitions/prop_tree_large.tres b/world/generation/props/definitions/prop_tree_large.tres index ebf6f2f..9092ad1 100644 --- a/world/generation/props/definitions/prop_tree_large.tres +++ b/world/generation/props/definitions/prop_tree_large.tres @@ -27,4 +27,4 @@ secondary_variant_material_slot_names = PackedStringArray("wood", "wood_light", secondary_material_variants = Array[Material]([ExtResource("6_wood_light"), ExtResource("7_wood_mid"), ExtResource("8_wood_dark")]) collision_radius = 0.85 collision_height = 7.5 -gatherable_anchor_height = 2.15 +gatherable_surface_material_names = PackedStringArray("wood", "wood_light", "wood_mid", "wood_dark") diff --git a/world/generation/terrain_prop_catalog.gd b/world/generation/terrain_prop_catalog.gd index f475563..ed37c68 100644 --- a/world/generation/terrain_prop_catalog.gd +++ b/world/generation/terrain_prop_catalog.gd @@ -74,11 +74,12 @@ func validation_errors() -> PackedStringArray: % definition.stable_id ) if ( - definition.gatherable_anchor_height > 0.0 - and definition.gatherable_anchor_surface_radius() <= 0.0 + not definition.gatherable_surface_material_names.is_empty() + and definition.gatherable_surface_maximum_height + <= definition.gatherable_surface_minimum_height ): errors.append( - "%s is gatherable but has no trunk-surface radius." + "%s has an invalid gatherable-surface height band." % definition.stable_id ) if ( diff --git a/world/generation/terrain_prop_definition.gd b/world/generation/terrain_prop_definition.gd index 1479371..58fdddd 100644 --- a/world/generation/terrain_prop_definition.gd +++ b/world/generation/terrain_prop_definition.gd @@ -47,11 +47,18 @@ extends Resource @export_range(0.0, 20.0, 0.05) var collision_height := 0.0 @export var collision_box_size := Vector3.ZERO @export var collision_offset := Vector3.ZERO -## Values above zero add this prop to the tree-gathering anchor set. -@export_range(0.0, 20.0, 0.05) var gatherable_anchor_height := 0.0 -## Optional distance from the prop origin to the visible trunk surface. A -## zero value derives the distance from the authored collision shape. -@export_range(0.0, 5.0, 0.05) var gatherable_anchor_radius := 0.0 +@export_category("Gatherable Surface") +## Non-empty values explicitly designate this prop's matching mesh surfaces as +## valid attachment geometry. Unlisted props and materials are never sampled. +@export var gatherable_surface_material_names := PackedStringArray() +## Accessibility band measured upward from this prop's planted origin. It +## follows the tree onto hills/cliffs while keeping anchors within net reach. +@export_range(0.0, 20.0, 0.05) var gatherable_surface_minimum_height := 0.7 +@export_range(0.0, 20.0, 0.05) var gatherable_surface_maximum_height := 1.5 +## Reject upward-facing branches and foliage so attachments favor trunk-like +## faces. Zero accepts only vertical faces; one accepts every orientation. +@export_range(0.0, 1.0, 0.05) var gatherable_surface_maximum_up_dot := 0.35 +@export_range(0.0, 0.25, 0.005) var gatherable_surface_clearance := 0.025 func supports_chunk_tags(chunk_tags: PackedStringArray) -> bool: @@ -77,14 +84,12 @@ func has_box_collision() -> bool: ) -func gatherable_anchor_surface_radius() -> float: - if gatherable_anchor_radius > 0.0: - return gatherable_anchor_radius - if has_cylinder_collision(): - return collision_radius - if has_box_collision(): - return maxf(collision_box_size.x, collision_box_size.z) * 0.5 - return 0.0 +func has_gatherable_surface() -> bool: + return ( + not gatherable_surface_material_names.is_empty() + and gatherable_surface_maximum_height + > gatherable_surface_minimum_height + ) func is_procedural() -> bool: From bb75121dcdc7e1801adb06103a40be5497b07a2d Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 24 Aug 2026 11:13:49 -0400 Subject: [PATCH 14/44] Activate full fish catalog --- art/exported/creatures/fish_placeholder.png | Bin 0 -> 1964 bytes .../creatures/fish_placeholder.png.import | 40 +++ fish/pools/generated_lake_pool.tres | 30 ++- fish/pools/generated_pond_pool.tres | 42 +++- fish/pools/generated_river_pool.tres | 66 ++++- fish/pools/starter_ocean_pool.tres | 234 +++++++++++++++--- fish/pools/starter_pond_pool.tres | 136 +++++++--- fish/species/abalone_red/abalone_red.tres | 6 +- .../amberjack_greater/amberjack_greater.tres | 6 +- .../amberjack_lesser/amberjack_lesser.tres | 6 +- .../anchovy_european/anchovy_european.tres | 2 +- .../anchovy_northern/anchovy_northern.tres | 2 +- .../angelfish_freshwater.tres | 6 +- .../angelfish_queen/angelfish_queen.tres | 6 +- .../anglerfish_black_seadevil.tres | 6 +- fish/species/arapaima/arapaima.tres | 6 +- .../arowana_silver/arowana_silver.tres | 6 +- fish/species/axolotl/axolotl.tres | 6 +- fish/species/barbel_common/barbel_common.tres | 6 +- .../barracuda_great/barracuda_great.tres | 6 +- fish/species/barramundi/barramundi.tres | 6 +- .../barreleye_pacific/barreleye_pacific.tres | 6 +- fish/species/bass/bass.tres | 2 +- fish/species/betta_siamese/betta_siamese.tres | 6 +- fish/species/black_drum/black_drum.tres | 6 +- .../blobfish_smooth_head.tres | 6 +- fish/species/bluefish/bluefish.tres | 6 +- fish/species/bluegill/bluegill.tres | 2 +- fish/species/bonefish/bonefish.tres | 6 +- fish/species/bowfin/bowfin.tres | 2 +- .../boxfish_yellow/boxfish_yellow.tres | 6 +- fish/species/bream_common/bream_common.tres | 6 +- .../buffalo_bigmouth/buffalo_bigmouth.tres | 6 +- .../buffalo_smallmouth.tres | 6 +- .../bullhead_black/bullhead_black.tres | 6 +- .../bullhead_brown/bullhead_brown.tres | 6 +- .../bullhead_yellow/bullhead_yellow.tres | 6 +- fish/species/burbot/burbot.tres | 6 +- .../butterflyfish_copperband.tres | 6 +- fish/species/capelin/capelin.tres | 6 +- fish/species/carp/carp.tres | 2 +- fish/species/catfish_blue/catfish_blue.tres | 2 +- .../catfish_channel/catfish_channel.tres | 2 +- .../catfish_flathead/catfish_flathead.tres | 2 +- .../catfish_walking/catfish_walking.tres | 6 +- fish/species/catfish_white/catfish_white.tres | 2 +- fish/species/char_arctic/char_arctic.tres | 6 +- .../chromis_blue_green.tres | 6 +- fish/species/chub_creek/chub_creek.tres | 6 +- fish/species/chub_european/chub_european.tres | 2 +- fish/species/chub_flame/chub_flame.tres | 2 +- fish/species/chub_lake/chub_lake.tres | 2 +- fish/species/cisco/cisco.tres | 6 +- fish/species/clam_geoduck/clam_geoduck.tres | 6 +- fish/species/clam_giant/clam_giant.tres | 6 +- .../clownfish_ocellaris.tres | 6 +- fish/species/cobia/cobia.tres | 6 +- fish/species/cod_atlantic/cod_atlantic.tres | 6 +- fish/species/cod_pacific/cod_pacific.tres | 6 +- .../coelacanth_west_indian.tres | 6 +- fish/species/conch_queen/conch_queen.tres | 6 +- .../cornetfish_red/cornetfish_red.tres | 6 +- .../cowfish_longhorn/cowfish_longhorn.tres | 6 +- .../crab_japanese_spider.tres | 6 +- fish/species/crab_red_king/crab_red_king.tres | 6 +- fish/species/crappie_black/crappie_black.tres | 6 +- fish/species/crappie_white/crappie_white.tres | 6 +- .../crayfish_red_swamp.tres | 6 +- .../croaker_atlantic/croaker_atlantic.tres | 6 +- .../croaker_yellowfin/croaker_yellowfin.tres | 6 +- fish/species/cusk/cusk.tres | 6 +- .../cuttlefish_common/cuttlefish_common.tres | 6 +- .../cuttlefish_flamboyant.tres | 6 +- .../damselfish_sergeant_major.tres | 6 +- fish/species/danio_zebra/danio_zebra.tres | 6 +- fish/species/discus/discus.tres | 6 +- .../dolphin_bottlenose.tres | 6 +- fish/species/dorado_golden/dorado_golden.tres | 6 +- fish/species/dugong/dugong.tres | 6 +- fish/species/eel_american/eel_american.tres | 6 +- fish/species/eel_european/eel_european.tres | 6 +- fish/species/electric_eel/electric_eel.tres | 6 +- fish/species/fallfish/fallfish.tres | 6 +- .../fangtooth_common/fangtooth_common.tres | 6 +- .../flounder_peacock/flounder_peacock.tres | 6 +- .../flounder_summer/flounder_summer.tres | 6 +- .../flounder_winter/flounder_winter.tres | 6 +- .../flyingfish_tropical.tres | 6 +- .../foureyes_largescale.tres | 6 +- .../freshwater_drum/freshwater_drum.tres | 6 +- fish/species/gar_alligator/gar_alligator.tres | 6 +- fish/species/gar_longnose/gar_longnose.tres | 2 +- fish/species/gar_spotted/gar_spotted.tres | 2 +- fish/species/goby_round/goby_round.tres | 2 +- fish/species/goldeye/goldeye.tres | 6 +- fish/species/goldfish/goldfish.tres | 2 +- .../goldfish_bubbleeye.tres | 2 +- fish/species/gourami_giant/gourami_giant.tres | 6 +- .../grayling_arctic/grayling_arctic.tres | 6 +- fish/species/green_sunfish/green_sunfish.tres | 6 +- .../grouper_atlantic_goliath.tres | 6 +- fish/species/grouper_giant/grouper_giant.tres | 6 +- fish/species/grouper_gulf/grouper_gulf.tres | 2 +- .../grouper_nassau/grouper_nassau.tres | 6 +- fish/species/grouper_red/grouper_red.tres | 2 +- .../grunt_bluestriped/grunt_bluestriped.tres | 6 +- fish/species/grunt_french/grunt_french.tres | 6 +- fish/species/gulper_eel/gulper_eel.tres | 6 +- fish/species/guppy/guppy.tres | 6 +- fish/species/haddock/haddock.tres | 6 +- fish/species/hake_pacific/hake_pacific.tres | 6 +- fish/species/hake_silver/hake_silver.tres | 6 +- .../halfbeak_ballyhoo/halfbeak_ballyhoo.tres | 6 +- .../halibut_atlantic/halibut_atlantic.tres | 6 +- .../halibut_pacific/halibut_pacific.tres | 6 +- fish/species/hellbender/hellbender.tres | 6 +- .../herring_atlantic/herring_atlantic.tres | 6 +- .../herring_pacific/herring_pacific.tres | 6 +- fish/species/hogfish/hogfish.tres | 6 +- fish/species/ide/ide.tres | 6 +- fish/species/jack_crevalle/jack_crevalle.tres | 6 +- fish/species/jelly_crystal/jelly_crystal.tres | 6 +- .../jelly_lions_mane/jelly_lions_mane.tres | 6 +- fish/species/jelly_moon/jelly_moon.tres | 6 +- .../jelly_pacific_sea_nettle.tres | 6 +- .../jelly_sea_wasp/jelly_sea_wasp.tres | 6 +- .../knifefish_clown/knifefish_clown.tres | 6 +- fish/species/koi/koi.tres | 6 +- .../krill_antarctic/krill_antarctic.tres | 6 +- fish/species/ladyfish/ladyfish.tres | 6 +- .../lanternfish_spotted.tres | 6 +- .../largemouth_bass/largemouth_bass.tres | 6 +- fish/species/lingcod/lingcod.tres | 6 +- fish/species/lionfish_red/lionfish_red.tres | 6 +- fish/species/loach_clown/loach_clown.tres | 6 +- fish/species/loach_weather/loach_weather.tres | 6 +- .../lobster_american/lobster_american.tres | 6 +- .../lobster_caribbean_spiny.tres | 6 +- .../lungfish_west_african.tres | 2 +- .../mackerel_atlantic/mackerel_atlantic.tres | 2 +- fish/species/mackerel_cero/mackerel_cero.tres | 2 +- fish/species/mackerel_chub/mackerel_chub.tres | 2 +- fish/species/mackerel_king/mackerel_king.tres | 2 +- .../mackerel_spanish/mackerel_spanish.tres | 2 +- .../madtom_tadpole/madtom_tadpole.tres | 6 +- fish/species/mahi_mahi/mahi_mahi.tres | 6 +- .../mahseer_golden/mahseer_golden.tres | 6 +- .../manatee_west_indian.tres | 6 +- .../manta_ray_giant/manta_ray_giant.tres | 6 +- fish/species/marlin_black/marlin_black.tres | 2 +- fish/species/marlin_blue/marlin_blue.tres | 2 +- fish/species/marlin_white/marlin_white.tres | 2 +- .../menhaden_atlantic/menhaden_atlantic.tres | 6 +- fish/species/milkfish/milkfish.tres | 6 +- .../minnow_fathead/minnow_fathead.tres | 6 +- fish/species/monkfish/monkfish.tres | 6 +- fish/species/mooneye/mooneye.tres | 6 +- fish/species/moray_green/moray_green.tres | 6 +- .../mudskipper_atlantic.tres | 6 +- fish/species/mullet_red/mullet_red.tres | 6 +- .../mullet_striped/mullet_striped.tres | 6 +- fish/species/muskellunge/muskellunge.tres | 6 +- .../nautilus_chambered.tres | 6 +- .../needlefish_hound/needlefish_hound.tres | 6 +- fish/species/oarfish_giant/oarfish_giant.tres | 6 +- .../ocean_perch_pacific.tres | 6 +- .../octopus_common/octopus_common.tres | 6 +- fish/species/octopus_day/octopus_day.tres | 6 +- .../octopus_giant_pacific.tres | 6 +- .../octopus_greater_blue_ringed.tres | 6 +- fish/species/opah/opah.tres | 6 +- fish/species/oscar/oscar.tres | 6 +- .../oyster_black_lip_pearl.tres | 6 +- fish/species/paddlefish/paddlefish.tres | 2 +- .../parrotfish_rainbow.tres | 6 +- .../parrotfish_stoplight.tres | 6 +- fish/species/peacock_bass/peacock_bass.tres | 6 +- fish/species/permit/permit.tres | 6 +- .../pickerel_chain/pickerel_chain.tres | 6 +- .../pickerel_grass/pickerel_grass.tres | 6 +- fish/species/pike_northern/pike_northern.tres | 6 +- fish/species/pipefish_bay/pipefish_bay.tres | 6 +- .../piranha_red_bellied.tres | 6 +- .../plaice_european/plaice_european.tres | 6 +- .../pollock_alaska/pollock_alaska.tres | 6 +- .../pollock_atlantic/pollock_atlantic.tres | 6 +- fish/species/pomfret_black/pomfret_black.tres | 2 +- .../pomfret_chinese/pomfret_chinese.tres | 2 +- .../pomfret_golden/pomfret_golden.tres | 2 +- fish/species/pomfret_white/pomfret_white.tres | 2 +- .../pompano_african/pompano_african.tres | 6 +- .../pompano_florida/pompano_florida.tres | 6 +- .../porcupinefish_spotted.tres | 6 +- fish/species/porgy_scup/porgy_scup.tres | 6 +- .../pufferfish_guineafowl.tres | 6 +- fish/species/pumpkinseed/pumpkinseed.tres | 6 +- .../queenfish_talang/queenfish_talang.tres | 6 +- fish/species/quillback/quillback.tres | 6 +- fish/species/red_drum/red_drum.tres | 6 +- .../redear_sunfish/redear_sunfish.tres | 6 +- .../redhorse_golden/redhorse_golden.tres | 6 +- fish/species/roach_common/roach_common.tres | 6 +- .../rockfish_black/rockfish_black.tres | 6 +- .../rockfish_canary/rockfish_canary.tres | 6 +- .../rockfish_yelloweye.tres | 6 +- fish/species/roosterfish/roosterfish.tres | 6 +- fish/species/rudd/rudd.tres | 6 +- fish/species/sablefish/sablefish.tres | 6 +- fish/species/sailfish/sailfish.tres | 2 +- .../salmon_atlantic/salmon_atlantic.tres | 2 +- fish/species/salmon_chum/salmon_chum.tres | 2 +- fish/species/salmon_coho/salmon_coho.tres | 2 +- fish/species/salmon_pink/salmon_pink.tres | 2 +- .../salmon_sockeye/salmon_sockeye.tres | 2 +- fish/species/sand_dollar/sand_dollar.tres | 6 +- .../sand_lance_american.tres | 6 +- .../sardine_european/sardine_european.tres | 6 +- .../sardine_pacific/sardine_pacific.tres | 6 +- fish/species/sauger/sauger.tres | 2 +- fish/species/saugeye/saugeye.tres | 2 +- .../sawfish_largetooth.tres | 6 +- .../scorpionfish_red/scorpionfish_red.tres | 6 +- .../sea_bass_black/sea_bass_black.tres | 6 +- .../sea_bass_chilean/sea_bass_chilean.tres | 6 +- .../sea_cucumber_giant_california.tres | 6 +- fish/species/sea_otter/sea_otter.tres | 6 +- .../sea_star_crown_of_thorns.tres | 6 +- .../sea_star_sunflower.tres | 6 +- .../seadragon_leafy/seadragon_leafy.tres | 6 +- .../seadragon_weedy/seadragon_weedy.tres | 6 +- .../seahorse_lined/seahorse_lined.tres | 6 +- fish/species/seal_harbor/seal_harbor.tres | 6 +- .../shark_blacktip/shark_blacktip.tres | 6 +- .../shark_great_hammerhead.tres | 6 +- .../shark_great_white/shark_great_white.tres | 6 +- fish/species/shark_tiger/shark_tiger.tres | 6 +- fish/species/shark_whale/shark_whale.tres | 6 +- fish/species/sheepshead/sheepshead.tres | 6 +- fish/species/shiner_common/shiner_common.tres | 6 +- .../shiner_emerald/shiner_emerald.tres | 6 +- fish/species/shiner_golden/shiner_golden.tres | 6 +- .../shrimp_peacock_mantis.tres | 6 +- .../shrimp_skunk_cleaner.tres | 6 +- .../skate_barndoor/skate_barndoor.tres | 6 +- .../smallmouth_bass/smallmouth_bass.tres | 6 +- .../smelt_eulachon/smelt_eulachon.tres | 6 +- fish/species/smelt_rainbow/smelt_rainbow.tres | 6 +- .../snakehead_giant/snakehead_giant.tres | 6 +- fish/species/snapper_lane/snapper_lane.tres | 2 +- .../snapper_mangrove/snapper_mangrove.tres | 2 +- .../snapper_mutton/snapper_mutton.tres | 2 +- fish/species/snapper_red/snapper_red.tres | 2 +- fish/species/snook_common/snook_common.tres | 6 +- fish/species/sole_dover/sole_dover.tres | 6 +- .../sprat_european/sprat_european.tres | 6 +- .../squid_bigfin_reef/squid_bigfin_reef.tres | 6 +- .../squid_colossal/squid_colossal.tres | 6 +- fish/species/squid_giant/squid_giant.tres | 6 +- .../squid_humboldt/squid_humboldt.tres | 6 +- .../stingray_ocellate_river.tres | 6 +- .../stingray_southern/stingray_southern.tres | 6 +- fish/species/sturgeon_lake/sturgeon_lake.tres | 2 +- .../sturgeon_shovelnose.tres | 2 +- .../sturgeon_white/sturgeon_white.tres | 6 +- fish/species/sucker_white/sucker_white.tres | 6 +- fish/species/sunfish/sunfish.tres | 2 +- .../surgeonfish_blue/surgeonfish_blue.tres | 6 +- fish/species/swordfish/swordfish.tres | 2 +- fish/species/tambaqui/tambaqui.tres | 6 +- fish/species/tang_yellow/tang_yellow.tres | 6 +- .../tarpon_atlantic/tarpon_atlantic.tres | 6 +- fish/species/tautog/tautog.tres | 6 +- fish/species/tench/tench.tres | 6 +- fish/species/tetra_neon/tetra_neon.tres | 6 +- .../tigerfish_goliath/tigerfish_goliath.tres | 6 +- fish/species/tilapia_nile/tilapia_nile.tres | 6 +- .../tilefish_blueline/tilefish_blueline.tres | 6 +- .../tilefish_golden/tilefish_golden.tres | 6 +- .../trevally_bluefin/trevally_bluefin.tres | 6 +- .../trevally_giant/trevally_giant.tres | 6 +- .../triggerfish_gray/triggerfish_gray.tres | 6 +- .../triggerfish_queen/triggerfish_queen.tres | 6 +- fish/species/tripletail/tripletail.tres | 6 +- fish/species/trout_brown/trout_brown.tres | 6 +- .../trout_cutthroat/trout_cutthroat.tres | 2 +- fish/species/trout_golden/trout_golden.tres | 2 +- fish/species/trout_rainbow/trout_rainbow.tres | 2 +- .../trout_steelhead/trout_steelhead.tres | 2 +- .../trumpetfish_atlantic.tres | 6 +- fish/species/tuna_albacore/tuna_albacore.tres | 2 +- fish/species/tuna_bigeye/tuna_bigeye.tres | 2 +- fish/species/tuna_bluefin/tuna_bluefin.tres | 2 +- fish/species/tuna_skipjack/tuna_skipjack.tres | 2 +- .../tuna_yellowfin/tuna_yellowfin.tres | 2 +- fish/species/turbot/turbot.tres | 6 +- fish/species/turtle_green/turtle_green.tres | 6 +- .../turtle_hawksbill/turtle_hawksbill.tres | 6 +- .../turtle_leatherback.tres | 6 +- .../turtle_loggerhead/turtle_loggerhead.tres | 6 +- fish/species/urchin_purple/urchin_purple.tres | 6 +- fish/species/wahoo/wahoo.tres | 6 +- fish/species/walleye/walleye.tres | 2 +- fish/species/walrus/walrus.tres | 6 +- fish/species/warmouth/warmouth.tres | 6 +- fish/species/weakfish/weakfish.tres | 6 +- fish/species/whale_blue/whale_blue.tres | 6 +- .../whale_humpback/whale_humpback.tres | 6 +- fish/species/whale_killer/whale_killer.tres | 6 +- fish/species/whale_sperm/whale_sperm.tres | 6 +- fish/species/white_perch/white_perch.tres | 6 +- .../whitefish_lake/whitefish_lake.tres | 6 +- .../wolffish_atlantic/wolffish_atlantic.tres | 6 +- .../wolffish_spotted/wolffish_spotted.tres | 6 +- fish/species/wrasse_ballan/wrasse_ballan.tres | 6 +- .../wrasse_cleaner/wrasse_cleaner.tres | 6 +- fish/species/wreckfish/wreckfish.tres | 6 +- fish/species/yellow_perch/yellow_perch.tres | 6 +- tests/economy_regression_validation.gd | 2 +- tests/fish_catalog_content_validation.gd | 95 ++++--- tests/logbook_runtime_validation.gd | 46 ++-- tests/logbook_validation.gd | 6 +- 321 files changed, 1611 insertions(+), 706 deletions(-) create mode 100644 art/exported/creatures/fish_placeholder.png create mode 100644 art/exported/creatures/fish_placeholder.png.import diff --git a/art/exported/creatures/fish_placeholder.png b/art/exported/creatures/fish_placeholder.png new file mode 100644 index 0000000000000000000000000000000000000000..de69afb7d7040b41193b957beb8527c5d396e67b GIT binary patch literal 1964 zcmV;d2UGZoP)9@C00001b5ch_0olnc ze*gdg1ZP1_K>z@;j|==^1poj5AY({UO#lFTCIA3{ga82g0001h=l}q9FaQARU;qF* zm;eA5aGbhPJOBUy32;bRa{vG?BLDy{BLR4&KXw2B00(qQO+^Rl2pA3&B*x{qdjJ3j z0!c(cRCwC$oNH_pRTRg6ce^buyWO@(Tl#=%p@EkVlB`@AInO&yW@wsE!*yPoPEzO z`%ju~c4qI~x&M0}_uO-?APb=BECH4P%~H@z7C~gd;8y`R0m}dnPy$Q`oaz=&$Y+3l zV71+e(sU|-Dxe$al!Ab53DWdY{ip&~0Zl+7P#j~kXMtPnNR+1Y5b!W?IdB4a0r*%7 zd}$|0k>LP}fHGh*upGD%m;+QK_>s0EO4IpLX@s$O9e8@IB#9JutN@q-%vCdIj*^gB zim+rO?L=uh?Z7mXTYiv&>r+OMl9ma|W~>HoSH$F}aT2GLC`~6HSOM&?_>-lfeG^Gi zUoJIM8Uali_=z?%1t}p)(|HeA7sXG0l7hyB5Txnkjj<&aK)uqjOVl;HqZ_y_(L`xF zD}n7Xj6o2nm4eQg2~zf?8dwM{0IpDKtQN>i!XWA?u$S$?g5iOQb4~VzS`NgDbyBb; zDuR@>lmhdV%37g5=aXG{Mhad{6EVa1Rti>FB1lO~4X_BfP5m#8w+F$&0{(5U;QYx# zCQk2T!jzMCBB>A1{!i_xMf57=B6||}{C+8n_Jqm93za*(N_k*c0R@S8y8lcW$6l;w zVCx0ptA4ki%BF*MlcqK)Xc$LYHUjgLxJ0Oyf=&_P;Z9&Ga4Ya2Fw<&_0k50WJEzgJ z-9^oYZanjkM3kKFUG@BRPq+}yZarc*NdoYzN!kTmn*?bwwL>BTIoAL$0XK|^ahc*= zTP?p|R~z>I)u(?9lb(a~E=NBXVanK3iqeJ8q+rch@tgs?p&S5bG?Mer*7C^8d~bbj zsAI!aLiMMn;|3m8u5(lv8YrUooe2@IotC3jS)R*F&E~Ta?=4+)Do4u&%p4&|)0qf7 z0^A?Vdc*1VQL_Gg#A}na(PTIZ1}R<8h5Igln0m9J{YfV-8S&yYoyUPq@w8^u24#qw zZ3{?&5SmUcupcOow?QjjTn^C_v=aC_6JNmBQN_tmD*>oj(?!v=&a5NIi4o_@IQ+4G zKHg6*IGQ~h>zMRZC3R2yl2s%*f!~1h82dKn-dbM?zN2LX&gD^1ex3mmw-(g! ztQ*WGN8wLG!#PsWtm*uM5w(sQ{(TM(T~mj1?o=+h+lPDZ0Ipf5Ol?%a>&APioPicM zfdL0YeGdL@aWdHAAb2Xe^|EX25Yu1YpNVR0ybaFd5w{yOoj)a=g1dfN@SWUaqbPfWuBV9YQyMf~yCJ|^)euAo_R;Tv0vy?78k(R}A ziZPPBC@f~IJPo{J=81JYTaJHkV%46jX$a3^A0-QWvM$bMCRtKk$o&vemVx|jW$ofS zQefFj3-$8y+(C*Od=%FBD5&VY7$gO=l&hE$7d0IpP-ry*5W3D8MxHxpX`OVDVkBuX zR)QSBN5GxAu}q$O9d~ZFrqc*~Lt-*1b1={&g|XE=;vB`%*w4UXU|()r6Na5(-Yc>1 z_631f;AW-nBt~X_4zc5Xl2rn!Y}1v%a~L_?=~$Tu6Hs083~(1lp;;=?Aq90QsZrH* zYJhh!Qf5U4&EW5)F!BN;g}PGd93;X9nW?`MQm`7R1GZ&|>f10(&Um3Nl3Vi9nYJ=~ zB~iCyl$(2ONKUg9EVUXmPB6NpV2W)HvQVWPj{z%fQ2f;otdSzzdrBcm)n-+F!UjjU z+^gL6`V@xvbzn2_mJ}Se*w@x1I1a0%;43oWD439HwI+ISWjVDj6($pyh)mCqI void: ) assert(player != null) assert(catalog != null and catalog.candidates.size() == 316) - assert(LogbookCatalog.ordered_species(catalog.candidates).size() == 63) + assert(LogbookCatalog.ordered_species(catalog.candidates).size() == 313) assert(sale_service != null) assert(shop_service != null) assert(session != null and session.is_host()) diff --git a/tests/fish_catalog_content_validation.gd b/tests/fish_catalog_content_validation.gd index fb5f9e4..4a252b0 100644 --- a/tests/fish_catalog_content_validation.gd +++ b/tests/fish_catalog_content_validation.gd @@ -37,6 +37,18 @@ const PelicanBuyer = preload("res://economy/buyers/pelicans.tres") const StarterRegionScene = preload( "res://world/regions/starter_island_region.tscn" ) +const SharedFishPlaceholder: Texture2D = preload( + "res://art/exported/creatures/fish_placeholder.png" +) + +const ACTIVE_CATALOG_COUNT: int = 313 +const INACTIVE_CATALOG_COUNT: int = 3 +const FISHING_SPECIES_COUNT: int = 310 +const GENERATED_POND_COUNT: int = 33 +const GENERATED_LAKE_COUNT: int = 20 +const GENERATED_RIVER_COUNT: int = 53 +const STARTER_POND_COUNT: int = 106 +const OCEAN_COUNT: int = 204 const ORIGINAL_IDS: Array[StringName] = [ &"bluegill", &"bass", &"carp", &"sunfish", @@ -82,23 +94,6 @@ const NEW_FRESH_WATER_IDS: Array[StringName] = [ &"bowfin", &"sturgeon_lake", &"gar_longnose", &"paddlefish", &"sturgeon_shovelnose", &"gar_spotted", &"lungfish_west_african", ] -const GENERATED_POND_IDS: Array[StringName] = [ - &"bowfin", &"gar_spotted", &"lungfish_west_african", - &"catfish_channel", &"catfish_white", &"carp", - &"goldfish_bubbleeye", &"goldfish", &"bluegill", -] -const GENERATED_LAKE_IDS: Array[StringName] = [ - &"sturgeon_lake", &"trout_golden", &"chub_lake", &"saugeye", - &"walleye", &"goby_round", -] -const GENERATED_RIVER_IDS: Array[StringName] = [ - &"gar_longnose", &"paddlefish", &"sturgeon_shovelnose", - &"trout_cutthroat", &"trout_rainbow", &"catfish_blue", - &"catfish_flathead", &"chub_european", &"chub_flame", &"sauger", - &"trout_steelhead", -] - - func _initialize() -> void: call_deferred("_run") @@ -115,21 +110,35 @@ func _run() -> void: func _validate_generated_habitat_pools() -> void: - var expected_by_pool: Dictionary = { - GeneratedPondPool: GENERATED_POND_IDS, - GeneratedLakePool: GENERATED_LAKE_IDS, - GeneratedRiverPool: GENERATED_RIVER_IDS, + var expected_counts: Dictionary = { + GeneratedPondPool: GENERATED_POND_COUNT, + GeneratedLakePool: GENERATED_LAKE_COUNT, + GeneratedRiverPool: GENERATED_RIVER_COUNT, } - for pool: FishPoolType in expected_by_pool: - var expected_ids: Array[StringName] = expected_by_pool[pool] - assert(pool.candidates.size() == expected_ids.size()) - for fish_id: StringName in expected_ids: - var fish: FishDataType = Catalog.get_fish_by_id(fish_id) + var freshwater_ids: Dictionary[StringName, bool] = {} + for pool: FishPoolType in expected_counts: + assert(pool.candidates.size() == int(expected_counts[pool])) + for fish: FishDataType in pool.candidates: assert(fish != null and fish.is_fishable()) - assert(pool.get_fish_by_id(fish_id) == fish) + assert(not freshwater_ids.has(fish.id)) + freshwater_ids[fish.id] = true assert(fish.is_allowed_in_water(WaterType.Type.FRESH_WATER)) - assert(GeneratedPondPool.get_fish_by_id(&"pomfret_white") == null) - assert(GeneratedPondPool.get_fish_by_id(&"mackerel_atlantic") == null) + assert(freshwater_ids.size() == STARTER_POND_COUNT) + assert(PondPool.candidates.size() == STARTER_POND_COUNT) + for fish: FishDataType in PondPool.candidates: + assert(freshwater_ids.has(fish.id)) + assert(OceanPool.candidates.size() == OCEAN_COUNT) + var ocean_ids: Dictionary[StringName, bool] = {} + for fish: FishDataType in OceanPool.candidates: + assert(fish != null and fish.is_fishable()) + assert(not ocean_ids.has(fish.id)) + ocean_ids[fish.id] = true + assert(fish.is_allowed_in_water(WaterType.Type.SALT_WATER)) + for fish: FishDataType in Catalog.candidates: + if not fish.is_fishable(): + continue + assert(freshwater_ids.has(fish.id) != ocean_ids.has(fish.id)) + assert(freshwater_ids.size() + ocean_ids.size() == FISHING_SPECIES_COUNT) assert(GeneratedPondPool.get_fish_by_id(&"gar_longnose") == null) assert(GeneratedRiverPool.get_fish_by_id(&"gar_longnose") != null) assert(GeneratedRiverPool.get_fish_by_id(&"goby_round") == null) @@ -143,7 +152,7 @@ func _validate_weight_based_display_scale() -> void: continue assert(fish.is_selectable()) assert(fish.weight_min_lb > 0.0) - assert(fish.weight_max_lb <= 1000.0) + assert(fish.weight_max_lb <= 500000.0) var minimum_scale: float = fish.get_display_scale_for_weight( fish.weight_min_lb ) @@ -172,10 +181,11 @@ func _validate_weight_based_display_scale() -> void: func _validate_catalog_and_pools() -> void: assert(Catalog.candidates.size() == 316) - assert(PondPool.candidates.size() == 26) - assert(OceanPool.candidates.size() == 34) + assert(PondPool.candidates.size() == STARTER_POND_COUNT) + assert(OceanPool.candidates.size() == OCEAN_COUNT) var active_count: int = 0 var inactive_count: int = 0 + var fishing_count: int = 0 var catalog_numbers: Dictionary[int, bool] = {} for fish: FishDataType in Catalog.candidates: assert(fish != null and not fish.id.is_empty()) @@ -194,8 +204,13 @@ func _validate_catalog_and_pools() -> void: inactive_count += 1 assert(not fish.is_selectable()) assert(fish.display_texture == null) - assert(active_count == 63) - assert(inactive_count == 253) + if fish.collection_method == FishDataType.CollectionMethod.FISHING: + fishing_count += 1 + assert(fish.active) + assert(fish.display_texture == SharedFishPlaceholder) + assert(active_count == ACTIVE_CATALOG_COUNT) + assert(inactive_count == INACTIVE_CATALOG_COUNT) + assert(fishing_count == FISHING_SPECIES_COUNT) var chum: FishDataType = Catalog.get_fish_by_id(&"salmon_chum") assert(chum != null) assert(chum.get_season_text() == "fall") @@ -233,12 +248,12 @@ func _validate_catalog_and_pools() -> void: ) == chum ) seasonal_collection.free() - var inactive_fish: FishDataType = Catalog.get_fish_by_id(&"mudskipper_atlantic") + var inactive_fish: FishDataType = Catalog.get_fish_by_id(&"crab_blue") assert(inactive_fish != null and not inactive_fish.active) var inactive_pool := FishPoolType.new() inactive_pool.candidates = [inactive_fish] var inactive_context := FishingContextType.new() - inactive_context.water_type = WaterType.Type.FRESH_WATER + inactive_context.water_type = WaterType.Type.SALT_WATER var inactive_collection := CollectionLogType.new() var inactive_selector := FishSelectorType.new() inactive_selector.use_deterministic_test_seed = true @@ -424,11 +439,15 @@ func _validate_authoritative_water_filter() -> void: ocean_region.location_tags = [&"coast", &"ocean"] var stale_salt_pool_region := FishableWaterRegion.new() stale_salt_pool_region.water_type = WaterType.Type.FRESH_WATER - stale_salt_pool_region.fish_pool = OceanPool + var salt_only_pool := FishPoolType.new() + salt_only_pool.candidates = [Catalog.get_fish_by_id(&"bass")] + stale_salt_pool_region.fish_pool = salt_only_pool stale_salt_pool_region.location_tags = [&"starter_pond"] var stale_fresh_pool_region := FishableWaterRegion.new() stale_fresh_pool_region.water_type = WaterType.Type.SALT_WATER - stale_fresh_pool_region.fish_pool = PondPool + var fresh_only_pool := FishPoolType.new() + fresh_only_pool.candidates = [Catalog.get_fish_by_id(&"bluegill")] + stale_fresh_pool_region.fish_pool = fresh_only_pool stale_fresh_pool_region.location_tags = [&"coast", &"ocean"] var evidence := {"discovered_fish_ids": []} assert( diff --git a/tests/logbook_runtime_validation.gd b/tests/logbook_runtime_validation.gd index e411fef..8b19f92 100644 --- a/tests/logbook_runtime_validation.gd +++ b/tests/logbook_runtime_validation.gd @@ -49,20 +49,20 @@ func _run() -> void: assert(not hotbar.visible) var entry_buttons: Dictionary = logbook.get("_entry_buttons") - assert(entry_buttons.size() == 26) + assert(entry_buttons.size() == 108) await _capture_if_requested("-unknown") logbook.call( "_select_category", WaterType.Type.FRESH_WATER ) await create_timer(0.25).timeout - assert((logbook.get("_entry_buttons") as Dictionary).size() == 26) + assert((logbook.get("_entry_buttons") as Dictionary).size() == 108) await _capture_if_requested("-fresh") logbook.call("_select_category", WaterType.Type.SALT_WATER) await create_timer(0.25).timeout - assert((logbook.get("_entry_buttons") as Dictionary).size() == 34) + assert((logbook.get("_entry_buttons") as Dictionary).size() == 202) logbook.call("_select_category", WaterType.Type.FRESH_WATER) await create_timer(0.25).timeout - assert((logbook.get("_entry_buttons") as Dictionary).size() == 26) + assert((logbook.get("_entry_buttons") as Dictionary).size() == 108) player.collection_log.mark_discovered(&"bluegill") await process_frame logbook.call("_select_entry", &"bluegill", &"bluegill") @@ -110,11 +110,24 @@ func _validate_save_round_trip( assert(catalog != null) assert(catalog.candidates.size() == 316) var active_species := LogbookCatalog.ordered_species(catalog.candidates) - assert(active_species.size() == 63) - # This test intentionally round-trips one catch for every active species. - # Give the fixture enough combined capacity, then bypass per-catch carried - # inventory admission while constructing it. The inventory layout still - # reconciles and persists the resulting placements. + assert(active_species.size() == 313) + # Keep this runtime save fixture within maximum player capacity while the + # focused catalog validation exercises every active species individually. + var round_trip_species: Array[FishData] = [] + for fish: FishData in active_species: + if round_trip_species.size() >= 59: + break + round_trip_species.append(fish) + for fish_id: StringName in [ + &"catfish_blue", + &"catfish_channel", + &"catfish_flathead", + &"catfish_white", + ]: + var catfish: FishData = catalog.get_fish_by_id(fish_id) + if catfish not in round_trip_species: + round_trip_species.append(catfish) + assert(round_trip_species.size() == 63) player.inventory_layout.restore_backpack_level( PlayerInventoryLayout.MAX_BACKPACK_LEVEL ) @@ -126,7 +139,7 @@ func _validate_save_round_trip( assert(player.cooler_capacity.get_level() == PlayerCoolerCapacity.MAX_LEVEL) player.inventory.set_inventory_layout(null) for index: int in 4: - _add_test_catch(player, active_species[index]) + _add_test_catch(player, round_trip_species[index]) assert(save_manager.save_now()) var no_catches: Array[FishCatch] = [] var no_discoveries: Array[StringName] = [] @@ -135,18 +148,21 @@ func _validate_save_round_trip( assert(save_manager.load_player_data()) assert(player.inventory.get_all_catches().size() == 4) for index: int in 4: - var original_fish: FishData = active_species[index] + var original_fish: FishData = round_trip_species[index] assert(player.inventory.get_count(original_fish.id) == 1) assert(player.collection_log.has_discovered(original_fish.id)) - for index: int in range(4, active_species.size()): - _add_test_catch(player, active_species[index]) + for index: int in range(4, round_trip_species.size()): + _add_test_catch(player, round_trip_species[index]) assert(save_manager.save_now()) assert(player.inventory.replace_all_catches(no_catches, 1)) assert(player.collection_log.replace_discovered_ids(no_discoveries)) assert(save_manager.load_player_data()) - assert(player.inventory.get_all_catches().size() == 63) - for fish: FishData in active_species: + assert( + player.inventory.get_all_catches().size() + == round_trip_species.size() + ) + for fish: FishData in round_trip_species: assert(player.inventory.get_count(fish.id) == 1) assert(player.collection_log.has_discovered(fish.id)) for fish_id: StringName in [ diff --git a/tests/logbook_validation.gd b/tests/logbook_validation.gd index 7eca559..a621a8a 100644 --- a/tests/logbook_validation.gd +++ b/tests/logbook_validation.gd @@ -43,7 +43,7 @@ func _run() -> void: func _validate_catalog() -> void: assert(CatalogResource.candidates.size() == 316) var ordered := LogbookCatalog.ordered_species(CatalogResource.candidates) - assert(ordered.size() == 63) + assert(ordered.size() == 313) var previous_number: int = 0 var catalog_numbers: Dictionary[int, bool] = {} for fish: FishDataType in CatalogResource.candidates: @@ -171,7 +171,7 @@ func _validate_page() -> void: var entries: Dictionary = page.get("_entry_buttons") assert( entries.size() - == (26 if category == LogbookCatalog.Category.FRESH_WATER else 34) + == (108 if category == LogbookCatalog.Category.FRESH_WATER else 202) ) for fish: FishDataType in LogbookCatalog.ordered_species( CatalogResource.candidates @@ -222,7 +222,7 @@ func _validate_page() -> void: candidate.display_name ) ) - assert(silhouette_count == 60) + assert(silhouette_count == 310) page.call("_select_category", LogbookCatalog.Category.SHELLFISH) await create_timer(0.25).timeout From 7f5a522ae57ea3b910a6387a6dc5d29d758ff6e0 Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 24 Aug 2026 12:01:20 -0400 Subject: [PATCH 15/44] Add finished creature artwork --- art/exported/creatures/101.png | Bin 0 -> 6688 bytes art/exported/creatures/101.png.import | 42 ++++ art/exported/creatures/102.png | Bin 0 -> 8589 bytes art/exported/creatures/102.png.import | 42 ++++ art/exported/creatures/103.png | Bin 0 -> 2764 bytes art/exported/creatures/103.png.import | 42 ++++ art/exported/creatures/104.png | Bin 0 -> 7444 bytes art/exported/creatures/104.png.import | 42 ++++ art/exported/creatures/105.png | Bin 0 -> 2799 bytes art/exported/creatures/105.png.import | 42 ++++ art/exported/creatures/106.png | Bin 0 -> 3777 bytes art/exported/creatures/106.png.import | 42 ++++ art/exported/creatures/107.png | Bin 0 -> 2873 bytes art/exported/creatures/107.png.import | 42 ++++ art/exported/creatures/3906.png | Bin 0 -> 2742 bytes art/exported/creatures/3906.png.import | 42 ++++ art/exported/creatures/4401.png | Bin 0 -> 2936 bytes art/exported/creatures/4401.png.import | 42 ++++ art/exported/creatures/4701.png | Bin 0 -> 3770 bytes art/exported/creatures/4701.png.import | 42 ++++ art/exported/creatures/5702.png | Bin 0 -> 1760 bytes art/exported/creatures/5702.png.import | 42 ++++ art/exported/creatures/5901.png | Bin 0 -> 1940 bytes art/exported/creatures/5901.png.import | 42 ++++ art/exported/creatures/6103.png | Bin 0 -> 5998 bytes art/exported/creatures/6103.png.import | 42 ++++ art/exported/creatures/6804.png | Bin 0 -> 4219 bytes art/exported/creatures/6804.png.import | 42 ++++ art/exported/creatures/6905.png | Bin 0 -> 6838 bytes art/exported/creatures/6905.png.import | 42 ++++ art/exported/creatures/7403.png | Bin 0 -> 3144 bytes art/exported/creatures/7403.png.import | 42 ++++ art/exported/creatures/7508.png | Bin 0 -> 2019 bytes art/exported/creatures/7508.png.import | 42 ++++ docs/ATTRIBUTION.md | 36 +-- fish/species/betta_siamese/betta_siamese.tres | 2 +- fish/species/bowfin/bowfin.tres | 2 +- .../chromis_blue_green.tres | 2 +- .../clownfish_ocellaris.tres | 2 +- fish/species/crab_brown/crab_brown.tres | 2 +- fish/species/gar_longnose/gar_longnose.tres | 2 +- fish/species/gar_spotted/gar_spotted.tres | 2 +- fish/species/hogfish/hogfish.tres | 2 +- fish/species/ladyfish/ladyfish.tres | 2 +- .../lungfish_west_african.tres | 2 +- fish/species/mahi_mahi/mahi_mahi.tres | 2 +- .../mullet_striped/mullet_striped.tres | 2 +- fish/species/paddlefish/paddlefish.tres | 2 +- .../salmon_atlantic/salmon_atlantic.tres | 2 +- fish/species/sheepshead/sheepshead.tres | 2 +- fish/species/sturgeon_lake/sturgeon_lake.tres | 2 +- .../sturgeon_shovelnose.tres | 2 +- tests/fish_catalog_content_validation.gd | 34 ++- tools/art/export_creature_art.py | 230 ++++++++++++++++++ 54 files changed, 1013 insertions(+), 35 deletions(-) create mode 100644 art/exported/creatures/101.png create mode 100644 art/exported/creatures/101.png.import create mode 100644 art/exported/creatures/102.png create mode 100644 art/exported/creatures/102.png.import create mode 100644 art/exported/creatures/103.png create mode 100644 art/exported/creatures/103.png.import create mode 100644 art/exported/creatures/104.png create mode 100644 art/exported/creatures/104.png.import create mode 100644 art/exported/creatures/105.png create mode 100644 art/exported/creatures/105.png.import create mode 100644 art/exported/creatures/106.png create mode 100644 art/exported/creatures/106.png.import create mode 100644 art/exported/creatures/107.png create mode 100644 art/exported/creatures/107.png.import create mode 100644 art/exported/creatures/3906.png create mode 100644 art/exported/creatures/3906.png.import create mode 100644 art/exported/creatures/4401.png create mode 100644 art/exported/creatures/4401.png.import create mode 100644 art/exported/creatures/4701.png create mode 100644 art/exported/creatures/4701.png.import create mode 100644 art/exported/creatures/5702.png create mode 100644 art/exported/creatures/5702.png.import create mode 100644 art/exported/creatures/5901.png create mode 100644 art/exported/creatures/5901.png.import create mode 100644 art/exported/creatures/6103.png create mode 100644 art/exported/creatures/6103.png.import create mode 100644 art/exported/creatures/6804.png create mode 100644 art/exported/creatures/6804.png.import create mode 100644 art/exported/creatures/6905.png create mode 100644 art/exported/creatures/6905.png.import create mode 100644 art/exported/creatures/7403.png create mode 100644 art/exported/creatures/7403.png.import create mode 100644 art/exported/creatures/7508.png create mode 100644 art/exported/creatures/7508.png.import create mode 100644 tools/art/export_creature_art.py diff --git a/art/exported/creatures/101.png b/art/exported/creatures/101.png new file mode 100644 index 0000000000000000000000000000000000000000..e6e431c4e13d5ce8ee18de9eeab71b38914e5e14 GIT binary patch literal 6688 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 WD@hOa1pmHNfSHlCVa*-axc>otn_ZFs literal 0 HcmV?d00001 diff --git a/art/exported/creatures/103.png.import b/art/exported/creatures/103.png.import new file mode 100644 index 0000000..2972cde --- /dev/null +++ b/art/exported/creatures/103.png.import @@ -0,0 +1,42 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cfdsg4tevv0jm" +path.s3tc="res://.godot/imported/103.png-f8985f10e04b19499cd6f5ffbadc83c9.s3tc.ctex" +path.etc2="res://.godot/imported/103.png-f8985f10e04b19499cd6f5ffbadc83c9.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} + +[deps] + +source_file="res://art/exported/creatures/103.png" +dest_files=["res://.godot/imported/103.png-f8985f10e04b19499cd6f5ffbadc83c9.s3tc.ctex", "res://.godot/imported/103.png-f8985f10e04b19499cd6f5ffbadc83c9.etc2.ctex"] + +[params] + +compress/mode=2 +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=0 +detect_3d/compress_to=0 diff --git a/art/exported/creatures/104.png b/art/exported/creatures/104.png new file mode 100644 index 0000000000000000000000000000000000000000..09eec3c33e1b572627fb7755a6b28f0182b67312 GIT binary patch literal 7444 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 literal 0 HcmV?d00001 diff --git a/art/exported/creatures/104.png.import b/art/exported/creatures/104.png.import new file mode 100644 index 0000000..01a9393 --- /dev/null +++ b/art/exported/creatures/104.png.import @@ -0,0 +1,42 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://pgm364g5qlgs" +path.s3tc="res://.godot/imported/104.png-e164f67032648af2cc0a545d2a861c7d.s3tc.ctex" +path.etc2="res://.godot/imported/104.png-e164f67032648af2cc0a545d2a861c7d.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} + +[deps] + +source_file="res://art/exported/creatures/104.png" +dest_files=["res://.godot/imported/104.png-e164f67032648af2cc0a545d2a861c7d.s3tc.ctex", "res://.godot/imported/104.png-e164f67032648af2cc0a545d2a861c7d.etc2.ctex"] + +[params] + +compress/mode=2 +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=0 +detect_3d/compress_to=0 diff --git a/art/exported/creatures/105.png b/art/exported/creatures/105.png new file mode 100644 index 0000000000000000000000000000000000000000..3ba1000ed6a4247eb2659d96327c55a39e68f614 GIT binary patch literal 2799 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 H?g{?`DnM*Y literal 0 HcmV?d00001 diff --git a/art/exported/creatures/105.png.import b/art/exported/creatures/105.png.import new file mode 100644 index 0000000..de797a7 --- /dev/null +++ b/art/exported/creatures/105.png.import @@ -0,0 +1,42 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dwanip2j561n7" +path.s3tc="res://.godot/imported/105.png-a2b440912e59b3375c6e61e17d08287b.s3tc.ctex" +path.etc2="res://.godot/imported/105.png-a2b440912e59b3375c6e61e17d08287b.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} + +[deps] + +source_file="res://art/exported/creatures/105.png" +dest_files=["res://.godot/imported/105.png-a2b440912e59b3375c6e61e17d08287b.s3tc.ctex", "res://.godot/imported/105.png-a2b440912e59b3375c6e61e17d08287b.etc2.ctex"] + +[params] + +compress/mode=2 +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=0 +detect_3d/compress_to=0 diff --git a/art/exported/creatures/106.png b/art/exported/creatures/106.png new file mode 100644 index 0000000000000000000000000000000000000000..61285cbcfb617d3e4526c1b0c8eeba1e0db0e2bd GIT binary patch literal 3777 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 F{ttQRICcO4 literal 0 HcmV?d00001 diff --git a/art/exported/creatures/106.png.import b/art/exported/creatures/106.png.import new file mode 100644 index 0000000..3032c38 --- /dev/null +++ b/art/exported/creatures/106.png.import @@ -0,0 +1,42 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cr7tc11ievso5" +path.s3tc="res://.godot/imported/106.png-306ce449448588714e5a0e883ae441ac.s3tc.ctex" +path.etc2="res://.godot/imported/106.png-306ce449448588714e5a0e883ae441ac.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} + +[deps] + +source_file="res://art/exported/creatures/106.png" +dest_files=["res://.godot/imported/106.png-306ce449448588714e5a0e883ae441ac.s3tc.ctex", "res://.godot/imported/106.png-306ce449448588714e5a0e883ae441ac.etc2.ctex"] + +[params] + +compress/mode=2 +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=0 +detect_3d/compress_to=0 diff --git a/art/exported/creatures/107.png b/art/exported/creatures/107.png new file mode 100644 index 0000000000000000000000000000000000000000..c843bbe1f4572dcf00a3e31e422fdee3768e1c48 GIT binary patch literal 2873 zcmbW3`9IT-1IOQ(tG;Sz?$9tAbF&W#Eio+R*eG%pOXfZ4&o%K7d4!}lNf{P23det7--dOV)gt5#+L$0Uvc03cwFHnI8Bl>dc~=MO&^ zy7d6SalqW@oiw^&od{fcO2N^7cwzR04z~$Lq=6Z@YeqPEg zuY`JfmdxfA+K&cvQgO1eBFCWnSgJ)v2Q_~}Z?Yb%O4q|k(e=VVGIoEi-47X95A|$r zWKOdtyy@mGGTsZ1bKhufGWVwaL}X*YD84iQ3SHsCk#t^Riag*A5O`{Yjo&fp7l09< zkLxy%EB94EmczKZ#RW8r1sb=!y#P}VDIPWlMHt`#Fb1q-!|twICBQEoiW@0SdUOfo z4{o~;SOVePZR__qUUG;rjuP3AIoXatHAfe5Ec%y{tc1f0?~R`TNBjuBIW^GeD{O0qY5A!I zS#kB+T9^_vNvr*A`m%p%mBr-7Mu3i9ZLLOmk`vxg@IrZY>w~^3-*wx(HH&xmc7C9b zQ=CGOloZr~J1X2jNN8R|Tl+=58O%w0@}DmmFz%ry|Fr!4q>;tvCke~`fBjiOi5}o) z(m~DjO7;BoT6rX|mzPl0FFb)DrDzo)Qfe*%9G5=B@sM|IGepx!O;j1DVk_Z&yKY|% zarVSydIBZkN&V|!R{}x%Nn3sWl&RaCX3EXz=!?HttcIH7*WFQ^x_(*20DY)IAvwTs z;Jn=OCwPn{Ai|a7IefQH%i7tw98(*4+bkl2$)!g8HXz7iHp>w_83*Fw&x#s;zrHKT z;rP!+6q2bCyK`V<7(5i@G~qjg6J#5Hg{nFe*oG=z-^E070JMJAw+LhT8}iJ@X=$|R zoS3ua72Y+0%epByNzEj*i+mZ0ose5ud0tw?ZD=18q5huWAQAteD5l+YGEogAl`UuX zz3F5#?`*t-ET?O>@zfZVN;SnU!cutj^v2OwMEljl2)d2$;?MCOZF4vuFqNh0FqjnF zmoGNjuu4l*TbFqiHG94xG<00a5i9Wm9D@kVNdkyd;q;#tDe30sR3#+`edW!FUu^5= zK}C1`Zmja#;hJYRr=DA84^DbV4(sXh^JGO{C@w(<>zi9yiMYGF*WUF=jUQz&kh9F@ zQA)pjzmJv1d;^|QM(Wl!QQF%>z(3~XfSYsOi9`^sKYT6#qnRoC#&^SCPDjR4V~8t& zx1c1r05n_Gaqkdzl#90*U@{}8oXj_(FUGjf$1SJkWl0Q0ceRZ&T&%g2-$R$|RLYBq zb!xZ|VRiU4LC{kg?WAu0sRL99grA=uefhDFK`8UxJl^0d-_FMQkQV9bX`AMsvkJgb zrvX3Zdv|gAqK87&1vmmxFuGA;Up;^KgAUeU!*oc+%LV(YbD7dDi;f9j{G^lVEFS&U z8@0QuB6j+;fPNnugE6^wjXy0toht-4oY5^UDfyu*fgo2i=>tay=-9sXFXHIz?9tHm z#2#$KzO_7ihaYN}<|$G3^}%9K#wDQ+i3BYBLX&gciAI3M#SXctXhh~88n&tNn+XAn zipF#CGU=n05NYA{wN~cDQQY^bBuQidYs}oi2bWO^Z=W4d|J+@dJYV=|ijV_^erfJ7 z=phSAh=a<+9aKahZ1HI5k5htfs6Ve58n#XS>d9P|yqteZL~x+A^tLu~=Eq+%Ge%E9 z80Zn}McMPHg$4On{I7y&nL%Lr=!Y$dlrN)?bFnL88j|E0CX>3`!^jxTtc2q@WrdA2 zH6Pg0j0$x(5Qq@}>Q@3T5ut-v7({tsWF$%DO)?&jFZcfB3V}eLlq{uF3>o&A!I6>J zxl}Wz&NGCH#N4hp+ z;NDAaq~Gfp1A03jCBvV5My4Pv7$JhN*ko_ht`uS=V1=XrR*nN8;P?hN*XD9q#3>!p z5i1t0E>7%pUHk;Gh{j(GGvjrCdb5VO0gcIDS&<-&*I73RlF@C70T0)MxO0iKeUBR* zUcYD?A?qO-tN?RsEnxEjg7+xH`G4tDL@@ovnN75 zI!%LQwY0dz#clTZPmtGUcgVk9)&Wodf$YA*yA0%>$5mXu2CluzsGey{YjQfJ(i?~n zqk5QIVC*2oCf%Mk(+Cx(a?V<1dr7yT3Lz_J%jgkLGbh5|4|F;d?hlWTC$U&(ca~O{ zdza)FO`8Q*MHUm1lW{0KC=S)?(dOb~Oq6N+-u~0iRKwV#lLy;R1_#lNwk1~|K)}ak zS)pF>dH2@$|2p+9?0#xde%Q_5I7}t!JqPV#)~(Zb(&zK`%XCkSm_}K0QP~Kti&fs@ zUGnBHf7IDL-;ToUVrs?}2gb%0ic~bay+MyGD6BYmAx7K%X%-9nBDMvpOjCBA}~yv0iE_WJ%$PEz*@ znA5|wn741WS}3DsT?s2m%$B(7sH`UZyQ64t z=Ndw&SNqGI5k?iwdVlMfLS>{nrl1j`p5?4)zbPdqCT4dXQJ8e+dXox7u|rPZLX)r2 zVvi8q_+y-@SKFkN=5A$bVNthRP@FNt&3{@s<#FD9%5XT77*L*&x#;fVw0Q@a!aA;h5=Ma6e=%Ss;#g_hs*?Xc<_ARdzGrEIUTGjZ-*cFXkw*d`(9*mQga_M=R= zON6y9wYX%xW#hmqG((<5*Z5QDNc&g>*rvF~wdF(`G=~%Hd?M^cz?&Fb8S!Y)0 z3cku&Hf*4|gOk`YhW0eM3myz{a49O_pzc-|Etoy~R|5He?*xu`XNS~YMlS#U_D6{U N^GjAHRVde}{{l7{NXq~K literal 0 HcmV?d00001 diff --git a/art/exported/creatures/107.png.import b/art/exported/creatures/107.png.import new file mode 100644 index 0000000..c4d28e8 --- /dev/null +++ b/art/exported/creatures/107.png.import @@ -0,0 +1,42 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c8rf4tnxx8mov" +path.s3tc="res://.godot/imported/107.png-062740d0bdad6e3ac4ecc1cf92ee6b9d.s3tc.ctex" +path.etc2="res://.godot/imported/107.png-062740d0bdad6e3ac4ecc1cf92ee6b9d.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} + +[deps] + +source_file="res://art/exported/creatures/107.png" +dest_files=["res://.godot/imported/107.png-062740d0bdad6e3ac4ecc1cf92ee6b9d.s3tc.ctex", "res://.godot/imported/107.png-062740d0bdad6e3ac4ecc1cf92ee6b9d.etc2.ctex"] + +[params] + +compress/mode=2 +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=0 +detect_3d/compress_to=0 diff --git a/art/exported/creatures/3906.png b/art/exported/creatures/3906.png new file mode 100644 index 0000000000000000000000000000000000000000..6cc1d1b359f7ef7f8bd2c6b76fa426b72eb91558 GIT binary patch literal 2742 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 wJ%g{Tr_ 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 literal 0 HcmV?d00001 diff --git a/art/exported/creatures/4401.png.import b/art/exported/creatures/4401.png.import new file mode 100644 index 0000000..809a146 --- /dev/null +++ b/art/exported/creatures/4401.png.import @@ -0,0 +1,42 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://7dbcbicxwvlo" +path.s3tc="res://.godot/imported/4401.png-0fd242cb090b49614af9b4d07503b2db.s3tc.ctex" +path.etc2="res://.godot/imported/4401.png-0fd242cb090b49614af9b4d07503b2db.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} + +[deps] + +source_file="res://art/exported/creatures/4401.png" +dest_files=["res://.godot/imported/4401.png-0fd242cb090b49614af9b4d07503b2db.s3tc.ctex", "res://.godot/imported/4401.png-0fd242cb090b49614af9b4d07503b2db.etc2.ctex"] + +[params] + +compress/mode=2 +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=0 +detect_3d/compress_to=0 diff --git a/art/exported/creatures/4701.png b/art/exported/creatures/4701.png new file mode 100644 index 0000000000000000000000000000000000000000..afd6a853fd29bca87048640a75ea6109ea855820 GIT binary patch literal 3770 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 literal 0 HcmV?d00001 diff --git a/art/exported/creatures/4701.png.import b/art/exported/creatures/4701.png.import new file mode 100644 index 0000000..5f4ed32 --- /dev/null +++ b/art/exported/creatures/4701.png.import @@ -0,0 +1,42 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://do6hcv3omivv5" +path.s3tc="res://.godot/imported/4701.png-dcb44d4ce6dfdc812a2e185e7f9511be.s3tc.ctex" +path.etc2="res://.godot/imported/4701.png-dcb44d4ce6dfdc812a2e185e7f9511be.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} + +[deps] + +source_file="res://art/exported/creatures/4701.png" +dest_files=["res://.godot/imported/4701.png-dcb44d4ce6dfdc812a2e185e7f9511be.s3tc.ctex", "res://.godot/imported/4701.png-dcb44d4ce6dfdc812a2e185e7f9511be.etc2.ctex"] + +[params] + +compress/mode=2 +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=0 +detect_3d/compress_to=0 diff --git a/art/exported/creatures/5702.png b/art/exported/creatures/5702.png new file mode 100644 index 0000000000000000000000000000000000000000..2da58085c4f18faaec6247554c7ac0cef8264627 GIT binary patch literal 1760 zcmV<61|Ru}P)q8OD_Du_l~ zBUJyO1fxVrOej$>p-~DLB0)fj;ub75MSDVzef!4zW;<`+zCC-9roK-yeaFt5H{ajP z?>E1B1x!p#OiWBnOiWBnOiWBn`U%4U@G#{83~+dyi8utOB~h5k%)|p!J42GYi<0-R(p`})2mW=ngik} z?->lyBUn!xAhAB5f?10uMXExbv~->q19(Zim@(`xIe-b{ry_5`JwXqoJ8h^~?hI*Y z=I71u@!r*7>&e51%a3w>=W7e?sP;IJozn)+ffb)|RMjffLDB8KyGoFf;u3X9xRKMV zQlh@^$A<&IPe0TwPkB!aVL=Z@e&w2_LiF~Ti8!-51w+Otm^Qx!o~sTF$x-mbYrY^+ zR+cD)hZCP*?b`dPU4y~M5WDV(0m@3t57NlX>G)i}icy(u@LWg~*IC(ZXlrKLIdwF_ z0zAKYZNxS^eBJ2@P+1UUgw36XNbXYuOc+0PH63a6X{2e|4;`z(>GYxQstw8MDw^x8 zsQV+}VU;WW9nD`lC8WKoTk-Q#h@6mMm~g^NE& zt>Z58M*aP-rcjq4@pdcVD`BD#h0`3^F~dRP>GE>8wpgSHFRe+@okU*0=76dOEk7a9 z3(4xSh~@@TOte`b4J6x`dH+PHstTu#p;h79)GsKu;_X>ZeVY6z zE?+K-Bp&g-h0G@i5>I@8>>~CS?GxuN2k_lBMf5qEF)hX$`ZSirO23XptR$wZ#99`= z{kkgT-kBZKu(9xIEYGoHL?IWhKyhl{Zwih+F5L8&m_e|i>7R&#{;o~Hog!Ldv@O?x z`O9)~;+wPDY^70l7u^=;N3I6PkXOFqGX71BX@E3WhB!8PIb8N&muYYBcH$T}BW0Vl_#2|ly8Wx-JM*fm1V)JscCbS{9y92d4H&E4pqbe$3 zi(`W19cqT%ZVxe>Ty`S`EIGpIqkI@%6uJ^+WQF!uzg%|`Vp$gMfy!g&`JBa*MI4|E zFEcZc;6z&ni6z-~yfGtuE+C(!t#DduI_ew#4eR@3(iuj&fJ=2iO*0P~;hF{@=VdMo zURGKz3@442>h>WaiN`IT2s>tWn_cTLSN+1{5l>WjyrSN+Bb1K&;fA%Ja~>jI{`Sce z3=8k&NKEF~umJFW_S}K}Uuv$=SZjsnSDPkUCqD8nELtiY<)b7a zzS0jocG%? zFMko82U?Vd0;B`!6z{;cZ4!wqRjDHbsz?utMg&qasT>E zxKdKl)ZD0DQ>rbqO(L3`$sAvgZkl&X8$-|GTV`9ZWwt+ak((i>Q$=g5Kg29N+=6Cr zR)ewjsON3zY-yA2Mx)X>0}VdNJy^P)k=nmNN7Xwy+*u6L<8!^+$>YWTD(;y;01-}S1(T69p4f+A8h9F4S@ftAR{|XJbe~jo6 z!f4f~e8$GD%ztfR;3o@Gc-tQTY5|}Iy#z!5f`2eC3HezJRx8Kt+g2^O`g{x{i#vL~ z-xl;A#FU;s%qlirNl7Y(xjVGL>K^aylR>kfpK|l29f^smwyJb4cfCGi8Tt=_q@4x6 zMD^3Y_9@0lpWU~wGchqSF)=YQF)=YQF)_jajsF0=A}aS51z-9A0000x literal 0 HcmV?d00001 diff --git a/art/exported/creatures/5702.png.import b/art/exported/creatures/5702.png.import new file mode 100644 index 0000000..527c367 --- /dev/null +++ b/art/exported/creatures/5702.png.import @@ -0,0 +1,42 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dxwr7yv5h30s" +path.s3tc="res://.godot/imported/5702.png-b0cead42bd9a5851c3f6775a7d0e469e.s3tc.ctex" +path.etc2="res://.godot/imported/5702.png-b0cead42bd9a5851c3f6775a7d0e469e.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} + +[deps] + +source_file="res://art/exported/creatures/5702.png" +dest_files=["res://.godot/imported/5702.png-b0cead42bd9a5851c3f6775a7d0e469e.s3tc.ctex", "res://.godot/imported/5702.png-b0cead42bd9a5851c3f6775a7d0e469e.etc2.ctex"] + +[params] + +compress/mode=2 +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=0 +detect_3d/compress_to=0 diff --git a/art/exported/creatures/5901.png b/art/exported/creatures/5901.png new file mode 100644 index 0000000000000000000000000000000000000000..a75039feaf883b458065c96d8f5165848b42e9d4 GIT binary patch literal 1940 zcmV;F2W$9=P)6GiVquKhfvTkeB#HkNdkL?a z8AUo822ddM8Xg&Zz-W;uT15TS{tDaa0v!3d7itS-=2HUJ-OV+$qQ3BpP8Mr zyXWqn{eE}P?w(Bm8yg!N8yg!N8yg#&u0!}kw}W?a{eq2~a7fu$vbMV$(G}#v>Aa+D zEGZ(<^6GFj>**W82+>Ns>6-68Tm;5^*Rv5KSako*rSdxMFQ0}q<$5t9!6YVRP#&4O zFIfEG%{z;Nj5v+|BQ}Q@%={s;DKuBmYY}EtgbHUmtMRM@W$OX-FjCKb1|+2dSFUKa za;^nxu1^J8T3VOBPz9_`Yn{irPkGC|PR*L0o##Hhw&0a{>7so9-NPn7_sK7&&8RAz z{qs3kRJ|4}lCs;`ei2-Tq3uiqP-3K3G)6Bm^gE#6MS#=12cV$`@NFXSz0=<;0lXOS zQYYVic&#gQRCe%H-V7LFMT0)ST=iRKp>U{*A`4GDf_71H50L8w)FzpX9g}{%DJj{5 z(yin8qL+3o9yRsC;lGw7?WZu zKra<)`D`|{WxBV)ojEt0I2J{GDS#;vhrGGR50(RC($OX|Qh~Aq;qQMu2w0K~B>nUh zF8(tCmulkucTNDfGeil^kl}X&)t*{hI@33jP8OwkqLuiPEa1>~V3`-V^)xWRgDxd! z9`L8vf!7WI+vY}V@6Q1IO~%MjGS-4dW=TB#YGEvfU)HiJd$Y*B)>(X%0Jw&lb7*)~ zn9?)?Dvi|Ma)5D7Ja?$@wJ6aH;9>HAWW*N5O7X<00dZ3NowZFs4b6*P%tVjY0r^#W zPI=IVNE1)LdBv-Vf8iOKmA|E8y_Hptg~=EAqX&5-&tqxtIxQz774frXl8wX&-*>P3$#hHy2;}q@)%Bjh`jes0$04jj)rH#M06Ki2l|EDt%QV!$#2~A>O$nd ze*)vPerM^S&NtQoKL1|73+N#jZW7#Dx30S7so%c--X<$QWfAU)Wn0To*CSCTjLFc- zhKAxFmup{x?I5Em7Gs;Vl_!|GbN|6x3uoZcg|ooTNkHKmp#MNXRRBc|Jb&DaW!Seo zqa*tOLiFtI#D$Jqhem7 zwo=e%Zj|Mc%nQfzDR)6t1Lp|olb_iCKvvFiiu-&jE0~%36oBsm#QXRD&4HHFS%i<; zAs|H9p~zqjVK$+;sS&eA{#eHUuA_}gIdc4JoR&GVTUO01jUm3nWQH5t$ z7Rwt)_m{!hw?CH8nTUFCV4hg9`y}$WerfcwIfN6ha3+OQAbvA`m?oZUCn>SL;u`ed zl6cOQaI-E=2FxGcjwFm*G{7)n+JaSBGGi<}HQKRU+5?Xu=Z*Klxq_Z?eISI8y-&gQ z*uC0v8U22#r)TfoNJ=ZM`DA9+;Kj8<(7S}$45^kBh;2=D2^DU-alE2m+!v+6&RA-T3jTu zMkS)DA)q%HWnJ;Q#c{)YXRA-e6)l)P(=_?vni`P*&Y>M7p01SfucH#zNHAoHi!Sa8!TucPyAPwk^-6&%)c#iLiE*ktR^YYy`11Gj%F0hXfgN4chX< z{dL%75>Iiu3gJUaT83dr1ZbAN*)&uCAmZC=03*S<#pQHfDAA3WxBA_3*W+WP8H6Jy zMtynHu97#)D&JU3R#>U3R#>NKS ag#Q7^p7{8#1%TE70000 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 O0f5v)>DJw{!~YMRHRwqI literal 0 HcmV?d00001 diff --git a/art/exported/creatures/7403.png.import b/art/exported/creatures/7403.png.import new file mode 100644 index 0000000..bef32c4 --- /dev/null +++ b/art/exported/creatures/7403.png.import @@ -0,0 +1,42 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bl7ru6gm5xnpb" +path.s3tc="res://.godot/imported/7403.png-54b51089a6ff5c2091372d659ba4bdc7.s3tc.ctex" +path.etc2="res://.godot/imported/7403.png-54b51089a6ff5c2091372d659ba4bdc7.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} + +[deps] + +source_file="res://art/exported/creatures/7403.png" +dest_files=["res://.godot/imported/7403.png-54b51089a6ff5c2091372d659ba4bdc7.s3tc.ctex", "res://.godot/imported/7403.png-54b51089a6ff5c2091372d659ba4bdc7.etc2.ctex"] + +[params] + +compress/mode=2 +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=0 +detect_3d/compress_to=0 diff --git a/art/exported/creatures/7508.png b/art/exported/creatures/7508.png new file mode 100644 index 0000000000000000000000000000000000000000..3f6c7f33ea36a99ace7405f1f1dbe1ee7d5e2d34 GIT binary patch literal 2019 zcmV<92ORi`P)? z|L7lr#4n(tu|p(83yDM^0aRKCIYLnz;RFax9MJ^l!@2r=7rmeUlFJW&~ULQ@q+965a!{X163vYxur z_dtGVmtD|r0L3@>2V_UVw2w>MUL-|Or3M{chvdp1_}5`%zPuNd>@I1;GiyM}ZNVK_ zr@^yN+JhCfVfqIkI_Xu!1y=%vRT{NTd}U`J_HO?a`AIXBtdI+IvNn+Cv~Uo1#Q%UC zYX;9g4p_rj#}>=F?!X7h2>Sc;G9?)N`1vL4{$ zAP_oPYhb4)IJQ~?t|Er?1=v;p7&N0t4tXIJL1C3lkD0gpWl~O%)b6daMDgpl0mjY5zp(x`3M7D)yo2uOWZtL$6ah0bt%aO$fFt3&Lha z{{Mp`-r?=zkbM?oFi|VRfa>r!>nnVVye#h{4b-6yLURE4x7idF5C}!yi=g9htl{?<)Z0qrt5z?^RB*8OFfu3l z!E+lVN!K_!gvIaT7(}1*3;Av4`p@t&aXP})tcO*mJ=_2G?}GjOyA54PYgdO7sfPI3 z;cwCZ&;POuvl+cF|3u1_B8tf?c@6H61wr28ze7jYtHBeb1pxn_ zB&^1>;}N@GC8s2y8C#r?-LD8eOaV1@vwVc*_99JgL65HnC0+!WIC8-_{O@D(3#9G{9D_nEjagz~ghm^l1dJ7q zrWq)?wJdQ^7Mi{TmbnVtT8ODaj3LHvFtQjK-0LbpkSC)NsNW1Q1AKU`Z^x;qRLO*r z%7xlgVGbhyHmW*%IJ*f9rnLOU(cuHR=kbR5X`$m}H#DQn{eZ_|=I<>L6FUN?_4#CK z8;ji|hzkYuZU3XMP7pLFJBxZ7hi1ItwkyY#LdbJ!d<~!)*8{jNkF|gjFJSS+ezcFi z?aI9#VW;&q(ij7)Uo1c813{qqhIf$u?3bu(Y{kT^wt@oU8v*?~pAx*sqS7Uc%93|i znA>N_A*fbgxkT~Xz1&y>CGr1BK8pAGV5rruL|*Lx0kI1z^^;G31X$x$C13Oa8E;+r zGzRt-&XFP*M&4WY;>Q%jI&#`na9_nRv-_c^?n3)L%dw|t6IMO45iB;}Ch0N4MVOpQ zwpb4mUe&U^<{&hyk zms7x9nd5uxWR`G)MG&qn?%e9aR$tT=CqNWlZ?@(4vjuM3Zrwg|vcKdubSE~s3-IN^ zM`tWML}77cx!j+aq|Ep@N>{2I!31_TF9GL`u=001{R@|CpMQ@Fp0U&zRk8uT z3WxCf_3Lr+?1n%Esj$;azDKO4$P&^pnEnoyp4o>5+9JG_-zSZ$iZdQ!i(o*L?LC3l zjOGQYS_Oz?=Rz3xs_|todI{!tEuh$HUmH)%9iSX8lb!|tP0&+!U~SsACt}MBnNqSt zUgXT@Vg|tiWFj?-d2Ok})H4suOs^m>L(bv867o#n>6VMhi)ZYNuZ_0E%~-2lFGJVv z`UfRhu?xhiUrqJssS&Y^#Eg!Q5CIu{=u4nCEfVpU@9wRjV(+j-g{sp)_!XOv zL_o0SNv~b7_X2v5@Z=Y7;_#kl3XPh?`@Tttgn~`3yBxvCSnQg=1dc?y1)k5E;K}XQ zvC?WIsgm6n^z9IZ)Rny9HBvtZ_x+QG@AA5h6s05FtW@2oWMgh=5BC-vjvtExV>1hf4qe002ovPDHLkV1l=M B+}8jA literal 0 HcmV?d00001 diff --git a/art/exported/creatures/7508.png.import b/art/exported/creatures/7508.png.import new file mode 100644 index 0000000..a14d42f --- /dev/null +++ b/art/exported/creatures/7508.png.import @@ -0,0 +1,42 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bnw0dw14iyxbh" +path.s3tc="res://.godot/imported/7508.png-555eff33a2cb66622ccca9d7f063522e.s3tc.ctex" +path.etc2="res://.godot/imported/7508.png-555eff33a2cb66622ccca9d7f063522e.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} + +[deps] + +source_file="res://art/exported/creatures/7508.png" +dest_files=["res://.godot/imported/7508.png-555eff33a2cb66622ccca9d7f063522e.s3tc.ctex", "res://.godot/imported/7508.png-555eff33a2cb66622ccca9d7f063522e.etc2.ctex"] + +[params] + +compress/mode=2 +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=0 +detect_3d/compress_to=0 diff --git a/docs/ATTRIBUTION.md b/docs/ATTRIBUTION.md index 2eac3e5..d24f1ca 100644 --- a/docs/ATTRIBUTION.md +++ b/docs/ATTRIBUTION.md @@ -12,14 +12,14 @@ publicly credited as Voyager and Endeavour and operating collectively as the independent team Woofmeow. - Voyager: co-owner, developer, project director, creator of the game's - current provisional fish artwork and original 3D work except where another - creator is credited, and composer of the original title track + provisional fish placeholder artwork and original 3D work except where + another creator is credited, and composer of the original title track `audio/music/title/as_in_four_wolves.ogg`, dusk/world track `audio/music/world/craft.mp3`, and the synthesized robot animalese tones. - Endeavour: co-owner, developer, and 2D artist. Her incorporated work includes - character pattern/channel-map art, environment and UI art, and item art. - Planned work is not described as incorporated until its files enter the - project. + character pattern/channel-map art, environment and UI art, item art, and + finished fish and creature artwork. Planned work is not described as + incorporated until its files enter the project. - chillnfill: contributor of original 3D models for the character bodies and arms, ears and tails, and multiple world props and decorative assets, including trees and bridges. The contributor requested to be credited as @@ -132,17 +132,18 @@ locations only and must never appear in scenes or resources. ## Project-created artwork and music -The current in-game fish images are provisional artwork by Voyager. Endeavour -is creating replacement fish artwork, but work that has not been incorporated -into this repository is not presented here as part of the game or attributed -as a shipped asset. +Fish entries without finished replacement artwork use provisional placeholder +artwork by Voyager. Incorporated finished fish and creature artwork is by +Endeavour; work that has not entered this repository is not presented here as +part of the game or attributed as a shipped asset. Endeavour's incorporated 2D contributions include the inventory notepad, -environment and UI artwork, item artwork, and character customization pattern -and channel-map artwork. Voyager's incorporated original work includes 3D -assets, the provisional fish artwork, the title and dusk music identified -above, and the robot animalese tones. Specific contributor exceptions are -recorded below and in the in-game credits. +environment and UI artwork, item artwork, finished fish and creature artwork, +and character customization pattern and channel-map artwork. Voyager's +incorporated original work includes 3D assets, the provisional fish +placeholder artwork, the title and dusk music identified above, and the robot +animalese tones. Specific contributor exceptions are recorded below and in the +in-game credits. These credits intentionally describe work by creator and contribution family instead of maintaining a second file-by-file asset catalog. Runtime resources @@ -192,9 +193,10 @@ https://creativecommons.org/licenses/by/3.0/legalcode. ### Endeavour 2D contribution record Endeavour created the incorporated character customization patterns and -channel maps as well as additional UI, environment, and item artwork. The -current provisional fish artwork is not part of this credit. New or replacement -art is added to this record only after it is accepted into the repository. +channel maps as well as additional UI, environment, item, fish, and creature +artwork. Voyager's provisional fish placeholder is not part of this credit. +New or replacement art is added to this record only after it is accepted into +the repository. The exact current filenames and bytes are intentionally not copied into this document. The character appearance resources and Git history are the diff --git a/fish/species/betta_siamese/betta_siamese.tres b/fish/species/betta_siamese/betta_siamese.tres index d8e7ee5..4c9ca3c 100644 --- a/fish/species/betta_siamese/betta_siamese.tres +++ b/fish/species/betta_siamese/betta_siamese.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="99_fish_placeholder"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/7508.png" id="99_fish_placeholder"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/fish/species/bowfin/bowfin.tres b/fish/species/bowfin/bowfin.tres index b87370e..7b2881f 100644 --- a/fish/species/bowfin/bowfin.tres +++ b/fish/species/bowfin/bowfin.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="4_texture"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/101.png" id="4_texture"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/fish/species/chromis_blue_green/chromis_blue_green.tres b/fish/species/chromis_blue_green/chromis_blue_green.tres index 17061f8..65f35b4 100644 --- a/fish/species/chromis_blue_green/chromis_blue_green.tres +++ b/fish/species/chromis_blue_green/chromis_blue_green.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="99_fish_placeholder"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/5901.png" id="99_fish_placeholder"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/fish/species/clownfish_ocellaris/clownfish_ocellaris.tres b/fish/species/clownfish_ocellaris/clownfish_ocellaris.tres index 25bea8f..16566cb 100644 --- a/fish/species/clownfish_ocellaris/clownfish_ocellaris.tres +++ b/fish/species/clownfish_ocellaris/clownfish_ocellaris.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="99_fish_placeholder"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/5702.png" id="99_fish_placeholder"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/fish/species/crab_brown/crab_brown.tres b/fish/species/crab_brown/crab_brown.tres index 8f31b9c..7dec427 100644 --- a/fish/species/crab_brown/crab_brown.tres +++ b/fish/species/crab_brown/crab_brown.tres @@ -2,7 +2,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] -[ext_resource type="Texture2D" path="res://fish/species/crab_brown/crab_brown.png" id="3_texture"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/3906.png" id="3_texture"] [resource] script = ExtResource("1_fish_data") diff --git a/fish/species/gar_longnose/gar_longnose.tres b/fish/species/gar_longnose/gar_longnose.tres index 28f6e7b..aaabbfe 100644 --- a/fish/species/gar_longnose/gar_longnose.tres +++ b/fish/species/gar_longnose/gar_longnose.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="4_texture"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/103.png" id="4_texture"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/fish/species/gar_spotted/gar_spotted.tres b/fish/species/gar_spotted/gar_spotted.tres index faf4afc..3405ee9 100644 --- a/fish/species/gar_spotted/gar_spotted.tres +++ b/fish/species/gar_spotted/gar_spotted.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="4_texture"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/106.png" id="4_texture"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/fish/species/hogfish/hogfish.tres b/fish/species/hogfish/hogfish.tres index bd4373d..0e94a20 100644 --- a/fish/species/hogfish/hogfish.tres +++ b/fish/species/hogfish/hogfish.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="99_fish_placeholder"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/6103.png" id="99_fish_placeholder"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/fish/species/ladyfish/ladyfish.tres b/fish/species/ladyfish/ladyfish.tres index 55aa31a..57c277c 100644 --- a/fish/species/ladyfish/ladyfish.tres +++ b/fish/species/ladyfish/ladyfish.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="99_fish_placeholder"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/7403.png" id="99_fish_placeholder"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/fish/species/lungfish_west_african/lungfish_west_african.tres b/fish/species/lungfish_west_african/lungfish_west_african.tres index 342a8f8..f049cff 100644 --- a/fish/species/lungfish_west_african/lungfish_west_african.tres +++ b/fish/species/lungfish_west_african/lungfish_west_african.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="4_texture"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/107.png" id="4_texture"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/fish/species/mahi_mahi/mahi_mahi.tres b/fish/species/mahi_mahi/mahi_mahi.tres index c7bd6dd..691a52a 100644 --- a/fish/species/mahi_mahi/mahi_mahi.tres +++ b/fish/species/mahi_mahi/mahi_mahi.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="99_fish_placeholder"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/4701.png" id="99_fish_placeholder"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/fish/species/mullet_striped/mullet_striped.tres b/fish/species/mullet_striped/mullet_striped.tres index 409d7d6..a285ee7 100644 --- a/fish/species/mullet_striped/mullet_striped.tres +++ b/fish/species/mullet_striped/mullet_striped.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="99_fish_placeholder"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/6804.png" id="99_fish_placeholder"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/fish/species/paddlefish/paddlefish.tres b/fish/species/paddlefish/paddlefish.tres index 80cebe6..498730b 100644 --- a/fish/species/paddlefish/paddlefish.tres +++ b/fish/species/paddlefish/paddlefish.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="4_texture"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/104.png" id="4_texture"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/fish/species/salmon_atlantic/salmon_atlantic.tres b/fish/species/salmon_atlantic/salmon_atlantic.tres index 122c3e3..1c77325 100644 --- a/fish/species/salmon_atlantic/salmon_atlantic.tres +++ b/fish/species/salmon_atlantic/salmon_atlantic.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" uid="uid://h7hg45b7wmst" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/bass_catch_profile.tres" id="2_profile"] [ext_resource type="Script" uid="uid://bs7cqi88csolc" path="res://fish/fish_availability.gd" id="3_availability_script"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="4_texture"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/4401.png" id="4_texture"] [sub_resource type="Resource" id="AtlanticSalmonAvailability"] script = ExtResource("3_availability_script") diff --git a/fish/species/sheepshead/sheepshead.tres b/fish/species/sheepshead/sheepshead.tres index 5e3fcc0..d4b8cfa 100644 --- a/fish/species/sheepshead/sheepshead.tres +++ b/fish/species/sheepshead/sheepshead.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="99_fish_placeholder"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/6905.png" id="99_fish_placeholder"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/fish/species/sturgeon_lake/sturgeon_lake.tres b/fish/species/sturgeon_lake/sturgeon_lake.tres index cf73e25..eef7789 100644 --- a/fish/species/sturgeon_lake/sturgeon_lake.tres +++ b/fish/species/sturgeon_lake/sturgeon_lake.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="4_texture"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/102.png" id="4_texture"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/fish/species/sturgeon_shovelnose/sturgeon_shovelnose.tres b/fish/species/sturgeon_shovelnose/sturgeon_shovelnose.tres index 03434b0..9f02593 100644 --- a/fish/species/sturgeon_shovelnose/sturgeon_shovelnose.tres +++ b/fish/species/sturgeon_shovelnose/sturgeon_shovelnose.tres @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] [ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] [ext_resource type="Script" path="res://fish/fish_availability.gd" id="3_availability"] -[ext_resource type="Texture2D" path="res://art/exported/creatures/fish_placeholder.png" id="4_texture"] +[ext_resource type="Texture2D" path="res://art/exported/creatures/105.png" id="4_texture"] [sub_resource type="Resource" id="Availability"] script = ExtResource("3_availability") diff --git a/tests/fish_catalog_content_validation.gd b/tests/fish_catalog_content_validation.gd index 4a252b0..d7174a8 100644 --- a/tests/fish_catalog_content_validation.gd +++ b/tests/fish_catalog_content_validation.gd @@ -40,6 +40,25 @@ const StarterRegionScene = preload( const SharedFishPlaceholder: Texture2D = preload( "res://art/exported/creatures/fish_placeholder.png" ) +const FINAL_CREATURE_TEXTURE_PATHS: Dictionary[StringName, String] = { + &"bowfin": "res://art/exported/creatures/101.png", + &"sturgeon_lake": "res://art/exported/creatures/102.png", + &"gar_longnose": "res://art/exported/creatures/103.png", + &"paddlefish": "res://art/exported/creatures/104.png", + &"sturgeon_shovelnose": "res://art/exported/creatures/105.png", + &"gar_spotted": "res://art/exported/creatures/106.png", + &"lungfish_west_african": "res://art/exported/creatures/107.png", + &"crab_brown": "res://art/exported/creatures/3906.png", + &"salmon_atlantic": "res://art/exported/creatures/4401.png", + &"mahi_mahi": "res://art/exported/creatures/4701.png", + &"clownfish_ocellaris": "res://art/exported/creatures/5702.png", + &"chromis_blue_green": "res://art/exported/creatures/5901.png", + &"hogfish": "res://art/exported/creatures/6103.png", + &"mullet_striped": "res://art/exported/creatures/6804.png", + &"sheepshead": "res://art/exported/creatures/6905.png", + &"ladyfish": "res://art/exported/creatures/7403.png", + &"betta_siamese": "res://art/exported/creatures/7508.png", +} const ACTIVE_CATALOG_COUNT: int = 313 const INACTIVE_CATALOG_COUNT: int = 3 @@ -186,6 +205,8 @@ func _validate_catalog_and_pools() -> void: var active_count: int = 0 var inactive_count: int = 0 var fishing_count: int = 0 + var final_art_count: int = 0 + var placeholder_fishing_count: int = 0 var catalog_numbers: Dictionary[int, bool] = {} for fish: FishDataType in Catalog.candidates: assert(fish != null and not fish.id.is_empty()) @@ -204,13 +225,24 @@ func _validate_catalog_and_pools() -> void: inactive_count += 1 assert(not fish.is_selectable()) assert(fish.display_texture == null) + if FINAL_CREATURE_TEXTURE_PATHS.has(fish.id): + var expected_texture: Texture2D = load( + FINAL_CREATURE_TEXTURE_PATHS[fish.id] + ) as Texture2D + assert(expected_texture != null) + assert(fish.display_texture == expected_texture) + final_art_count += 1 if fish.collection_method == FishDataType.CollectionMethod.FISHING: fishing_count += 1 assert(fish.active) - assert(fish.display_texture == SharedFishPlaceholder) + if not FINAL_CREATURE_TEXTURE_PATHS.has(fish.id): + assert(fish.display_texture == SharedFishPlaceholder) + placeholder_fishing_count += 1 assert(active_count == ACTIVE_CATALOG_COUNT) assert(inactive_count == INACTIVE_CATALOG_COUNT) assert(fishing_count == FISHING_SPECIES_COUNT) + assert(final_art_count == FINAL_CREATURE_TEXTURE_PATHS.size()) + assert(placeholder_fishing_count == 294) var chum: FishDataType = Catalog.get_fish_by_id(&"salmon_chum") assert(chum != null) assert(chum.get_season_text() == "fall") diff --git a/tools/art/export_creature_art.py b/tools/art/export_creature_art.py new file mode 100644 index 0000000..fb44e52 --- /dev/null +++ b/tools/art/export_creature_art.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Export cataloged creature artwork with consistent transparent margins.""" + +from __future__ import annotations + +import argparse +import hashlib +import re +import sys +import zipfile +import xml.etree.ElementTree as ET +from pathlib import Path + +from PIL import Image + + +TABLE_NAMESPACE = "urn:oasis:names:tc:opendocument:xmlns:table:1.0" +TABLE_ATTRIBUTE = "{%s}" % TABLE_NAMESPACE +NAMESPACES = {"table": TABLE_NAMESPACE} +VALID_EXPORT_SIZES = {64, 128, 256, 512} +SAFE_AREA_RATIO = 0.875 +KNOWN_SOURCE_ALIASES = { + # The delivered filename predates the catalog's authoritative species ID. + "bowfish": "bowfin", +} + + +def parse_arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Export PNG creature art according to the catalog's recommended " + "canvas size, using alpha-bound trimming and nearest-neighbor scaling." + ) + ) + parser.add_argument("--tracker", required=True, type=Path) + parser.add_argument("--source", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument( + "--skip", + action="append", + default=[], + help="Catalog creature ID or source filename stem to skip; repeat as needed.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Validate and report the batch without writing output files.", + ) + return parser.parse_args() + + +def normalize_name(value: str) -> str: + normalized = value.strip().lower().replace("&", " and ") + normalized = re.sub(r"[^a-z0-9]+", "_", normalized) + return normalized.strip("_") + + +def expanded_row_values(row: ET.Element) -> list[str]: + values: list[str] = [] + for cell in row.findall("table:table-cell", NAMESPACES): + repeat = int( + cell.attrib.get( + TABLE_ATTRIBUTE + "number-columns-repeated", + "1", + ) + ) + values.extend(["".join(cell.itertext()).strip()] * repeat) + return values + + +def load_catalog(tracker_path: Path) -> list[dict[str, str]]: + with zipfile.ZipFile(tracker_path) as archive: + root = ET.fromstring(archive.read("content.xml")) + table = root.find(".//table:table", NAMESPACES) + if table is None: + raise ValueError("tracker does not contain a table") + rows = table.findall("table:table-row", NAMESPACES) + if not rows: + raise ValueError("tracker table is empty") + headers = expanded_row_values(rows[0]) + required_headers = { + "catalog_number", + "display_name", + "id", + "recommended_export_canvas_px", + } + missing_headers = required_headers.difference(headers) + if missing_headers: + raise ValueError( + "tracker is missing required columns: " + + ", ".join(sorted(missing_headers)) + ) + records: list[dict[str, str]] = [] + for row in rows[1:]: + values = expanded_row_values(row) + if not any(values): + continue + record = dict(zip(headers, values)) + if record.get("id", "").strip(): + records.append(record) + return records + + +def catalog_lookup(records: list[dict[str, str]]) -> dict[str, dict[str, str]]: + lookup: dict[str, dict[str, str]] = {} + for record in records: + keys = { + normalize_name(record["id"]), + normalize_name(record["display_name"]), + } + for key in keys: + previous = lookup.get(key) + if previous is not None and previous["id"] != record["id"]: + raise ValueError( + f"ambiguous normalized catalog name {key!r}: " + f"{previous['id']} and {record['id']}" + ) + lookup[key] = record + return lookup + + +def resolve_record( + source_path: Path, + lookup: dict[str, dict[str, str]], +) -> dict[str, str]: + source_key = normalize_name(source_path.stem) + catalog_key = KNOWN_SOURCE_ALIASES.get(source_key, source_key) + record = lookup.get(catalog_key) + if record is None: + raise ValueError( + f"cannot map source {source_path.name!r} to one catalog creature" + ) + return record + + +def normalize_artwork(source_path: Path, canvas_size: int) -> tuple[Image.Image, tuple[int, int, int, int], tuple[int, int]]: + with Image.open(source_path) as source_image: + artwork = source_image.convert("RGBA") + alpha_bounds = artwork.getchannel("A").getbbox() + if alpha_bounds is None: + raise ValueError(f"source {source_path.name!r} is fully transparent") + visible = artwork.crop(alpha_bounds) + safe_long_side = round(canvas_size * SAFE_AREA_RATIO) + scale = safe_long_side / max(visible.width, visible.height) + scaled_size = ( + max(1, round(visible.width * scale)), + max(1, round(visible.height * scale)), + ) + visible = visible.resize(scaled_size, Image.Resampling.NEAREST) + canvas = Image.new("RGBA", (canvas_size, canvas_size), (0, 0, 0, 0)) + offset = ( + (canvas_size - scaled_size[0]) // 2, + (canvas_size - scaled_size[1]) // 2, + ) + canvas.alpha_composite(visible, offset) + return canvas, alpha_bounds, scaled_size + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for block in iter(lambda: source.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def main() -> int: + arguments = parse_arguments() + records = load_catalog(arguments.tracker) + lookup = catalog_lookup(records) + skip_keys = {normalize_name(value) for value in arguments.skip} + source_paths = sorted(arguments.source.glob("*.png")) + if not source_paths: + raise ValueError(f"no PNG files found under {arguments.source}") + + resolved: list[tuple[Path, dict[str, str]]] = [] + output_names: set[str] = set() + for source_path in source_paths: + record = resolve_record(source_path, lookup) + source_key = normalize_name(source_path.stem) + creature_key = normalize_name(record["id"]) + if source_key in skip_keys or creature_key in skip_keys: + print(f"SKIP {source_path.name} -> {record['id']}") + continue + output_name = f"{record['catalog_number']}.png" + if output_name in output_names: + raise ValueError(f"duplicate output filename {output_name}") + output_names.add(output_name) + resolved.append((source_path, record)) + + if not arguments.dry_run: + arguments.output.mkdir(parents=True, exist_ok=True) + + for source_path, record in resolved: + canvas_size = int(record["recommended_export_canvas_px"]) + if canvas_size not in VALID_EXPORT_SIZES: + raise ValueError( + f"{record['id']} has invalid export size {canvas_size}" + ) + image, alpha_bounds, scaled_size = normalize_artwork( + source_path, + canvas_size, + ) + output_path = arguments.output / f"{record['catalog_number']}.png" + if not arguments.dry_run: + image.save(output_path, format="PNG", optimize=False, compress_level=6) + output_hash = sha256(output_path) + else: + output_hash = "dry-run" + print( + "EXPORT " + f"{source_path.name} -> {output_path.name} " + f"id={record['id']} canvas={canvas_size}x{canvas_size} " + f"alpha_bounds={alpha_bounds} visible={scaled_size[0]}x{scaled_size[1]} " + f"source_sha256={sha256(source_path)} output_sha256={output_hash}" + ) + + print( + f"Complete: {len(resolved)} export(s), " + f"{len(source_paths) - len(resolved)} skipped." + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, ValueError, zipfile.BadZipFile) as error: + print(f"ERROR: {error}", file=sys.stderr) + raise SystemExit(1) From 8b13ba1d7d114101cbb600da776017cdfb2c0562 Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 24 Aug 2026 12:01:33 -0400 Subject: [PATCH 16/44] Normalize logbook creature portraits --- tests/logbook_validation.gd | 34 +++++++++++++++++++-- ui/components/logbook_portrait.gd | 51 +++++++++++++++++++++++++++++++ ui/logbook_page.gd | 9 ++++-- 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/tests/logbook_validation.gd b/tests/logbook_validation.gd index a621a8a..74736fa 100644 --- a/tests/logbook_validation.gd +++ b/tests/logbook_validation.gd @@ -162,6 +162,8 @@ func _validate_page() -> void: var shared_material: Material var silhouette_count: int = 0 + var smallest_silhouette_area: float = INF + var largest_silhouette_area: float = 0.0 for category: LogbookCatalog.Category in [ LogbookCatalog.Category.FRESH_WATER, LogbookCatalog.Category.SALT_WATER, @@ -192,8 +194,15 @@ func _validate_page() -> void: assert(portrait != null) assert(portrait.source_texture == fish.display_texture) assert( - portrait.custom_minimum_size - == LogbookPage.CATALOG_PORTRAIT_SIZE + portrait.custom_minimum_size.x + <= LogbookPage.CATALOG_PORTRAIT_SIZE.x + and portrait.custom_minimum_size.y + <= LogbookPage.CATALOG_PORTRAIT_SIZE.y + ) + assert(portrait.get_parent() is CenterContainer) + assert( + (portrait.get_parent() as CenterContainer).custom_minimum_size + == LogbookPage.CATALOG_PORTRAIT_SIZE ) assert( portrait.expand_mode @@ -209,6 +218,19 @@ func _validate_page() -> void: else: assert(portrait.material == shared_material) assert(_texture_has_transparency(portrait.texture)) + var silhouette_area: float = ( + portrait.custom_minimum_size.x + * portrait.custom_minimum_size.y + ) + assert(silhouette_area > 0.0) + smallest_silhouette_area = minf( + smallest_silhouette_area, + silhouette_area, + ) + largest_silhouette_area = maxf( + largest_silhouette_area, + silhouette_area, + ) silhouette_count += 1 for candidate: FishDataType in CatalogResource.candidates: assert(not entry.text.contains(candidate.display_name)) @@ -223,6 +245,9 @@ func _validate_page() -> void: ) ) assert(silhouette_count == 310) + assert( + largest_silhouette_area / smallest_silhouette_area <= 1.12 + ) page.call("_select_category", LogbookCatalog.Category.SHELLFISH) await create_timer(0.25).timeout @@ -269,6 +294,11 @@ func _validate_page() -> void: assert(known_portrait != null) assert(known_portrait.source_texture == bluegill.display_texture) assert(known_portrait.material == null) + assert(known_portrait.get_parent() is CenterContainer) + assert( + (known_portrait.get_parent() as CenterContainer).custom_minimum_size + == LogbookPage.CATALOG_PORTRAIT_SIZE + ) assert(known.button_pressed) _validate_handwritten_logbook_font(page) diff --git a/ui/components/logbook_portrait.gd b/ui/components/logbook_portrait.gd index ef23922..fb5fa4a 100644 --- a/ui/components/logbook_portrait.gd +++ b/ui/components/logbook_portrait.gd @@ -3,6 +3,7 @@ extends TextureRect const ENTRY_FRAME_SIZE := Vector2(86.0, 40.0) const DETAIL_FRAME_SIZE := Vector2(240.0, 132.0) +const UNIFORM_FOOTPRINT_AREA_RATIO: float = 0.6 static var _normalized_textures: Dictionary[String, Texture2D] = {} @@ -52,6 +53,53 @@ func configure_fitted( custom_minimum_size = texture_size * fit_scale +func configure_uniform_footprint( + portrait_texture: Texture2D, + maximum_size: Vector2, + portrait_material: Material = null, +) -> void: + source_texture = portrait_texture + material = portrait_material + texture = _normalize_visible_bounds(portrait_texture) + if texture == null: + custom_minimum_size = Vector2.ZERO + return + custom_minimum_size = uniform_footprint_size( + texture.get_size(), + maximum_size, + ) + + +static func uniform_footprint_size( + visible_size: Vector2, + maximum_size: Vector2, +) -> Vector2: + if ( + visible_size.x <= 0.0 + or visible_size.y <= 0.0 + or maximum_size.x <= 0.0 + or maximum_size.y <= 0.0 + ): + return Vector2.ZERO + var target_area: float = ( + maximum_size.x + * maximum_size.y + * UNIFORM_FOOTPRINT_AREA_RATIO + ) + var area_scale: float = sqrt( + target_area / (visible_size.x * visible_size.y) + ) + var fit_scale: float = minf( + maximum_size.x / visible_size.x, + maximum_size.y / visible_size.y, + ) + var scale: float = minf(area_scale, fit_scale) + return Vector2( + minf(maximum_size.x, maxf(1.0, roundf(visible_size.x * scale))), + minf(maximum_size.y, maxf(1.0, roundf(visible_size.y * scale))), + ) + + static func _normalize_visible_bounds( portrait_texture: Texture2D, ) -> Texture2D: @@ -67,6 +115,9 @@ static func _normalize_visible_bounds( if image == null or image.is_empty(): _normalized_textures[cache_key] = portrait_texture return portrait_texture + if image.is_compressed() and image.decompress() != OK: + _normalized_textures[cache_key] = portrait_texture + return portrait_texture var full_rect := Rect2i(Vector2i.ZERO, image.get_size()) var visible_rect: Rect2i = image.get_used_rect() if visible_rect.size == Vector2i.ZERO or visible_rect == full_rect: diff --git a/ui/logbook_page.gd b/ui/logbook_page.gd index a79239d..c5528e4 100644 --- a/ui/logbook_page.gd +++ b/ui/logbook_page.gd @@ -666,13 +666,18 @@ func _add_entry_content( content.add_theme_constant_override("separation", 4) content_margin.add_child(content) + var portrait_frame := CenterContainer.new() + portrait_frame.custom_minimum_size = CATALOG_PORTRAIT_SIZE + portrait_frame.mouse_filter = Control.MOUSE_FILTER_IGNORE + content.add_child(portrait_frame) + var portrait_view := LogbookPortraitType.new() - portrait_view.configure( + portrait_view.configure_uniform_footprint( portrait, CATALOG_PORTRAIT_SIZE, _silhouette_material if unknown else null, ) - content.add_child(portrait_view) + portrait_frame.add_child(portrait_view) var name_label := _label(_entry_label_text(entry_name), 16) name_label.custom_minimum_size.y = 38.0 From 4dc87235ec3094c634d5c2d402d7521218736401 Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 24 Aug 2026 13:50:57 -0400 Subject: [PATCH 17/44] Restore catch quality inventory styling --- tests/unified_inventory_validation.gd | 54 +++++++++++++++++++++ ui/components/general_inventory_slot.gd | 62 +++++++++++++++++++++---- ui/components/shop_sale_tray_slot.gd | 26 ++++++++++- ui/shop_sell_inventory.gd | 10 +++- 4 files changed, 141 insertions(+), 11 deletions(-) diff --git a/tests/unified_inventory_validation.gd b/tests/unified_inventory_validation.gd index dacc5a0..e9a0d85 100644 --- a/tests/unified_inventory_validation.gd +++ b/tests/unified_inventory_validation.gd @@ -62,6 +62,7 @@ func _run() -> void: fish_catch.sale_value = Bluegill.get_sale_value_for_weight( fish_catch.weight_lb ) + fish_catch.quality = FishQuality.Tier.IMPRESSIVE assert(catches.add_catch(fish_catch)) assert(layout.get_inventory_count() == 2) assert(layout.move_entry_to_first_free( @@ -106,8 +107,53 @@ func _run() -> void: == Color(UtilityPageStyle.OCEAN_SELECTED, 0.92) ) staged_slot.set_staged(false) + var quality_slot := GeneralInventorySlot.new() + root.add_child(quality_slot) + quality_slot.set_presentation_size(Vector2(78.0, 78.0)) + quality_slot.configure( + 0, + PlayerInventoryLayout.InventoryContainer.STORAGE, + false, + layout, + bag, + catches, + hotbar, + ItemCatalogResource, + ) + assert(quality_slot.entry_identity == fish_catch.catch_id) + assert( + (quality_slot.get_theme_stylebox("normal") as StyleBoxFlat).bg_color + == GeneralInventorySlot.quality_background_color( + FishQuality.Tier.IMPRESSIVE, + GeneralInventorySlot.QualityEmphasis.NORMAL, + ) + ) + quality_slot.set_staged(true) + assert( + (quality_slot.get_theme_stylebox("normal") as StyleBoxFlat).bg_color + == GeneralInventorySlot.quality_background_color( + FishQuality.Tier.IMPRESSIVE, + GeneralInventorySlot.QualityEmphasis.SELECTED, + ) + ) + var distinct_quality_colors: Dictionary[Color, bool] = {} + for quality: int in FishQuality.TIER_COUNT: + var quality_color := GeneralInventorySlot.quality_background_color( + quality, + GeneralInventorySlot.QualityEmphasis.NORMAL, + ) + assert(not distinct_quality_colors.has(quality_color)) + distinct_quality_colors[quality_color] = true + assert(distinct_quality_colors.size() == FishQuality.TIER_COUNT) var sale_tray_slot := ShopSaleTraySlot.new() root.add_child(sale_tray_slot) + sale_tray_slot.configure( + "catch:%s" % fish_catch.catch_id, + fish_catch.fish.display_texture, + fish_catch.fish.display_name, + 1, + fish_catch.quality, + ) assert( sale_tray_slot.custom_minimum_size == GeneralInventoryGrid.DEFAULT_SLOT_SIZE @@ -117,6 +163,14 @@ func _run() -> void: ) as StyleBoxFlat assert(sale_tray_style != null) assert(sale_tray_style.corner_radius_top_left == 26) + var expected_tray_quality_color := ( + GeneralInventorySlot.quality_background_color( + FishQuality.Tier.IMPRESSIVE, + GeneralInventorySlot.QualityEmphasis.NORMAL, + ) + ) + expected_tray_quality_color.a = 0.96 + assert(sale_tray_style.bg_color == expected_tray_quality_color) var wallet := PlayerWallet.new() root.add_child(wallet) assert(wallet.restore_balance(15000)) diff --git a/ui/components/general_inventory_slot.gd b/ui/components/general_inventory_slot.gd index 9204d0a..cf9ccc4 100644 --- a/ui/components/general_inventory_slot.gd +++ b/ui/components/general_inventory_slot.gd @@ -11,6 +11,12 @@ const LockedContentPresentationType = preload( "res://ui/components/locked_content_presentation.gd" ) +enum QualityEmphasis { + NORMAL, + HOVER, + SELECTED, +} + var slot_index: int = -1 var container: int = -1 var entry_kind: int = -1 @@ -29,6 +35,7 @@ var _context_text: String = "" var _context_hovered: bool = false var _context_focused: bool = false var _staged: bool = false +var _quality_tier: int = -1 func _ready() -> void: @@ -101,10 +108,12 @@ func refresh() -> void: entry_kind = -1 entry_identity = StringName() _context_text = "" + _quality_tier = -1 _icon.texture = null _quantity.text = "" disabled = _locked _apply_icon_geometry() + _apply_style() if _locked: _icon.texture = LockedContentPresentationType.ICON _icon.modulate = LockedContentPresentationType.icon_modulate() @@ -152,12 +161,14 @@ func refresh() -> void: ) if fish_catch == null: return + _quality_tier = fish_catch.quality _icon.texture = fish_catch.fish.display_texture _context_text = _catch_context_text(fish_catch) var catch_name: String = FishQuality.qualified_name( fish_catch.fish.display_name, fish_catch.quality ) accessibility_name = "%s, slot %d" % [catch_name, slot_index + 1] + _apply_style() func _on_pressed() -> void: @@ -306,27 +317,62 @@ func _drop_data(_at_position: Vector2, data: Variant) -> void: func _apply_style() -> void: var radius: int = roundi(minf(_presentation_size.x, _presentation_size.y) * 0.5) + var normal_color: Color = quality_background_color( + _quality_tier, + QualityEmphasis.SELECTED if _staged else QualityEmphasis.NORMAL, + ) var normal := UtilityPageStyle.rounded_style( - Color( - UtilityPageStyle.OCEAN_SELECTED - if _staged else UtilityPageStyle.OCEAN_FIELD, - 0.92 if _staged else 0.88, - ), + normal_color, radius, ) var hover := UtilityPageStyle.rounded_style( - Color(UtilityPageStyle.OCEAN_SELECTED, 0.92), radius + quality_background_color(_quality_tier, QualityEmphasis.HOVER), + radius, + ) + var selected := UtilityPageStyle.rounded_style( + quality_background_color(_quality_tier, QualityEmphasis.SELECTED), + radius, ) var locked := UtilityPageStyle.rounded_style( LockedContentPresentationType.disabled_background_color(), radius ) - for state: StringName in [&"normal", &"pressed"]: - add_theme_stylebox_override(state, normal) + add_theme_stylebox_override("normal", normal) + add_theme_stylebox_override( + "pressed", selected if FishQuality.is_valid(_quality_tier) else normal + ) add_theme_stylebox_override("disabled", locked) for state: StringName in [&"hover", &"focus"]: add_theme_stylebox_override(state, hover) +static func quality_background_color( + quality: int, + emphasis: QualityEmphasis, +) -> Color: + var base_color: Color + var alpha: float + var quality_mix: float + match emphasis: + QualityEmphasis.HOVER: + base_color = UtilityPageStyle.OCEAN_SELECTED + alpha = 0.92 + quality_mix = 0.64 + QualityEmphasis.SELECTED: + base_color = UtilityPageStyle.OCEAN_SELECTED + alpha = 0.92 + quality_mix = 0.82 + _: + base_color = UtilityPageStyle.OCEAN_FIELD + alpha = 0.88 + quality_mix = 0.46 + if not FishQuality.is_valid(quality): + return Color(base_color, alpha) + return Color( + base_color.lerp(UIPalette.get_quality_color(quality), quality_mix), + alpha, + ) + + func _apply_presentation() -> void: _apply_icon_geometry() var is_large: bool = _presentation_size.x >= 70.0 diff --git a/ui/components/shop_sale_tray_slot.gd b/ui/components/shop_sale_tray_slot.gd index 5deb62f..0ac87bf 100644 --- a/ui/components/shop_sale_tray_slot.gd +++ b/ui/components/shop_sale_tray_slot.gd @@ -7,6 +7,7 @@ signal drop_requested(payload: Dictionary) var entry_key: String = "" var _icon: TextureRect var _quantity: Label +var _quality_tier: int = -1 func _ready() -> void: @@ -32,11 +33,29 @@ func _ready() -> void: _quantity.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT _quantity.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_quantity) + _apply_style() + + +func _apply_style() -> void: + var normal_color := GeneralInventorySlot.quality_background_color( + _quality_tier, + GeneralInventorySlot.QualityEmphasis.NORMAL, + ) + var hover_color := GeneralInventorySlot.quality_background_color( + _quality_tier, + GeneralInventorySlot.QualityEmphasis.SELECTED, + ) + # Preserve the tray's established opacity while sharing the inventory's + # quality colors. Ordinary items and the empty drop target stay unchanged. + normal_color.a = 0.96 + hover_color.a = 0.96 var normal := UtilityPageStyle.rounded_style( - Color(UtilityPageStyle.OCEAN_FIELD, 0.96), 26 + normal_color, + 26, ) var hover := UtilityPageStyle.rounded_style( - Color(UtilityPageStyle.OCEAN_SELECTED, 0.96), 26 + hover_color, + 26, ) for state: StringName in [&"normal", &"pressed", &"disabled"]: add_theme_stylebox_override(state, normal) @@ -49,12 +68,15 @@ func configure( icon: Texture2D, label: String, quantity: int = 1, + quality: int = -1, ) -> void: entry_key = key + _quality_tier = quality _icon.texture = icon _quantity.text = "×%d" % quantity if quantity > 1 else "" tooltip_text = "%s · select to remove" % label accessibility_name = tooltip_text + _apply_style() func _can_drop_data(_at_position: Vector2, data: Variant) -> bool: diff --git a/ui/shop_sell_inventory.gd b/ui/shop_sell_inventory.gd index f4e9a43..9e39850 100644 --- a/ui/shop_sell_inventory.gd +++ b/ui/shop_sell_inventory.gd @@ -205,12 +205,14 @@ func _refresh_tray() -> void: var identity := StringName(str(record["identity"])) var icon: Texture2D var label: String + var quality: int = -1 if int(record["kind"]) == PlayerInventoryLayout.EntryKind.CATCH: var fish_catch := _fish_inventory.get_catch_by_id(identity) if fish_catch == null: continue icon = fish_catch.fish.display_texture label = fish_catch.fish.display_name + quality = fish_catch.quality else: var item := _item_catalog.get_item_by_id(identity) if item == null: @@ -219,7 +221,13 @@ func _refresh_tray() -> void: label = item.display_name var slot := ShopSaleTraySlot.new() _tray_grid.add_child(slot) - slot.configure(key, icon, label, int(record.get("quantity", 1))) + slot.configure( + key, + icon, + label, + int(record.get("quantity", 1)), + quality, + ) slot.remove_requested.connect(_on_remove_requested) slot.drop_requested.connect(_on_drop_payload) var drop_slot := ShopSaleTraySlot.new() From 966968e54d1ab21a494b7ad8a789b96b6557ebd9 Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 24 Aug 2026 13:51:11 -0400 Subject: [PATCH 18/44] Restrict generated clam spots to visible sand --- tests/digging_prototype_validation.gd | 56 +++++++++++++++++++++++++++ world/digging/diggable_area_3d.gd | 19 ++++++++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/tests/digging_prototype_validation.gd b/tests/digging_prototype_validation.gd index c19bf0b..21f8629 100644 --- a/tests/digging_prototype_validation.gd +++ b/tests/digging_prototype_validation.gd @@ -11,6 +11,15 @@ const Gatherables: GatherableCatalog = preload( "res://gathering/catalog/gatherable_catalog.tres" ) +class PrimaryTerrainProvider: + extends Node3D + + var primary_mesh: MeshInstance3D + + + func get_primary_terrain_meshes() -> Array[MeshInstance3D]: + return [primary_mesh] if primary_mesh != null else [] + func _initialize() -> void: call_deferred("_run") @@ -20,6 +29,7 @@ func _run() -> void: _validate_catalog_content() _validate_flat_shovel() await _validate_beach_authoring() + _validate_primary_terrain_provider() print("Digging prototype validation: PASS") quit() @@ -81,6 +91,52 @@ func _validate_beach_authoring() -> void: region.queue_free() +func _validate_primary_terrain_provider() -> void: + var fixture := Node3D.new() + fixture.name = "PrimaryTerrainFixture" + root.add_child(fixture) + var provider := PrimaryTerrainProvider.new() + provider.name = "Provider" + fixture.add_child(provider) + provider.primary_mesh = _flat_sand_triangle(0.0) + provider.add_child(provider.primary_mesh) + var buried_base := _flat_sand_triangle(10.0) + buried_base.name = "BuriedSandBase" + provider.add_child(buried_base) + var area := DiggableArea3D.new() + area.name = "DiggableArea" + area.area_id = &"provider_test" + area.terrain_source = NodePath("../Provider") + area.surface_materials = [&"sand"] + area.generation_bounds = Rect2(-20.0, -20.0, 40.0, 40.0) + fixture.add_child(area) + var triangles := area.get_surface_triangles() + assert(triangles.size() == 1) + var center := (triangles[0][0] + triangles[0][1] + triangles[0][2]) / 3.0 + assert(center.x < 2.0) + fixture.free() + + +func _flat_sand_triangle(x_offset: float) -> MeshInstance3D: + var arrays: Array = [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = PackedVector3Array([ + Vector3(x_offset, 0.0, 0.0), + Vector3(x_offset, 0.0, 1.0), + Vector3(x_offset + 1.0, 0.0, 0.0), + ]) + arrays[Mesh.ARRAY_INDEX] = PackedInt32Array([0, 1, 2]) + var mesh := ArrayMesh.new() + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + var material := StandardMaterial3D.new() + material.resource_name = "sand" + mesh.surface_set_material(0, material) + var mesh_instance := MeshInstance3D.new() + mesh_instance.name = "PrimarySand" + mesh_instance.mesh = mesh + return mesh_instance + + func _collect_meshes( root_node: Node, result: Array[MeshInstance3D], diff --git a/world/digging/diggable_area_3d.gd b/world/digging/diggable_area_3d.gd index 1c34070..47270b1 100644 --- a/world/digging/diggable_area_3d.gd +++ b/world/digging/diggable_area_3d.gd @@ -18,7 +18,7 @@ func get_surface_triangles() -> Array[PackedVector3Array]: var terrain_root: Node = get_node_or_null(terrain_source) if terrain_root == null: return triangles - for mesh_instance: MeshInstance3D in _collect_mesh_instances(terrain_root): + for mesh_instance: MeshInstance3D in _terrain_mesh_instances(terrain_root): var mesh: Mesh = mesh_instance.mesh if mesh == null: continue @@ -92,6 +92,23 @@ func _append_triangle( result.append(PackedVector3Array([a, b, c])) +func _terrain_mesh_instances(terrain_root: Node) -> Array[MeshInstance3D]: + # Generated terrain can contain authored base layers beneath raised visual + # overlays. Those meshes are useful for closing terrain seams, but they are + # not necessarily the visible surface and must not produce buried dig spots. + # A terrain provider may therefore expose its authoritative primary meshes. + if terrain_root.has_method(&"get_primary_terrain_meshes"): + var provided: Variant = terrain_root.call(&"get_primary_terrain_meshes") + var meshes: Array[MeshInstance3D] = [] + if provided is Array: + for value: Variant in provided: + var mesh_instance := value as MeshInstance3D + if mesh_instance != null: + meshes.append(mesh_instance) + return meshes + return _collect_mesh_instances(terrain_root) + + func _collect_mesh_instances(root: Node) -> Array[MeshInstance3D]: var meshes: Array[MeshInstance3D] = [] if root is MeshInstance3D: From a7c71213cc8cf5e5ec69ae053578c2be2a3e50e3 Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 24 Aug 2026 13:51:21 -0400 Subject: [PATCH 19/44] Improve beetle and net target visibility --- gathering/catalog/beetle_stag_common.tres | 2 +- gathering/gathering_controller.gd | 2 ++ tests/gathering_marker_surface_validation.gd | 2 ++ tests/generated_world_runtime_validation.gd | 4 ++++ tests/tree_gathering_prototype_validation.gd | 4 ++-- world/generation/terrain_prop_definition.gd | 5 ++++- 6 files changed, 15 insertions(+), 4 deletions(-) diff --git a/gathering/catalog/beetle_stag_common.tres b/gathering/catalog/beetle_stag_common.tres index 65b15df..c8b7d85 100644 --- a/gathering/catalog/beetle_stag_common.tres +++ b/gathering/catalog/beetle_stag_common.tres @@ -19,7 +19,7 @@ scare_radius = 0.1 capture_radius = 0.34 interaction_range = 2.8 charge_duration = 1.0 -sprite_pixel_size = 0.005 +sprite_pixel_size = 0.0075 sprite_tilt_degrees = 0.0 capture_respawn_min_seconds = 90.0 capture_respawn_max_seconds = 150.0 diff --git a/gathering/gathering_controller.gd b/gathering/gathering_controller.gd index 2859145..c061db5 100644 --- a/gathering/gathering_controller.gd +++ b/gathering/gathering_controller.gd @@ -341,10 +341,12 @@ func _build_marker() -> void: _marker_invalid_material.albedo_color = MARKER_INVALID_COLOR _marker_invalid_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED _marker_invalid_material.roughness = 1.0 + _marker_invalid_material.no_depth_test = true _marker_valid_material = StandardMaterial3D.new() _marker_valid_material.albedo_color = MARKER_VALID_COLOR _marker_valid_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED _marker_valid_material.roughness = 1.0 + _marker_valid_material.no_depth_test = true var disc := CylinderMesh.new() disc.top_radius = marker_radius disc.bottom_radius = marker_radius diff --git a/tests/gathering_marker_surface_validation.gd b/tests/gathering_marker_surface_validation.gd index 8ce3b4a..72f15f0 100644 --- a/tests/gathering_marker_surface_validation.gd +++ b/tests/gathering_marker_surface_validation.gd @@ -44,6 +44,8 @@ func _run() -> void: assert(invalid_material.albedo_color == Color.WHITE) assert(is_equal_approx(invalid_material.albedo_color.a, 1.0)) assert(is_equal_approx(valid_material.albedo_color.a, 1.0)) + assert(invalid_material.no_depth_test) + assert(valid_material.no_depth_test) assert( invalid_material.shading_mode == BaseMaterial3D.SHADING_MODE_UNSHADED diff --git a/tests/generated_world_runtime_validation.gd b/tests/generated_world_runtime_validation.gd index 6e03df7..6c4343f 100644 --- a/tests/generated_world_runtime_validation.gd +++ b/tests/generated_world_runtime_validation.gd @@ -943,6 +943,10 @@ func _validate_prop_catalog(region: GeneratedWorldRegion) -> void: assert(instance.position.is_zero_approx()) assert(_find_mesh_instance(instance) != null) instance.free() + if definition.has_gatherable_surface(): + assert( + definition.gatherable_surface_clearance >= 0.12 + ) var mushroom := catalog.definition_for_id(&"prop_mushroom") assert(mushroom != null and mushroom.has_collision()) assert(mushroom.visual_offset.y < 0.0) diff --git a/tests/tree_gathering_prototype_validation.gd b/tests/tree_gathering_prototype_validation.gd index bb6d1df..2814e19 100644 --- a/tests/tree_gathering_prototype_validation.gd +++ b/tests/tree_gathering_prototype_validation.gd @@ -41,7 +41,7 @@ func _validate_beetle_data() -> void: assert(beetle.target_population_for_anchor_count(8) == 3) assert(beetle.target_population_for_anchor_count(40) == 14) assert(beetle.target_population_for_anchor_count(400) == 64) - assert(is_equal_approx(beetle.sprite_pixel_size, 0.005)) + assert(is_equal_approx(beetle.sprite_pixel_size, 0.0075)) assert(beetle.is_stationary_spawn()) assert(not beetle.can_be_scared()) @@ -128,7 +128,7 @@ func _validate_anchored_presentation() -> void: var sprite := presentation.get_node("GatherableSprite") as Sprite3D assert(sprite != null) assert(is_zero_approx(sprite.position.y)) - assert(is_equal_approx(sprite.pixel_size, 0.005)) + assert(is_equal_approx(sprite.pixel_size, 0.0075)) assert(not sprite.shaded) assert(sprite.billboard == BaseMaterial3D.BILLBOARD_ENABLED) assert(sprite.texture_filter == BaseMaterial3D.TEXTURE_FILTER_NEAREST) diff --git a/world/generation/terrain_prop_definition.gd b/world/generation/terrain_prop_definition.gd index 58fdddd..d2524eb 100644 --- a/world/generation/terrain_prop_definition.gd +++ b/world/generation/terrain_prop_definition.gd @@ -58,7 +58,10 @@ extends Resource ## Reject upward-facing branches and foliage so attachments favor trunk-like ## faces. Zero accepts only vertical faces; one accepts every orientation. @export_range(0.0, 1.0, 0.05) var gatherable_surface_maximum_up_dot := 0.35 -@export_range(0.0, 0.25, 0.005) var gatherable_surface_clearance := 0.025 +## Keep camera-facing creature billboards clear of the sampled trunk as they +## rotate. This is a physical anchor offset, so creatures remain hidden by the +## rest of the tree instead of rendering through it as an overlay. +@export_range(0.0, 0.25, 0.005) var gatherable_surface_clearance := 0.12 func supports_chunk_tags(chunk_tags: PackedStringArray) -> bool: From 3608be41ab7faef94af34218f78475738974f32c Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 24 Aug 2026 14:34:58 -0400 Subject: [PATCH 20/44] Improve tree gathering and rain occlusion --- tests/generated_world_runtime_validation.gd | 26 +++++++++ tests/world_weather_validation.gd | 31 ++++++++++ world/environment/precipitation_occlusion.gd | 56 +++++++++++++++++++ world/generation/generated_world_region.gd | 15 +++++ .../props/definitions/prop_pine.tres | 1 - .../props/definitions/prop_pine_large.tres | 1 - world/regions/starter_island_region.gd | 6 ++ world/world_time_visual_controller.gd | 31 ++++++++++ 8 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 world/environment/precipitation_occlusion.gd diff --git a/tests/generated_world_runtime_validation.gd b/tests/generated_world_runtime_validation.gd index 6c4343f..b8ea847 100644 --- a/tests/generated_world_runtime_validation.gd +++ b/tests/generated_world_runtime_validation.gd @@ -12,6 +12,9 @@ const GeneratedLakePool: FishPool = preload( const GeneratedRiverPool: FishPool = preload( "res://fish/pools/generated_river_pool.tres" ) +const PrecipitationOcclusionType = preload( + "res://world/environment/precipitation_occlusion.gd" +) const FIRST_SEED := 13001 const SECOND_SEED := 13002 const RIVER_FALLBACK_SEED := 13012 @@ -502,6 +505,12 @@ func _validate_generated_region( assert(PROCEDURAL_PROP_IDS.has(prop_id)) var definition := region.get_prop_catalog().definition_for_id(prop_id) assert(definition != null) + if definition.procedural_group in [&"grass_tree", &"sand_tree"]: + assert( + _has_precipitation_occlusion_layer(child), + "Precipitation occlusion layer missing from %s (%s)." + % [child.name, prop_id], + ) var biome_id := StringName( child.get_meta(&"terrain_biome_id", &"") ) @@ -967,10 +976,12 @@ func _validate_prop_catalog(region: GeneratedWorldRegion) -> void: assert(pine != null) assert(pine.minimum_visual_scale < pine.maximum_visual_scale) assert(pine.material_variants.is_empty()) + assert(not pine.has_gatherable_surface()) var large_pine := catalog.definition_for_id(&"prop_pine_large") assert(large_pine != null) assert(large_pine.minimum_visual_scale < large_pine.maximum_visual_scale) assert(large_pine.material_variants.is_empty()) + assert(not large_pine.has_gatherable_surface()) var palm := catalog.definition_for_id(&"prop_palm") assert(palm != null) assert(is_equal_approx(palm.minimum_visual_scale, 0.5)) @@ -1180,6 +1191,21 @@ func _find_mesh_instance(root_node: Node) -> MeshInstance3D: return null +func _has_precipitation_occlusion_layer(root_node: Node) -> bool: + var mesh_instance := root_node as MeshInstance3D + if ( + mesh_instance != null + and mesh_instance.get_layer_mask_value( + PrecipitationOcclusionType.RENDER_LAYER_NUMBER + ) + ): + return true + for child: Node in root_node.get_children(): + if _has_precipitation_occlusion_layer(child): + return true + return false + + func _has_material_variant_override( root_node: Node, variants: Array[Material], diff --git a/tests/world_weather_validation.gd b/tests/world_weather_validation.gd index c346e93..9a53eff 100644 --- a/tests/world_weather_validation.gd +++ b/tests/world_weather_validation.gd @@ -10,6 +10,9 @@ const WorldTimeServiceType = preload("res://world/world_time_service.gd") const WorldTimeVisualControllerType = preload( "res://world/world_time_visual_controller.gd" ) +const PrecipitationOcclusionType = preload( + "res://world/environment/precipitation_occlusion.gd" +) const FishingContextType = preload("res://fishing/fishing_context.gd") const FishingSpotType = preload("res://fishing/fishing_spot.gd") const FishAvailabilityType = preload("res://fish/fish_availability.gd") @@ -526,6 +529,14 @@ func _validate_weather_presentation() -> void: )) var rain_material := rain.process_material as ParticleProcessMaterial assert(rain_material != null) + assert( + rain_material.collision_mode + == ParticleProcessMaterial.COLLISION_HIDE_ON_CONTACT + ) + assert(is_equal_approx( + rain.collision_base_size, + WorldTimeVisualControllerType.RAIN_COLLISION_BASE_SIZE, + )) assert(rain_material.emission_box_extents.is_equal_approx( WorldTimeVisualControllerType.RAIN_EMISSION_EXTENTS )) @@ -542,6 +553,26 @@ func _validate_weather_presentation() -> void: assert(rain_mesh.size.is_equal_approx( WorldTimeVisualControllerType.RAIN_DROP_SIZE )) + var rain_collision := visuals.get_node( + "LocalRainCanopyCollision" + ) as GPUParticlesCollisionHeightField3D + assert(rain_collision != null) + assert(rain_collision.follow_camera_enabled) + assert( + rain_collision.resolution + == GPUParticlesCollisionHeightField3D.RESOLUTION_256 + ) + assert( + rain_collision.update_mode + == GPUParticlesCollisionHeightField3D.UPDATE_MODE_WHEN_MOVED + ) + assert(rain_collision.size.is_equal_approx( + WorldTimeVisualControllerType.RAIN_COLLISION_SIZE + )) + assert( + rain_collision.heightfield_mask + == PrecipitationOcclusionType.RENDER_LAYER_MASK + ) visuals.call( "_on_weather_changed", WorldWeatherServiceType.Weather.SUNNY, diff --git a/world/environment/precipitation_occlusion.gd b/world/environment/precipitation_occlusion.gd new file mode 100644 index 0000000..8d27a68 --- /dev/null +++ b/world/environment/precipitation_occlusion.gd @@ -0,0 +1,56 @@ +class_name PrecipitationOcclusion +extends RefCounted + +## A secondary visual layer used only when building the local rain height field. +## Canopy meshes remain on layer 1 for ordinary cameras. +const RENDER_LAYER_NUMBER: int = 20 +const RENDER_LAYER_MASK: int = 1 << (RENDER_LAYER_NUMBER - 1) +const CANOPY_MATERIAL_NAMES: Array[String] = [ + "leaf", + "leaf_light", + "leaf_mid", + "leaf_dark", + "pine", + "tree", +] + + +static func mark_canopy_meshes(root_node: Node) -> int: + if root_node == null: + return 0 + var marked_count: int = 0 + var mesh_instance := root_node as MeshInstance3D + if mesh_instance != null and _has_canopy_material(mesh_instance): + mesh_instance.layers |= RENDER_LAYER_MASK + marked_count += 1 + for child: Node in root_node.get_children(): + marked_count += mark_canopy_meshes(child) + return marked_count + + +## Generated tree definitions are already explicit terrain metadata, so every +## mesh in their visual scene can safely participate. This also supports older +## combined tree meshes whose imported material name does not identify leaves. +static func mark_tree_meshes(root_node: Node) -> int: + if root_node == null: + return 0 + var marked_count: int = 0 + var mesh_instance := root_node as MeshInstance3D + if mesh_instance != null: + mesh_instance.layers |= RENDER_LAYER_MASK + marked_count += 1 + for child: Node in root_node.get_children(): + marked_count += mark_tree_meshes(child) + return marked_count + + +static func _has_canopy_material(mesh_instance: MeshInstance3D) -> bool: + if mesh_instance == null or mesh_instance.mesh == null: + return false + for surface_index: int in mesh_instance.mesh.get_surface_count(): + var material := mesh_instance.mesh.surface_get_material(surface_index) + if material == null: + continue + if material.resource_name.to_lower() in CANOPY_MATERIAL_NAMES: + return true + return false diff --git a/world/generation/generated_world_region.gd b/world/generation/generated_world_region.gd index 06406de..c816c3b 100644 --- a/world/generation/generated_world_region.gd +++ b/world/generation/generated_world_region.gd @@ -12,6 +12,9 @@ const PlayerStorageInteractionType = preload( const MeshSurfaceAnchorSamplerType = preload( "res://world/generation/mesh_surface_anchor_sampler.gd" ) +const PrecipitationOcclusionType = preload( + "res://world/environment/precipitation_occlusion.gd" +) const WATER_BODY_SCENE: PackedScene = preload("res://world/water_body.tscn") const SALT_WATER_MATERIAL: Material = preload( "res://world/materials/stylized_water.tres" @@ -292,9 +295,21 @@ func _on_generation_completed(summary: Dictionary) -> void: _place_spawn_amenities(spawn_position) _configure_fresh_water(records) _configure_diggable_area() + # Imported prop instances may finish applying their scene state while the + # generated world is assembled. Mark the final decoration tree once every + # prop and material variant is in place so rain canopy occlusion persists. + _mark_precipitation_occluders() world_generated.emit(_current_seed, summary) +func _mark_precipitation_occluders() -> void: + for prop: Node in _decorations.get_children(): + var prop_group := prop.get_meta(&"terrain_prop_group", &"") as StringName + if prop_group not in [&"grass_tree", &"sand_tree"]: + continue + PrecipitationOcclusionType.mark_tree_meshes(prop) + + func _assign_biomes( records: Array[Dictionary], summary: Dictionary, diff --git a/world/generation/props/definitions/prop_pine.tres b/world/generation/props/definitions/prop_pine.tres index 062dd3b..b3224a9 100644 --- a/world/generation/props/definitions/prop_pine.tres +++ b/world/generation/props/definitions/prop_pine.tres @@ -17,4 +17,3 @@ minimum_visual_scale = 0.65 maximum_visual_scale = 1.2 collision_radius = 0.5 collision_height = 4.0 -gatherable_surface_material_names = PackedStringArray("wood") diff --git a/world/generation/props/definitions/prop_pine_large.tres b/world/generation/props/definitions/prop_pine_large.tres index 5f5d584..df9076d 100644 --- a/world/generation/props/definitions/prop_pine_large.tres +++ b/world/generation/props/definitions/prop_pine_large.tres @@ -17,4 +17,3 @@ minimum_visual_scale = 0.75 maximum_visual_scale = 1.2 collision_radius = 0.65 collision_height = 9.5 -gatherable_surface_material_names = PackedStringArray("wood") diff --git a/world/regions/starter_island_region.gd b/world/regions/starter_island_region.gd index b787ff1..3af3010 100644 --- a/world/regions/starter_island_region.gd +++ b/world/regions/starter_island_region.gd @@ -11,6 +11,9 @@ const PlayerStorageInteractionType = preload( const FOLIAGE_WIND_SHADER: Shader = preload( "res://world/materials/foliage_wind.gdshader" ) +const PrecipitationOcclusionType = preload( + "res://world/environment/precipitation_occlusion.gd" +) const FOLIAGE_MATERIAL_NAMES: Array[StringName] = [ &"leaf", &"leaf_light", @@ -60,6 +63,9 @@ var _foliage_wind_enabled: bool = false func _ready() -> void: if rebuild_terrain_collision_on_ready or not has_terrain_collision(): _build_terrain_collision() + PrecipitationOcclusionType.mark_canopy_meshes( + get_node_or_null(terrain_visual_root_path) + ) _set_foliage_wind_enabled(true) if Engine.is_editor_hint(): update_configuration_warnings() diff --git a/world/world_time_visual_controller.gd b/world/world_time_visual_controller.gd index fd743ed..5328c54 100644 --- a/world/world_time_visual_controller.gd +++ b/world/world_time_visual_controller.gd @@ -17,6 +17,8 @@ const LIGHT_RAIN_FIXED_FPS: int = 8 const RAIN_VELOCITY_MIN: float = 16.0 const RAIN_VELOCITY_MAX: float = 20.0 const RAIN_DROP_SIZE := Vector3(0.014, 0.34, 0.014) +const RAIN_COLLISION_BASE_SIZE: float = 0.08 +const RAIN_COLLISION_SIZE := Vector3(28.0, 24.0, 28.0) const FOG_DAYLIGHT_SCENE_BRIGHTNESS: float = 0.42 const FOG_DAYLIGHT_FOG_LIGHT_BRIGHTNESS: float = 0.42 const FOG_DAYLIGHT_WATER_BRIGHTNESS: float = 0.42 @@ -37,6 +39,9 @@ const FRESH_WATER_MATERIAL: ShaderMaterial = preload( const LocalStormCloudLayerType = preload( "res://world/environment/local_storm_cloud_layer.gd" ) +const PrecipitationOcclusionType = preload( + "res://world/environment/precipitation_occlusion.gd" +) const DAY_SKY_TOP := Color(0.204, 0.498, 0.643) const DAY_SKY_HORIZON := Color(0.663, 0.843, 0.847) @@ -88,6 +93,7 @@ var _rain_camera_provider: Callable var _environment: Environment var _sky_material: ShaderMaterial var _rain: GPUParticles3D +var _rain_collision: GPUParticlesCollisionHeightField3D var _storm_clouds: LocalStormCloudLayer var _elapsed: float = 0.0 var _weather_from: WorldWeatherService.Weather = ( @@ -211,6 +217,7 @@ func _prepare_rain() -> void: LIGHT_RAIN_FIXED_FPS if _light_performance_profile else 30 ) _rain.local_coords = false + _rain.collision_base_size = RAIN_COLLISION_BASE_SIZE _rain.visibility_aabb = RAIN_VISIBILITY_AABB var process_material := ParticleProcessMaterial.new() process_material.emission_shape = ( @@ -222,6 +229,9 @@ func _prepare_rain() -> void: process_material.initial_velocity_min = RAIN_VELOCITY_MIN process_material.initial_velocity_max = RAIN_VELOCITY_MAX process_material.gravity = Vector3(0.0, -2.0, 0.0) + process_material.collision_mode = ( + ParticleProcessMaterial.COLLISION_HIDE_ON_CONTACT + ) _rain.process_material = process_material var drop_mesh := BoxMesh.new() drop_mesh.size = RAIN_DROP_SIZE @@ -232,9 +242,30 @@ func _prepare_rain() -> void: drop_mesh.material = drop_material _rain.draw_pass_1 = drop_mesh add_child(_rain) + _prepare_rain_collision() _update_rain_position() +func _prepare_rain_collision() -> void: + _rain_collision = GPUParticlesCollisionHeightField3D.new() + _rain_collision.name = "LocalRainCanopyCollision" + _rain_collision.size = RAIN_COLLISION_SIZE + _rain_collision.resolution = ( + GPUParticlesCollisionHeightField3D.RESOLUTION_256 + ) + _rain_collision.update_mode = ( + GPUParticlesCollisionHeightField3D.UPDATE_MODE_WHEN_MOVED + ) + _rain_collision.follow_camera_enabled = true + _rain_collision.heightfield_mask = ( + PrecipitationOcclusionType.RENDER_LAYER_MASK + ) + # Local rain remains on its normal layer; unrelated particle systems do not + # need to query this weather-specific collision field. + _rain_collision.cull_mask = 1 + add_child(_rain_collision) + + func _prepare_storm_clouds() -> void: _storm_clouds = LocalStormCloudLayerType.new() _storm_clouds.name = "LocalStormClouds" From 05838406d0e627bf54da175b069184dd937ddf8c Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 24 Aug 2026 16:31:55 -0400 Subject: [PATCH 21/44] Refresh generated terrain assets and projection --- tests/generated_world_runtime_validation.gd | 85 +++++++++++++++--- tools/blender/export_terrain_chunks.py | 68 ++++++++++++-- world/generation/chunks/assets/chunk_0000.glb | Bin 7700 -> 7700 bytes world/generation/chunks/assets/chunk_0001.glb | Bin 7660 -> 7660 bytes world/generation/chunks/assets/chunk_0002.glb | Bin 14472 -> 14472 bytes world/generation/chunks/assets/chunk_0003.glb | Bin 24372 -> 24348 bytes world/generation/chunks/assets/chunk_0004.glb | Bin 27748 -> 25744 bytes world/generation/chunks/assets/chunk_0005.glb | Bin 17972 -> 20480 bytes world/generation/chunks/assets/chunk_0006.glb | Bin 33760 -> 33952 bytes world/generation/chunks/assets/chunk_0007.glb | Bin 19004 -> 19004 bytes world/generation/chunks/assets/chunk_0008.glb | Bin 17996 -> 17996 bytes world/generation/chunks/assets/chunk_0009.glb | Bin 7700 -> 7700 bytes world/generation/chunks/assets/chunk_0010.glb | Bin 19908 -> 20036 bytes world/generation/chunks/assets/chunk_0011.glb | Bin 19428 -> 19420 bytes world/generation/chunks/assets/chunk_0012.glb | Bin 30804 -> 30816 bytes world/generation/chunks/assets/chunk_0013.glb | Bin 27908 -> 27908 bytes world/generation/chunks/assets/chunk_0014.glb | Bin 19420 -> 19420 bytes world/generation/chunks/assets/chunk_0015.glb | Bin 19908 -> 20040 bytes world/generation/chunks/assets/chunk_0016.glb | Bin 26224 -> 26200 bytes world/generation/chunks/assets/chunk_0017.glb | Bin 27692 -> 27632 bytes world/generation/chunks/assets/chunk_0018.glb | Bin 26616 -> 26528 bytes world/generation/chunks/assets/chunk_0019.glb | Bin 6996 -> 6972 bytes world/generation/chunks/assets/chunk_0020.glb | Bin 25916 -> 25852 bytes world/generation/chunks/assets/chunk_0021.glb | Bin 25860 -> 26724 bytes world/generation/chunks/assets/chunk_0022.glb | Bin 25988 -> 26292 bytes world/generation/chunks/assets/chunk_0023.glb | Bin 25988 -> 26292 bytes world/generation/chunks/assets/chunk_0024.glb | Bin 28540 -> 28544 bytes world/generation/chunks/assets/chunk_0025.glb | Bin 30292 -> 30760 bytes world/generation/chunks/assets/chunk_0026.glb | Bin 19988 -> 19980 bytes world/generation/chunks/assets/chunk_0027.glb | Bin 21096 -> 21160 bytes world/generation/chunks/assets/chunk_0028.glb | Bin 7700 -> 7700 bytes world/generation/chunks/assets/chunk_0029.glb | Bin 36656 -> 36644 bytes world/generation/chunks/assets/chunk_0030.glb | Bin 36656 -> 36648 bytes world/generation/chunks/assets/chunk_0031.glb | Bin 37872 -> 38052 bytes world/generation/chunks/assets/chunk_0032.glb | Bin 37868 -> 38052 bytes world/generation/chunks/assets/chunk_0033.glb | Bin 25700 -> 25700 bytes world/generation/chunks/assets/chunk_0034.glb | Bin 30684 -> 30680 bytes world/generation/chunks/assets/chunk_0035.glb | Bin 30304 -> 30292 bytes world/generation/chunks/assets/chunk_0036.glb | Bin 29524 -> 29524 bytes world/generation/chunks/assets/chunk_0037.glb | Bin 27692 -> 27592 bytes world/generation/generated_world_region.gd | 64 ++++++------- world/generation/props/assets/prop_palm.glb | Bin 26408 -> 26244 bytes world/generation/props/assets/prop_pine.glb | Bin 9408 -> 10168 bytes .../props/assets/prop_pine_large.glb | Bin 9624 -> 9500 bytes world/generation/props/assets/prop_tree_1.glb | Bin 8416 -> 8416 bytes world/generation/props/assets/prop_tree_2.glb | Bin 6324 -> 6328 bytes world/generation/props/assets/prop_tree_3.glb | Bin 7316 -> 7284 bytes .../props/assets/prop_tree_large.glb | Bin 4304 -> 13200 bytes .../generation/terrain_chunk_edge_profile.gd | 38 ++++++++ world/generation/terrain_chunk_generator.gd | 67 +++++++++++++- world/materials/generated_terrain_dirt.tres | 16 ++++ world/materials/generated_terrain_grass.tres | 16 ++++ world/materials/generated_terrain_sand.tres | 16 ++++ 53 files changed, 319 insertions(+), 51 deletions(-) create mode 100644 world/materials/generated_terrain_dirt.tres create mode 100644 world/materials/generated_terrain_grass.tres create mode 100644 world/materials/generated_terrain_sand.tres diff --git a/tests/generated_world_runtime_validation.gd b/tests/generated_world_runtime_validation.gd index b8ea847..ef933bf 100644 --- a/tests/generated_world_runtime_validation.gd +++ b/tests/generated_world_runtime_validation.gd @@ -481,6 +481,9 @@ func _validate_generated_region( assert(river_body_count == river_placement_count) _validate_authored_chunk_surfaces(region, generator) + _validate_projected_terrain_materials( + generator.get_generated_chunks_root() + ) var decorations := region.get_node("Decorations") as Node3D var anchors := region.get_node( @@ -1147,21 +1150,28 @@ func _validate_tree_gatherable_anchors( anchors: GatherableAnchorSet3D, ) -> void: var eligible_props: Dictionary[StringName, Node3D] = {} + var expected_anchor_count := 0 for child: Node in decorations.get_children(): var prop := child as Node3D if prop == null: continue var prop_id := StringName(prop.get_meta(&"terrain_prop_id", &"")) var definition := region.get_prop_catalog().definition_for_id(prop_id) - if definition != null and definition.has_gatherable_surface(): + var socket_count := _count_authored_beetle_sockets(prop) + if ( + definition != null + and definition.has_gatherable_surface() + and socket_count > 0 + ): eligible_props[prop.name] = prop + expected_anchor_count += socket_count var positions := anchors.get_spawn_positions() - assert(positions.size() == eligible_props.size()) + assert(positions.size() == expected_anchor_count) assert(positions.size() >= 12) for child: Node in anchors.get_children(): var anchor := child as Marker3D assert(anchor != null) - assert(bool(anchor.get_meta(&"mesh_surface_sampled", false))) + assert(bool(anchor.get_meta(&"authored_beetle_socket", false))) var prop_name := StringName(anchor.get_meta(&"terrain_prop_name", &"")) assert(eligible_props.has(prop_name)) var prop: Node3D = eligible_props[prop_name] @@ -1169,14 +1179,17 @@ func _validate_tree_gatherable_anchors( var definition := region.get_prop_catalog().definition_for_id(prop_id) assert(definition != null and definition.has_gatherable_surface()) var local_anchor := prop.to_local(anchor.global_position) - assert( - local_anchor.y - >= definition.gatherable_surface_minimum_height - 0.001 - ) - assert( - local_anchor.y - <= definition.gatherable_surface_maximum_height + 0.001 - ) + assert(prop_id != &"prop_palm") + assert(local_anchor.y >= 0.25 and local_anchor.y <= 2.0) + + +func _count_authored_beetle_sockets(root_node: Node) -> int: + var result := 0 + if String(root_node.name).to_lower().contains("beetle_socket"): + result += 1 + for child: Node in root_node.get_children(): + result += _count_authored_beetle_sockets(child) + return result func _find_mesh_instance(root_node: Node) -> MeshInstance3D: @@ -1357,6 +1370,56 @@ func _material_names(mesh_instance: MeshInstance3D) -> PackedStringArray: return result +func _validate_projected_terrain_materials(root: Node) -> void: + var expected_sizes := { + "grass_lite": 1.75, + "sand": 2.6, + "dirt": 2.5, + } + var surface_counts := { + "grass_lite": 0, + "sand": 0, + "dirt": 0, + } + _validate_projected_material_node(root, expected_sizes, surface_counts) + for material_name: String in expected_sizes: + assert(surface_counts[material_name] > 0) + + +func _validate_projected_material_node( + root: Node, + expected_sizes: Dictionary, + surface_counts: Dictionary, +) -> void: + var mesh_instance := root as MeshInstance3D + if mesh_instance != null and mesh_instance.mesh != null: + for surface_index: int in mesh_instance.mesh.get_surface_count(): + var material := mesh_instance.get_active_material(surface_index) + if material == null or not expected_sizes.has(material.resource_name): + continue + var shader_material := material as ShaderMaterial + assert(shader_material != null and shader_material.shader != null) + assert( + shader_material.shader.resource_path + == "res://world/materials/terrain_surface_projection.gdshader" + ) + assert( + is_equal_approx( + float(shader_material.get_shader_parameter( + &"tile_world_size" + )), + float(expected_sizes[material.resource_name]), + ) + ) + surface_counts[material.resource_name] += 1 + for child: Node in root.get_children(): + _validate_projected_material_node( + child, + expected_sizes, + surface_counts, + ) + + func _validate_pond_collision( region: GeneratedWorldRegion, pond: MeshInstance3D, diff --git a/tools/blender/export_terrain_chunks.py b/tools/blender/export_terrain_chunks.py index 2c35982..ced081e 100644 --- a/tools/blender/export_terrain_chunks.py +++ b/tools/blender/export_terrain_chunks.py @@ -10,10 +10,11 @@ Run through Blender rather than a standalone Python interpreter: Collections use ``chunk_####_description`` and contain one primary terrain mesh named ``chunk_####``. Additional production objects may live in the same collection. Reusable procedural props use individual ``prop_description`` -mesh objects. They may be arranged anywhere in the source file because each -object's authored origin becomes the exported runtime anchor. A same-named -``prop_description`` collection remains supported for multi-object props. -Unrelated objects and collections are ignored. +mesh objects. Their child empties are exported with them so authored sockets +remain attached to the prop. Props may be arranged anywhere in the source file +because each root object's authored origin becomes the exported runtime anchor. +A same-named ``prop_description`` collection remains supported for multi-object +props. Unrelated objects and collections are ignored. """ from __future__ import annotations @@ -43,6 +44,12 @@ PROP_COLLECTION_PATTERN = re.compile( r"^prop_(?P