From 3d34ed0bee03ac56ad1f1128c397010e7e2aa9c2 Mon Sep 17 00:00:00 2001 From: Voyager Date: Thu, 20 Aug 2026 21:43:04 -0400 Subject: [PATCH] Add selectable startup world layouts --- main/main.gd | 117 +++++-- network/network_protocol.gd | 7 + network/network_sale_service.gd | 4 + network/network_session.gd | 49 +++ network/network_shop_service.gd | 4 + save/player_save_manager.gd | 29 +- scripts/run_validations.sh | 2 + ...nventory_storage_persistence_validation.gd | 7 +- tests/world_layout_multiplayer_validation.gd | 126 +++++++ ...world_layout_multiplayer_validation.gd.uid | 1 + tests/world_layout_validation.gd | 125 +++++++ tests/world_layout_validation.gd.uid | 1 + ui/fishing_shop.gd | 4 + ui/game_ui.gd | 10 + ui/new_game_setup_page.gd | 309 ++++++++++++++++++ ui/new_game_setup_page.gd.uid | 1 + ui/new_game_setup_page.tscn | 170 ++++++++++ ui/player_storage.gd | 4 + ui/settings_panel.gd | 6 +- ui/title_screen.gd | 86 +++-- ui/title_screen.tscn | 13 +- world/test_world.gd | 112 +++++-- world/world_layout.gd | 29 ++ world/world_layout.gd.uid | 1 + 24 files changed, 1139 insertions(+), 78 deletions(-) create mode 100644 tests/world_layout_multiplayer_validation.gd create mode 100644 tests/world_layout_multiplayer_validation.gd.uid create mode 100644 tests/world_layout_validation.gd create mode 100644 tests/world_layout_validation.gd.uid create mode 100644 ui/new_game_setup_page.gd create mode 100644 ui/new_game_setup_page.gd.uid create mode 100644 ui/new_game_setup_page.tscn create mode 100644 world/world_layout.gd create mode 100644 world/world_layout.gd.uid diff --git a/main/main.gd b/main/main.gd index d57c724..fa8fc0b 100644 --- a/main/main.gd +++ b/main/main.gd @@ -6,6 +6,7 @@ const FishPoolType = preload("res://fish/fish_pool.gd") const GameUIType = preload("res://ui/game_ui.gd") const PlayerType = preload("res://player/player.gd") const TestWorldType = preload("res://world/test_world.gd") +const WorldLayoutType = preload("res://world/world_layout.gd") const WaterRecoveryControllerType = preload( "res://world/water_recovery_controller.gd" ) @@ -421,10 +422,17 @@ func _start_dedicated_server() -> void: "The dedicated-server chat privacy configuration is invalid." ) return - if not _apply_generated_world(config.world_seed, false): + if not _apply_world( + WorldLayoutType.GENERATED, + config.world_seed, + false, + ): _fail_dedicated_server("The configured world seed could not be generated.") return - if not _network_session.set_host_world_seed(config.world_seed): + if not _network_session.set_host_world( + WorldLayoutType.GENERATED, + config.world_seed, + ): _fail_dedicated_server("The configured world seed is invalid.") return _configure_portable_stores() @@ -1693,20 +1701,23 @@ func _resize_native_overlays() -> void: _shop_backdrop.size = Vector2(get_window().size) -func _on_new_game_requested(world_seed: int) -> void: +func _on_new_game_requested( + world_layout: StringName, + world_seed: int, +) -> void: if _gameplay_started or _quit_in_progress: return _start_new_game_music() - if not _apply_generated_world(world_seed, true): + if not _apply_world(world_layout, world_seed, true): _show_title_music(true) _game_ui.get_title_screen().report_network_error( - "Could not generate that world seed. Try rolling another world." + "Could not prepare that world. Try another seed or the starter island." ) return - if not _prepare_host_world_seed(world_seed): + if not _prepare_host_world(world_layout, world_seed): _show_title_music(true) _game_ui.get_title_screen().report_network_error( - "Could not prepare the generated world for hosting." + "Could not prepare that world for hosting." ) return if not _prepare_private_host(): @@ -1714,7 +1725,7 @@ func _on_new_game_requested(world_seed: int) -> void: return if ( not _save_manager.delete_progression_save() - or not _save_manager.initialize_new_game(world_seed) + or not _save_manager.initialize_new_game(world_seed, world_layout) ): _network_session.disconnect_session("New Game setup failed.") _show_title_music(true) @@ -1733,13 +1744,14 @@ func _on_continue_game_requested() -> void: "Failed to load save. 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_generated_world(world_seed, true) - or not _prepare_host_world_seed(world_seed) + 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 generated. The save was preserved." + "The saved world could not be loaded. The save was preserved." ) return if not _prepare_private_host(): @@ -1766,13 +1778,16 @@ func _prepare_private_host() -> bool: return true -func _prepare_host_world_seed(world_seed: int) -> bool: +func _prepare_host_world( + world_layout: StringName, + world_seed: int, +) -> bool: if _network_session.state in [ NetworkSessionType.State.CONNECTION_FAILED, NetworkSessionType.State.SERVER_LOST, ]: _network_session.reset_failure() - return _network_session.set_host_world_seed(world_seed) + return _network_session.set_host_world(world_layout, world_seed) func _enter_gameplay() -> void: @@ -1976,11 +1991,14 @@ func _on_reset_progress_requested() -> void: pause_menu.report_reset_failure() return var world_seed: int = PlayerSaveManager.roll_world_seed() - if not _save_manager.initialize_new_game(world_seed): + if not _save_manager.initialize_new_game( + world_seed, + WorldLayoutType.GENERATED, + ): pause_menu.report_reset_failure() return _network_session.disconnect_session("Progress reset.") - if not _apply_generated_world(world_seed, true): + if not _apply_world(WorldLayoutType.GENERATED, world_seed, true): pause_menu.report_reset_failure() return pause_menu.close_for_title_transition() @@ -1990,8 +2008,18 @@ func _on_reset_progress_requested() -> void: func _apply_joined_world(server_metadata: Dictionary) -> bool: + var world_layout: StringName = WorldLayoutType.normalized( + server_metadata.get( + "world_layout", + String(WorldLayoutType.GENERATED), + ), + &"", + ) var world_seed: int = int(server_metadata.get("world_seed", 0)) - if _apply_generated_world(world_seed, true): + if ( + not world_layout.is_empty() + and _apply_world(world_layout, world_seed, true) + ): return true _network_session.disconnect_session("World generation failed.") _join_requested_from_title = false @@ -2000,20 +2028,26 @@ func _apply_joined_world(server_metadata: Dictionary) -> bool: var title_screen: TitleScreenType = _game_ui.get_title_screen() title_screen.reopen_to_menu() title_screen.report_network_error( - "The host's generated world could not be built locally." + "The host's world could not be built locally." ) _show_title_music(true) return false -func _apply_generated_world(world_seed: int, reposition_player: bool) -> bool: - if not _test_world.generate_world(world_seed): +func _apply_world( + world_layout: StringName, + world_seed: int, + reposition_player: bool, +) -> bool: + if not _test_world.activate_world(world_layout, world_seed): return false var spawn_transform: Transform3D = _test_world.get_player_spawn_transform() _player_spawn_service.set_spawn_transform(spawn_transform) if reposition_player: _player.global_transform = spawn_transform _player.velocity = Vector3.ZERO + if _application_initialized: + _refresh_active_world_bindings() if _application_initialized and not _dedicated_runtime: _water_recovery.update_world_context( spawn_transform, @@ -2027,6 +2061,51 @@ func _apply_generated_world(world_seed: int, reposition_player: bool) -> bool: return true +func _refresh_active_world_bindings() -> void: + if is_instance_valid(_shop_interaction): + if _shop_interaction.local_player_range_changed.is_connected( + _on_shop_range_changed + ): + _shop_interaction.local_player_range_changed.disconnect( + _on_shop_range_changed + ) + if is_instance_valid(_storage_interaction): + if _storage_interaction.local_player_range_changed.is_connected( + _on_storage_range_changed + ): + _storage_interaction.local_player_range_changed.disconnect( + _on_storage_range_changed + ) + _shop_interaction = _test_world.get_fishing_shop() + _storage_interaction = _test_world.get_player_storage() + _network_sale.set_shop_interaction(_shop_interaction) + _network_shop.set_shop_interaction(_shop_interaction) + if _dedicated_runtime: + return + if _shop_interaction != null: + _shop_interaction.setup_local_player(_player) + _shop_interaction.local_player_range_changed.connect( + _on_shop_range_changed + ) + if _storage_interaction != null: + _storage_interaction.setup_local_player(_player) + _storage_interaction.local_player_range_changed.connect( + _on_storage_range_changed + ) + _game_ui.set_world_interactions( + _shop_interaction, + _storage_interaction, + ) + _on_shop_range_changed( + _shop_interaction != null + and _shop_interaction.is_local_player_in_range() + ) + _on_storage_range_changed( + _storage_interaction != null + and _storage_interaction.is_local_player_in_range() + ) + + func _on_water_recovery_starting() -> void: _local_recovery_attempt_id = ( "recovery:%s" diff --git a/network/network_protocol.gd b/network/network_protocol.gd index 141756a..9a30a27 100644 --- a/network/network_protocol.gd +++ b/network/network_protocol.gd @@ -1,6 +1,8 @@ class_name NetworkProtocol extends RefCounted +const WorldLayoutType = preload("res://world/world_layout.gd") + const PROTOCOL_VERSION: int = 9 const GAME_BUILD: String = "prealpha" const MAX_GAME_VERSION_LENGTH: int = 64 @@ -29,6 +31,7 @@ const JOBS_CAPABILITY: String = "jobs_v1" 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 DEFAULT_WORLD_SEED: int = 13001 const MAX_WORLD_SEED: int = 2147483646 @@ -182,6 +185,7 @@ static func make_client_hello( APPEARANCE_PREVIEW_CAPABILITY, BACKPACK_SHOP_CAPABILITY, WORLD_GENERATION_CAPABILITY, + WORLD_LAYOUT_CAPABILITY, ]), "cosmetic_snapshot": cosmetic_snapshot, "identity_fingerprint": identity_fingerprint, @@ -286,6 +290,7 @@ static func make_server_hello( max_players: int, server_display_name: String = "NETfishing", world_seed: int = DEFAULT_WORLD_SEED, + world_layout: StringName = WorldLayoutType.GENERATED, ) -> Dictionary: return { "accepted": accepted, @@ -298,6 +303,7 @@ static func make_server_hello( "player_count": player_count, "max_players": max_players, "world_seed": world_seed, + "world_layout": String(world_layout), "capability_flags": PackedStringArray([ "movement_v1", "fishing_v1", @@ -316,6 +322,7 @@ static func make_server_hello( WORLD_SPAWN_CAPABILITY, APPEARANCE_PREVIEW_CAPABILITY, WORLD_GENERATION_CAPABILITY, + WORLD_LAYOUT_CAPABILITY, "chat_v1", "mail_v1", "profile_v1", diff --git a/network/network_sale_service.gd b/network/network_sale_service.gd index 9207266..38af917 100644 --- a/network/network_sale_service.gd +++ b/network/network_sale_service.gd @@ -91,6 +91,10 @@ func setup( _session.state_changed.connect(_on_session_state_changed) +func set_shop_interaction(interaction: FishingShopInteraction) -> void: + _shop_interaction = interaction + + func is_local_sale_pending() -> bool: return not _pending_local_request_id.is_empty() diff --git a/network/network_session.gd b/network/network_session.gd index 39796f5..21d400c 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -86,6 +86,7 @@ var _last_server_player_count: int = 0 var _last_server_display_name: String = "" var _last_server_protocol_version: int = 0 var _last_server_world_seed: int = NetworkProtocol.DEFAULT_WORLD_SEED +var _last_server_world_layout: StringName = WorldLayout.GENERATED var _server_capabilities: PackedStringArray = PackedStringArray() var _profile_ready: bool = false var _player_identity: PlayerIdentityStore @@ -113,6 +114,7 @@ var _session_operator_fingerprints: Dictionary[String, bool] = {} var _operator_peer_ids: Dictionary[int, bool] = {} var _local_operator: bool = false var _host_world_seed: int = NetworkProtocol.DEFAULT_WORLD_SEED +var _host_world_layout: StringName = WorldLayout.GENERATED func _ready() -> void: @@ -275,6 +277,7 @@ func _register_player_host() -> void: NetworkProtocol.WORLD_SPAWN_CAPABILITY, NetworkProtocol.BACKPACK_SHOP_CAPABILITY, NetworkProtocol.WORLD_GENERATION_CAPABILITY, + NetworkProtocol.WORLD_LAYOUT_CAPABILITY, ]), ) _registry.update_appearance(1, _local_appearance_snapshot) @@ -531,20 +534,35 @@ func get_last_server_metadata() -> Dictionary: "player_count": _last_server_player_count, "max_players": _last_server_max_players, "world_seed": _last_server_world_seed, + "world_layout": String(_last_server_world_layout), } func set_host_world_seed(seed: int) -> bool: + return set_host_world(WorldLayout.GENERATED, seed) + + +func set_host_world(world_layout: StringName, seed: int) -> bool: if ( state != State.INACTIVE + or not WorldLayout.is_valid(world_layout) or seed <= 0 or seed > NetworkProtocol.MAX_WORLD_SEED ): return false + _host_world_layout = world_layout _host_world_seed = seed return true +func get_authoritative_world_layout() -> StringName: + return ( + _last_server_world_layout + if is_joined_client() + else _host_world_layout + ) + + func get_authoritative_world_seed() -> int: return _last_server_world_seed if is_joined_client() else _host_world_seed @@ -583,6 +601,7 @@ func supports_server_capability(capability: StringName) -> bool: NetworkProtocol.WORLD_SPAWN_CAPABILITY, NetworkProtocol.APPEARANCE_PREVIEW_CAPABILITY, NetworkProtocol.WORLD_GENERATION_CAPABILITY, + NetworkProtocol.WORLD_LAYOUT_CAPABILITY, "chat_v1", "mail_v1", "profile_v1", @@ -1190,6 +1209,15 @@ func submit_client_hello(data: Dictionary) -> void: NetworkProtocol.RejectionCode.UNSUPPORTED_CLIENT, ) return + if ( + _host_world_layout == WorldLayout.STARTER_ISLAND + and NetworkProtocol.WORLD_LAYOUT_CAPABILITY not in client_capabilities + ): + _reject_peer( + sender_id, + NetworkProtocol.RejectionCode.UNSUPPORTED_CLIENT, + ) + return var identity: Dictionary = _authenticated_identity_cache.get(sender_id, {}) if identity.is_empty(): _reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF) @@ -1278,6 +1306,7 @@ func submit_client_hello(data: Dictionary) -> void: session_max_players, _session_display_name, _host_world_seed, + _host_world_layout, ) ) receive_spawn_list.rpc_id(sender_id, _build_spawn_list()) @@ -1332,6 +1361,9 @@ func receive_server_hello(data: Dictionary) -> void: or typeof(data.get("game_version")) != TYPE_STRING or typeof(data.get("rejection_code")) != TYPE_INT or typeof(data.get("world_seed")) != TYPE_INT + or typeof( + data.get("world_layout", String(WorldLayout.GENERATED)) + ) not in [TYPE_STRING, TYPE_STRING_NAME] ): _teardown_peer() _fail("The server sent an invalid handshake response.") @@ -1362,6 +1394,14 @@ func receive_server_hello(data: Dictionary) -> void: _teardown_peer() _fail("The server sent an invalid world seed.") return + var received_world_layout := WorldLayout.normalized( + data.get("world_layout", String(WorldLayout.GENERATED)), + &"", + ) + if received_world_layout.is_empty(): + _teardown_peer() + _fail("The server sent an invalid world layout.") + return _session_id = str(data.get("session_id", "")) _last_server_max_players = int(data.get( "max_players", @@ -1373,6 +1413,7 @@ func receive_server_hello(data: Dictionary) -> void: "protocol_version", NetworkProtocol.PROTOCOL_VERSION )) _last_server_world_seed = received_world_seed + _last_server_world_layout = received_world_layout _server_capabilities = PackedStringArray() var advertised_capabilities: Variant = data.get( "capability_flags", PackedStringArray() @@ -1391,6 +1432,13 @@ func receive_server_hello(data: Dictionary) -> void: _teardown_peer() _fail("This server does not support generated worlds.") return + if ( + received_world_layout == WorldLayout.STARTER_ISLAND + and NetworkProtocol.WORLD_LAYOUT_CAPABILITY not in _server_capabilities + ): + _teardown_peer() + _fail("This server does not support selectable world layouts.") + return var local_peer_id: int = multiplayer.get_unique_id() _registry.clear() _registry.add_peer( @@ -1407,6 +1455,7 @@ func receive_server_hello(data: Dictionary) -> void: NetworkProtocol.WORLD_WEATHER_CAPABILITY, NetworkProtocol.BACKPACK_SHOP_CAPABILITY, NetworkProtocol.WORLD_GENERATION_CAPABILITY, + NetworkProtocol.WORLD_LAYOUT_CAPABILITY, ]), ) _registry.update_appearance(local_peer_id, _local_appearance_snapshot) diff --git a/network/network_shop_service.gd b/network/network_shop_service.gd index f30c1da..6f31c40 100644 --- a/network/network_shop_service.gd +++ b/network/network_shop_service.gd @@ -83,6 +83,10 @@ func setup( _session.state_changed.connect(_on_session_state_changed) +func set_shop_interaction(interaction: FishingShopInteraction) -> void: + _interaction = interaction + + func can_request_purchase() -> bool: return ( _session != null diff --git a/save/player_save_manager.gd b/save/player_save_manager.gd index d800670..1b4c1d2 100644 --- a/save/player_save_manager.gd +++ b/save/player_save_manager.gd @@ -31,6 +31,7 @@ const WorldTimeServiceType = preload("res://world/world_time_service.gd") const WorldWeatherServiceType = preload( "res://world/world_weather_service.gd" ) +const WorldLayoutType = preload("res://world/world_layout.gd") const PlayerJobServiceType = preload("res://jobs/player_job_service.gd") const PlayerType = preload("res://player/player.gd") @@ -63,6 +64,7 @@ class LoadSnapshot: var cooler_capacity_level: int = 0 var art_unlock_mask: int = 0 var total_experience: int = 0 + var world_layout: StringName = WorldLayoutType.GENERATED var world_seed: int = DEFAULT_WORLD_SEED var world_time_hours: float = WorldTimeServiceType.DEFAULT_START_HOUR var has_world_weather_state: bool = false @@ -102,6 +104,7 @@ var _autosave_enabled: bool = false var _save_path := "" var _expected_hash := "" var _data_root: PlayerDataRoot +var _world_layout: StringName = WorldLayoutType.GENERATED var _world_seed: int = DEFAULT_WORLD_SEED @@ -332,6 +335,7 @@ func load_player_data() -> bool: var world_time_restored: bool = ( _world_time.restore_persistent_time_hours(snapshot.world_time_hours) ) + _world_layout = snapshot.world_layout _world_seed = snapshot.world_seed var world_weather_restored: bool = true if snapshot.has_world_weather_state: @@ -521,18 +525,30 @@ func import_progression_archive(path: String) -> Dictionary: } -func initialize_new_game(world_seed: int = DEFAULT_WORLD_SEED) -> bool: +func initialize_new_game( + world_seed: int = DEFAULT_WORLD_SEED, + world_layout: StringName = WorldLayoutType.GENERATED, +) -> bool: if not _is_configured: return false - if world_seed <= 0 or world_seed > MAX_WORLD_SEED: + if ( + world_seed <= 0 + or world_seed > MAX_WORLD_SEED + or not WorldLayoutType.is_valid(world_layout) + ): return false _automatic_saving_blocked = false _restore_defaults() + _world_layout = world_layout _world_seed = world_seed _is_dirty = true return true +func get_world_layout() -> StringName: + return _world_layout + + func get_world_seed() -> int: return _world_seed @@ -733,6 +749,7 @@ func _build_save_dictionary() -> Dictionary: "art": _art_unlocks.to_save_data(), "experience": _experience.to_save_data(), "world": { + "layout": String(_world_layout), "seed": _world_seed, "time_hours": _world_time.get_persistent_time_hours(), "weather": int(_world_weather.get_persistent_weather()), @@ -822,6 +839,12 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: return null var snapshot := LoadSnapshot.new() + if world_data.has("layout"): + if not WorldLayoutType.is_valid(world_data["layout"]): + return null + snapshot.world_layout = WorldLayoutType.normalized( + world_data["layout"] + ) if world_data.has("seed"): snapshot.world_seed = _read_integer( world_data["seed"], @@ -1525,6 +1548,7 @@ func _prepare_external_save_data(value: Variant) -> Dictionary: "catch_count": snapshot.catches.size(), "wallet_balance": snapshot.wallet_balance, "discovered_species_count": snapshot.discovered_ids.size(), + "world_layout": String(snapshot.world_layout), "world_seed": snapshot.world_seed, } @@ -1657,6 +1681,7 @@ func _restore_defaults() -> void: WorldTimeServiceType.DEFAULT_START_HOUR ) _world_weather.reset_persistent_state() + _world_layout = WorldLayoutType.GENERATED _world_seed = DEFAULT_WORLD_SEED _jobs.reset_to_defaults() _is_restoring = false diff --git a/scripts/run_validations.sh b/scripts/run_validations.sh index 7e82076..1499d9a 100755 --- a/scripts/run_validations.sh +++ b/scripts/run_validations.sh @@ -51,6 +51,7 @@ readonly -a QUICK_TESTS=( "tests/tree_gathering_prototype_validation.gd" "tests/unified_inventory_validation.gd" "tests/world_time_validation.gd" + "tests/world_layout_validation.gd" "tests/world_spawn_protocol_validation.gd" "tests/world_weather_validation.gd" ) @@ -88,6 +89,7 @@ readonly -a NETWORK_TESTS=( "tests/profile_multiplayer_validation.gd" "tests/surface_drawing_multiplayer_validation.gd" "tests/world_time_multiplayer_validation.gd" + "tests/world_layout_multiplayer_validation.gd" "tests/world_spawn_multiplayer_validation.gd" ) diff --git a/tests/inventory_storage_persistence_validation.gd b/tests/inventory_storage_persistence_validation.gd index c3026d7..794f438 100644 --- a/tests/inventory_storage_persistence_validation.gd +++ b/tests/inventory_storage_persistence_validation.gd @@ -26,7 +26,10 @@ func _run() -> void: var player := main.get("_player") as Player assert(save_manager != null and player != null) const TEST_WORLD_SEED := 918273 - assert(save_manager.initialize_new_game(TEST_WORLD_SEED)) + assert(save_manager.initialize_new_game( + TEST_WORLD_SEED, + WorldLayout.STARTER_ISLAND, + )) save_manager.set_autosave_enabled(true) assert(player.bag.add_item(&"art_kit")) assert(player.bag.add_item(&"coffee")) @@ -44,6 +47,7 @@ func _run() -> void: assert(bool(decoded.get("ok", false))) var parsed: Dictionary = decoded["data"] var records: Array = parsed["bag"]["items"] + assert(str(parsed["world"]["layout"]) == String(WorldLayout.STARTER_ISLAND)) assert(int(parsed["world"]["seed"]) == TEST_WORLD_SEED) assert(_saved_slot(records, &"basic_fishing_rod") == 14) assert(_saved_slot(records, &"coffee") == 12) @@ -53,6 +57,7 @@ func _run() -> void: player.unequip_bait() player.unequip_lure() assert(save_manager.load_player_data()) + assert(save_manager.get_world_layout() == WorldLayout.STARTER_ISLAND) assert(save_manager.get_world_seed() == TEST_WORLD_SEED) assert(player.bag.get_storage_slot(&"basic_fishing_rod") == 14) assert(player.bag.get_storage_slot(&"coffee") == 12) diff --git a/tests/world_layout_multiplayer_validation.gd b/tests/world_layout_multiplayer_validation.gd new file mode 100644 index 0000000..51e7be6 --- /dev/null +++ b/tests/world_layout_multiplayer_validation.gd @@ -0,0 +1,126 @@ +extends SceneTree + +const MainScene: PackedScene = preload("res://main/main.tscn") +const TEST_PORT: int = 18155 +const TEST_SEED: int = 86421357 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var arguments: PackedStringArray = OS.get_cmdline_user_args() + if arguments.has("host"): + await _run_host() + return + if arguments.has("client"): + await _run_client() + return + push_error("World layout multiplayer validation needs host or client mode.") + quit(1) + + +func _run_host() -> void: + var main: Node = await _create_initialized_main() + var session := main.get_node("%NetworkSession") as NetworkSession + var save_manager := main.get("_save_manager") as PlayerSaveManager + assert(bool(main.call( + "_apply_world", + WorldLayout.STARTER_ISLAND, + TEST_SEED, + true, + ))) + assert(bool(main.call( + "_prepare_host_world", + WorldLayout.STARTER_ISLAND, + TEST_SEED, + ))) + assert(save_manager.initialize_new_game( + TEST_SEED, + WorldLayout.STARTER_ISLAND, + )) + assert(session.start_private_host(TEST_PORT)) + main.call("_enter_gameplay") + assert(session.set_host_open(true)) + var remote_peer_id: int = await _wait_for_remote_peer(session) + assert(remote_peer_id > 1) + assert(session.peer_supports_capability( + remote_peer_id, + NetworkProtocol.WORLD_LAYOUT_CAPABILITY, + )) + var disconnect_deadline: int = Time.get_ticks_msec() + 12000 + 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("World layout multiplayer host validation: PASS") + await _cleanup(main, session) + + +func _run_client() -> void: + var main: Node = await _create_initialized_main() + main.call( + "_on_title_join_game_requested", + "127.0.0.1:%d" % TEST_PORT, + ) + var session := main.get_node("%NetworkSession") as NetworkSession + var 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")): + break + assert(session.is_joined_client()) + assert(session.supports_server_capability( + NetworkProtocol.WORLD_LAYOUT_CAPABILITY + )) + var metadata: Dictionary = session.get_last_server_metadata() + assert(str(metadata.get("world_layout", "")) == String( + WorldLayout.STARTER_ISLAND + )) + assert(int(metadata.get("world_seed", 0)) == TEST_SEED) + var world := main.get("_test_world") as TestWorld + assert(world.get_world_layout() == WorldLayout.STARTER_ISLAND) + assert(world.get_generation_seed() == TEST_SEED) + assert(world.get_node_or_null("Regions/StarterIslandRegion") != null) + assert(world.get_fishing_shop() != null) + assert(world.get_player_storage() != null) + print("World layout multiplayer client validation: PASS") + await _cleanup(main, session) + + +func _create_initialized_main() -> Node: + root.size = Vector2i(1280, 720) + var main: Node = MainScene.instantiate() + root.add_child(main) + for _frame: int in 4: + await process_frame + if not bool(main.get("_application_initialized")): + main.call("_activate_selected_data_path", "", true) + for _frame: int in 8: + await process_frame + assert(bool(main.get("_application_initialized"))) + return main + + +func _wait_for_remote_peer(session: NetworkSession) -> int: + var deadline: int = Time.get_ticks_msec() + 20000 + while Time.get_ticks_msec() < deadline: + await process_frame + for peer_id: int in session.get_authenticated_peer_ids(): + if peer_id != session.get_local_peer_id(): + return peer_id + return 0 + + +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/world_layout_multiplayer_validation.gd.uid b/tests/world_layout_multiplayer_validation.gd.uid new file mode 100644 index 0000000..e666cb2 --- /dev/null +++ b/tests/world_layout_multiplayer_validation.gd.uid @@ -0,0 +1 @@ +uid://bg52pbgv5ykjc diff --git a/tests/world_layout_validation.gd b/tests/world_layout_validation.gd new file mode 100644 index 0000000..63f03ae --- /dev/null +++ b/tests/world_layout_validation.gd @@ -0,0 +1,125 @@ +extends SceneTree + +const TestWorldScene: PackedScene = preload("res://world/test_world.tscn") +const SetupPageScene: PackedScene = preload( + "res://ui/new_game_setup_page.tscn" +) + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + root.size = Vector2i(1280, 720) + _validate_layout_values() + await _validate_setup_page() + await _validate_world_switching() + _validate_network_metadata() + print("World layout validation: PASS") + quit() + + +func _validate_layout_values() -> void: + assert(WorldLayout.is_valid(WorldLayout.GENERATED)) + assert(WorldLayout.is_valid(String(WorldLayout.STARTER_ISLAND))) + assert(not WorldLayout.is_valid(&"unknown")) + assert(WorldLayout.normalized("unknown") == WorldLayout.GENERATED) + assert(WorldLayout.display_name(WorldLayout.STARTER_ISLAND) == "starter island") + + +func _validate_setup_page() -> void: + assert(NewGameSetupPage.parse_seed_text(" 24680 ") == 24680) + assert(NewGameSetupPage.parse_seed_text("0") == 0) + assert(NewGameSetupPage.parse_seed_text("2147483647") == 0) + assert(NewGameSetupPage.parse_seed_text("") == 0) + var text_seed: int = NewGameSetupPage.parse_seed_text("voyager island") + assert(text_seed > 0 and text_seed <= PlayerSaveManager.MAX_WORLD_SEED) + assert(NewGameSetupPage.parse_seed_text(" voyager island ") == text_seed) + assert(NewGameSetupPage.parse_seed_text("voyager island") == text_seed) + assert(NewGameSetupPage.parse_seed_text("Voyager Island") != text_seed) + var page := SetupPageScene.instantiate() as NewGameSetupPage + root.add_child(page) + await process_frame + page.open_page(true) + await process_frame + assert(page.visible) + var paper := page.get_node("%Paper") as PanelContainer + assert(paper.get_combined_minimum_size().x <= paper.size.x) + assert(paper.get_combined_minimum_size().y <= paper.size.y) + assert(page.get_selected_world_layout() == WorldLayout.GENERATED) + assert(page.get_selected_world_seed() > 0) + assert((page.get_node("%OverwriteWarning") as Label).visible) + var generated_button := page.get_node("%GeneratedButton") as Button + var starter_button := page.get_node("%StarterButton") as Button + var random_button := page.get_node("%RandomSeedButton") as Button + var custom_button := page.get_node("%CustomSeedButton") as Button + var seed_edit := page.get_node("%SeedEdit") as LineEdit + var start_button := page.get_node("%StartButton") as Button + var back_button := page.get_node("%BackButton") as Button + assert(generated_button.get_node(generated_button.focus_neighbor_right) == starter_button) + assert(generated_button.get_node(generated_button.focus_neighbor_bottom) == random_button) + assert(random_button.get_node(random_button.focus_neighbor_right) == custom_button) + assert(start_button.get_node(start_button.focus_neighbor_right) == back_button) + page.call("_choose_custom_seed") + assert(seed_edit.editable) + assert(custom_button.get_node(custom_button.focus_neighbor_bottom) == seed_edit) + assert(seed_edit.get_node(seed_edit.focus_neighbor_bottom) == start_button) + page.call("_set_world_layout", WorldLayout.STARTER_ISLAND) + assert(not (page.get_node("%SeedSection") as Control).visible) + var request: Dictionary = {} + page.start_requested.connect(func(layout: StringName, seed: int) -> void: + request["layout"] = layout + request["seed"] = seed + ) + page.call("_request_start") + assert(request.get("layout") == WorldLayout.STARTER_ISLAND) + assert(int(request.get("seed", 0)) > 0) + page.queue_free() + await process_frame + + +func _validate_world_switching() -> void: + var world := TestWorldScene.instantiate() as TestWorld + root.add_child(world) + await process_frame + assert(world.get_world_layout() == WorldLayout.GENERATED) + assert(world.get_fishing_shop() != null) + assert(world.get_player_storage() != null) + assert(world.activate_world(WorldLayout.STARTER_ISLAND, 13579)) + assert(world.get_world_layout() == WorldLayout.STARTER_ISLAND) + assert(world.get_generation_seed() == 13579) + assert(world.get_node_or_null("Regions/StarterIslandRegion") != null) + assert(world.get_node_or_null("Regions/GeneratedWorldRegion") == null) + assert(world.get_fishing_shop() != null) + assert(world.get_player_storage() != null) + assert(not world.get_fishable_water_regions().is_empty()) + assert(world.activate_world(WorldLayout.GENERATED, 24680)) + assert(world.get_world_layout() == WorldLayout.GENERATED) + assert(world.get_generation_seed() == 24680) + assert(world.get_node_or_null("Regions/GeneratedWorldRegion") != null) + assert(world.get_node_or_null("Regions/StarterIslandRegion") == null) + world.queue_free() + await process_frame + + +func _validate_network_metadata() -> void: + var hello: Dictionary = NetworkProtocol.make_server_hello( + true, + NetworkProtocol.RejectionCode.NONE, + "session", + 2, + 1, + 16, + "room", + 97531, + WorldLayout.STARTER_ISLAND, + ) + assert(str(hello.get("world_layout", "")) == String( + WorldLayout.STARTER_ISLAND + )) + assert(int(hello.get("world_seed", 0)) == 97531) + assert( + NetworkProtocol.WORLD_LAYOUT_CAPABILITY + in PackedStringArray(hello.get("capability_flags", [])) + ) diff --git a/tests/world_layout_validation.gd.uid b/tests/world_layout_validation.gd.uid new file mode 100644 index 0000000..9002c58 --- /dev/null +++ b/tests/world_layout_validation.gd.uid @@ -0,0 +1 @@ +uid://bgxgyqq73sdpi diff --git a/ui/fishing_shop.gd b/ui/fishing_shop.gd index d76ec4a..f56b0ae 100644 --- a/ui/fishing_shop.gd +++ b/ui/fishing_shop.gd @@ -627,6 +627,10 @@ func setup( _refresh_all() +func set_world_interaction(interaction: ShopInteractionType) -> void: + _interaction = interaction + + func open_shop() -> bool: if ( visible diff --git a/ui/game_ui.gd b/ui/game_ui.gd index 1465990..c8d6f34 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -488,6 +488,16 @@ func setup( ) +func set_world_interactions( + shop_interaction: ShopInteractionType, + storage_interaction: PlayerStorageInteractionType, +) -> void: + _shop_interaction = shop_interaction + _storage_interaction = storage_interaction + _fishing_shop.set_world_interaction(shop_interaction) + _player_storage.set_world_interaction(storage_interaction) + + func _prioritize_surface_drawing_pointer_input() -> void: # Control input follows sibling order rather than CanvasItem.z_index. Chat is # a full-screen Control with interactive mobile children, so the toolbar must diff --git a/ui/new_game_setup_page.gd b/ui/new_game_setup_page.gd new file mode 100644 index 0000000..1938f9c --- /dev/null +++ b/ui/new_game_setup_page.gd @@ -0,0 +1,309 @@ +class_name NewGameSetupPage +extends Control + +signal start_requested(world_layout: StringName, world_seed: int) +signal back_requested + +const WorldLayoutType = preload("res://world/world_layout.gd") +const SaveManagerType = preload("res://save/player_save_manager.gd") +const ControllerFocusNavigationType = preload( + "res://ui/controller_focus_navigation.gd" +) + +enum SeedMode { + RANDOM, + CUSTOM, +} + +@onready var _paper: PanelContainer = %Paper +@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 _overwrite_warning: Label = %OverwriteWarning +@onready var _status: Label = %Status +@onready var _start_button: Button = %StartButton +@onready var _back_button: Button = %BackButton + +var _world_layout: StringName = WorldLayoutType.GENERATED +var _seed_mode: SeedMode = SeedMode.RANDOM +var _random_seed: int = SaveManagerType.DEFAULT_WORLD_SEED + + +func _ready() -> void: + UtilityPageStyle.apply_page(self) + _paper.add_theme_stylebox_override( + "panel", UtilityPageStyle.panel_style() + ) + for button: Button in [ + _generated_button, + _starter_button, + _random_seed_button, + _custom_seed_button, + _start_button, + _back_button, + ]: + UtilityPageStyle.apply_ocean_button(button) + UtilityPageStyle.apply_ocean_line_edit(_seed_edit) + _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) + _seed_edit.text_changed.connect(_on_seed_text_changed) + _seed_edit.text_submitted.connect(_on_seed_submitted) + _start_button.pressed.connect(_request_start) + _back_button.pressed.connect(back_requested.emit) + hide() + + +func open_page(has_existing_progression: bool) -> void: + _world_layout = WorldLayoutType.GENERATED + _seed_mode = SeedMode.RANDOM + _roll_random_seed() + _overwrite_warning.visible = has_existing_progression + _status.text = "" + _refresh_presentation() + show() + UtilityPageStyle.animate_in(self) + _generated_button.grab_focus.call_deferred() + + +func close_page() -> void: + var focus_owner: Control = get_viewport().gui_get_focus_owner() + if focus_owner != null and is_ancestor_of(focus_owner): + focus_owner.release_focus() + hide() + + +func request_back() -> void: + back_requested.emit() + + +func get_selected_world_layout() -> StringName: + return _world_layout + + +func get_selected_world_seed() -> int: + return ( + _random_seed + if _seed_mode == SeedMode.RANDOM + else parse_seed_text(_seed_edit.text) + ) + + +static func parse_seed_text(text: String) -> int: + var normalized: String = text.strip_edges() + if normalized.is_empty(): + return 0 + if normalized.is_valid_int(): + var numeric_seed: int = int(normalized) + if ( + numeric_seed <= 0 + or numeric_seed > SaveManagerType.MAX_WORLD_SEED + ): + return 0 + return numeric_seed + var digest: PackedByteArray = normalized.sha256_buffer() + var hashed_seed: int = ( + (int(digest[0]) << 24) + | (int(digest[1]) << 16) + | (int(digest[2]) << 8) + | int(digest[3]) + ) & 0x7fffffff + return (hashed_seed % SaveManagerType.MAX_WORLD_SEED) + 1 + + +func _set_world_layout(layout: StringName) -> void: + if not WorldLayoutType.is_valid(layout): + return + _world_layout = layout + _status.text = "" + _refresh_presentation() + if layout == WorldLayoutType.GENERATED: + _random_seed_button.grab_focus.call_deferred() + else: + _start_button.grab_focus.call_deferred() + + +func _choose_random_seed() -> void: + _seed_mode = SeedMode.RANDOM + _roll_random_seed() + _status.text = "" + _refresh_presentation() + + +func _choose_custom_seed() -> void: + _seed_mode = SeedMode.CUSTOM + if parse_seed_text(_seed_edit.text) == _random_seed: + _seed_edit.text = "" + _status.text = "" + _refresh_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 _on_seed_text_changed(_text: String) -> void: + if _seed_mode != SeedMode.CUSTOM: + return + _status.text = "" + _refresh_start_state() + + +func _on_seed_submitted(_text: String) -> void: + _request_start() + + +func _request_start() -> void: + var seed: int = get_selected_world_seed() + if _world_layout == WorldLayoutType.GENERATED and seed == 0: + _status.text = ( + "enter some 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() + _start_button.disabled = true + start_requested.emit(_world_layout, seed) + + +func _refresh_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. the same seed always " + + "builds the same world." + if generated + else "play on the authored 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. the same entry makes the same world." + % SaveManagerType.MAX_WORLD_SEED + ) + _refresh_start_state() + _configure_controller_focus() + + +func _refresh_start_state() -> void: + _start_button.disabled = ( + _world_layout == WorldLayoutType.GENERATED + and _seed_mode == SeedMode.CUSTOM + and parse_seed_text(_seed_edit.text) == 0 + ) + + +func _configure_controller_focus() -> void: + var generated: bool = _world_layout == WorldLayoutType.GENERATED + var custom: bool = generated and _seed_mode == SeedMode.CUSTOM + _set_neighbors( + _generated_button, + _generated_button, + _starter_button, + _generated_button, + _random_seed_button if generated else _start_button, + ) + _set_neighbors( + _starter_button, + _generated_button, + _starter_button, + _starter_button, + _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 _start_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, + _start_button, + ) + var action_top: Control = ( + _seed_edit + if custom + else _random_seed_button + if generated + else _generated_button + ) + _set_neighbors( + _start_button, + _start_button, + _back_button, + action_top, + _start_button, + ) + _set_neighbors( + _back_button, + _start_button, + _back_button, + action_top, + _back_button, + ) + var traversal: Array[Control] = [ + _generated_button, + _starter_button, + ] + if generated: + traversal.append(_random_seed_button) + traversal.append(_custom_seed_button) + if custom: + traversal.append(_seed_edit) + traversal.append(_start_button) + traversal.append(_back_button) + ControllerFocusNavigationType.configure_traversal(traversal) + + +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) diff --git a/ui/new_game_setup_page.gd.uid b/ui/new_game_setup_page.gd.uid new file mode 100644 index 0000000..d0654b5 --- /dev/null +++ b/ui/new_game_setup_page.gd.uid @@ -0,0 +1 @@ +uid://d3iueu4gj6bwo diff --git a/ui/new_game_setup_page.tscn b/ui/new_game_setup_page.tscn new file mode 100644 index 0000000..d863735 --- /dev/null +++ b/ui/new_game_setup_page.tscn @@ -0,0 +1,170 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://ui/new_game_setup_page.gd" id="1_script"] +[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"] + +[node name="NewGameSetupPage" 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="Paper" 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 = -360.0 +offset_top = -280.0 +offset_right = 360.0 +offset_bottom = 280.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Margin" type="MarginContainer" parent="Paper"] +layout_mode = 2 +theme_override_constants/margin_left = 46 +theme_override_constants/margin_top = 30 +theme_override_constants/margin_right = 46 +theme_override_constants/margin_bottom = 30 + +[node name="Layout" type="VBoxContainer" parent="Paper/Margin"] +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="Title" type="Label" parent="Paper/Margin/Layout"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1) +theme_override_font_sizes/font_size = 32 +text = "new game" +horizontal_alignment = 1 + +[node name="Prompt" type="Label" parent="Paper/Margin/Layout"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1) +theme_override_font_sizes/font_size = 17 +text = "choose where you want to begin" +horizontal_alignment = 1 + +[node name="WorldButtons" type="HBoxContainer" parent="Paper/Margin/Layout"] +layout_mode = 2 +theme_override_constants/separation = 14 +alignment = 1 + +[node name="GeneratedButton" type="Button" parent="Paper/Margin/Layout/WorldButtons"] +unique_name_in_owner = true +custom_minimum_size = Vector2(230, 58) +layout_mode = 2 +toggle_mode = true +text = "generate a world" + +[node name="StarterButton" type="Button" parent="Paper/Margin/Layout/WorldButtons"] +unique_name_in_owner = true +custom_minimum_size = Vector2(230, 58) +layout_mode = 2 +toggle_mode = true +text = "starter island" + +[node name="WorldDescription" type="Label" parent="Paper/Margin/Layout"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1) +theme_override_font_sizes/font_size = 16 +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="SeedSection" type="VBoxContainer" parent="Paper/Margin/Layout"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="SeedLabel" type="Label" parent="Paper/Margin/Layout/SeedSection"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1) +theme_override_font_sizes/font_size = 20 +text = "world seed" +horizontal_alignment = 1 + +[node name="SeedModes" type="HBoxContainer" parent="Paper/Margin/Layout/SeedSection"] +layout_mode = 2 +theme_override_constants/separation = 14 +alignment = 1 + +[node name="RandomSeedButton" type="Button" parent="Paper/Margin/Layout/SeedSection/SeedModes"] +unique_name_in_owner = true +custom_minimum_size = Vector2(190, 48) +layout_mode = 2 +toggle_mode = true +text = "random seed" + +[node name="CustomSeedButton" type="Button" parent="Paper/Margin/Layout/SeedSection/SeedModes"] +unique_name_in_owner = true +custom_minimum_size = Vector2(190, 48) +layout_mode = 2 +toggle_mode = true +text = "enter a seed" + +[node name="SeedEdit" type="LineEdit" parent="Paper/Margin/Layout/SeedSection"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 20 +placeholder_text = "number or words" +alignment = 1 +max_length = 64 + +[node name="SeedHelp" type="Label" parent="Paper/Margin/Layout/SeedSection"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1) +theme_override_font_sizes/font_size = 14 +horizontal_alignment = 1 + +[node name="OverwriteWarning" type="Label" parent="Paper/Margin/Layout"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.78, 0.62, 1) +theme_override_font_sizes/font_size = 15 +text = "starting a new game replaces your existing progression." +horizontal_alignment = 1 + +[node name="Status" type="Label" parent="Paper/Margin/Layout"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.7, 0.65, 1) +theme_override_font_sizes/font_size = 14 +horizontal_alignment = 1 + +[node name="Spacer" type="Control" parent="Paper/Margin/Layout"] +custom_minimum_size = Vector2(0, 4) +layout_mode = 2 +size_flags_vertical = 3 + +[node name="Actions" type="HBoxContainer" parent="Paper/Margin/Layout"] +layout_mode = 2 +theme_override_constants/separation = 14 +alignment = 1 + +[node name="StartButton" type="Button" parent="Paper/Margin/Layout/Actions"] +unique_name_in_owner = true +custom_minimum_size = Vector2(190, 54) +layout_mode = 2 +text = "start new game" + +[node name="BackButton" type="Button" parent="Paper/Margin/Layout/Actions"] +unique_name_in_owner = true +custom_minimum_size = Vector2(150, 54) +layout_mode = 2 +text = "back" diff --git a/ui/player_storage.gd b/ui/player_storage.gd index fd885d7..25eb0d4 100644 --- a/ui/player_storage.gd +++ b/ui/player_storage.gd @@ -70,6 +70,10 @@ func setup( _refresh() +func set_world_interaction(interaction: PlayerStorageInteraction) -> void: + _interaction = interaction + + func open_storage() -> bool: if ( visible diff --git a/ui/settings_panel.gd b/ui/settings_panel.gd index 17115a6..e93f0a2 100644 --- a/ui/settings_panel.gd +++ b/ui/settings_panel.gd @@ -758,11 +758,15 @@ func _progression_import_file_selected(path: String) -> void: 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 seed: %d\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)) diff --git a/ui/title_screen.gd b/ui/title_screen.gd index 81a4acf..bff04d8 100644 --- a/ui/title_screen.gd +++ b/ui/title_screen.gd @@ -26,6 +26,7 @@ const TitleCreditsPageType = preload("res://ui/title_credits_page.gd") 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 CurrencyPresentationType = preload( "res://ui/currency_presentation.gd" ) @@ -94,14 +95,13 @@ const INTRO_BRANDING_TRAVEL_DURATION: float = 2.0 const INTRO_BRANDING_START_DELAY: float = 0.35 const HOST_CLUSTER_SAFE_MARGIN: float = 24.0 -signal new_game_requested(world_seed: int) +signal new_game_requested(world_layout: StringName, world_seed: int) signal continue_game_requested signal quit_requested signal join_game_requested(endpoint: String) enum ConfirmationAction { NONE, - NEW_GAME, DELETE_SAVE, } @@ -142,12 +142,12 @@ enum ConfirmationAction { @onready var _start_prompt_center: CenterContainer = %StartPromptCenter @onready var _start_prompt_label: Label = %StartPromptLabel @onready var _join_game_page: JoinGamePageType = %JoinGamePage +@onready var _new_game_setup_page: NewGameSetupPageType = %NewGameSetupPage @onready var _credits_page: TitleCreditsPageType = %CreditsPage var _save_manager: SaveManagerType var _settings_manager: SettingsManagerType var _inspection: SaveInspectionType -var _pending_new_game_seed: int = PlayerSaveManager.DEFAULT_WORLD_SEED var _confirmation_action: ConfirmationAction = ConfirmationAction.NONE var _action_in_progress: bool = false var _decorative_rng := RandomNumberGenerator.new() @@ -210,6 +210,8 @@ func _ready() -> void: ) _new_game_button.pressed.connect(_on_new_game_pressed) _join_game_button.pressed.connect(_open_join_game) + _new_game_setup_page.start_requested.connect(_start_configured_new_game) + _new_game_setup_page.back_requested.connect(_close_new_game_setup) _settings_button.pressed.connect(_open_settings) _credits_button.pressed.connect(_open_credits) _delete_button.pressed.connect(_on_delete_pressed) @@ -283,6 +285,7 @@ func reopen() -> void: _reset_confirmation() _settings_panel.hide() _join_game_page.close_page() + _new_game_setup_page.close_page() _credits_page.close_page() _refresh_save_inspection() show() @@ -311,6 +314,7 @@ func open_join_game_page(endpoint: String = "") -> void: _settings_panel.hide() _confirmation_page.hide_page() _credits_page.close_page() + _new_game_setup_page.close_page() _join_game_page.open_page(endpoint) @@ -326,6 +330,7 @@ func _open_join_game() -> void: if ( _action_in_progress or _is_confirmation_active() + or _new_game_setup_page.visible or _credits_page.visible ): return @@ -346,6 +351,7 @@ func _open_credits() -> void: or _is_confirmation_active() or _settings_panel.visible or _join_game_page.visible + or _new_game_setup_page.visible or _credits_page.visible ): return @@ -583,6 +589,11 @@ func _input(event: InputEvent) -> void: _join_game_page.request_back() get_viewport().set_input_as_handled() return + if _new_game_setup_page.visible: + if event.is_action_pressed("ui_cancel"): + _new_game_setup_page.request_back() + get_viewport().set_input_as_handled() + return if ( _title_settings_transition_active or _title_entry_transition_active @@ -629,6 +640,7 @@ func _handle_primary_menu_focus_input(event: InputEvent) -> bool: not _button_center.visible or _is_confirmation_active() or _settings_panel.visible + or _new_game_setup_page.visible or _credits_page.visible ): return false @@ -1031,19 +1043,59 @@ func _on_new_game_pressed() -> void: "the existing save cannot be accessed safely." ) return - _pending_new_game_seed = PlayerSaveManager.roll_world_seed() - var warning := "start a new world?" - if _inspection.status != SaveInspectionType.Status.MISSING: - warning += " existing progression will be deleted." - warning += "\nworld seed: %d" % _pending_new_game_seed - _open_confirmation( - ConfirmationAction.NEW_GAME, - warning, - "start\nnew game", - true + _open_new_game_setup( + _inspection.status != SaveInspectionType.Status.MISSING ) +func _open_new_game_setup(has_existing_progression: bool) -> void: + _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.open_page(has_existing_progression) + + +func _close_new_game_setup() -> void: + _new_game_setup_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 + _new_game_button.grab_focus() + else: + _navigation_focus_active = false + _release_title_focus() + _update_continue_stats_visibility() + + +func _start_configured_new_game( + world_layout: StringName, + world_seed: int, +) -> void: + if _action_in_progress or not _new_game_setup_page.visible: + return + _new_game_setup_page.close_page() + _action_in_progress = true + new_game_requested.emit(world_layout, world_seed) + _action_in_progress = false + # Signal delivery is synchronous. A successful start hides this title screen; + # if it remains visible, restore the menu so a generation error is recoverable. + if visible: + _presentation_center.show() + _button_center.show() + _set_title_bubbles_interactive(true) + _navigation_focus_active = true + _new_game_button.grab_focus() + + func _on_delete_pressed() -> void: if ( _action_in_progress @@ -1071,14 +1123,6 @@ func _on_confirmation_accepted() -> void: var action: ConfirmationAction = _confirmation_action _confirmation_page.lock_interaction() _action_in_progress = true - if action == ConfirmationAction.NEW_GAME: - _confirmation_action = ConfirmationAction.NONE - _confirmation_transition_generation += 1 - _cancel_confirmation_transition() - _confirmation_page.hide_page() - new_game_requested.emit(_pending_new_game_seed) - _action_in_progress = false - return if not _save_manager.delete_progression_save(): _feedback_label.text = _center_feedback_text( "failed to delete saved progression." diff --git a/ui/title_screen.tscn b/ui/title_screen.tscn index 323e689..4c48849 100644 --- a/ui/title_screen.tscn +++ b/ui/title_screen.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=23 format=3] +[gd_scene load_steps=24 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"] @@ -19,6 +19,7 @@ [ext_resource type="Texture2D" path="res://ui/icons/main_menu/new_game.png" id="17_new_game"] [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"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_title_water"] shader = ExtResource("4_water_shader") @@ -486,3 +487,13 @@ anchor_right = 1.0 anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 + +[node name="NewGameSetupPage" parent="ResponsiveTitleStage/TitlePresentationScaleRoot" instance=ExtResource("20_new_game_setup")] +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/world/test_world.gd b/world/test_world.gd index 66c4e92..0d99e45 100644 --- a/world/test_world.gd +++ b/world/test_world.gd @@ -7,6 +7,13 @@ const FishingShopInteractionType = preload( const PlayerStorageInteractionType = preload( "res://world/player_storage_interaction.gd" ) +const WorldLayoutType = preload("res://world/world_layout.gd") +const GENERATED_REGION_SCENE: PackedScene = preload( + "res://world/generation/generated_world_region.tscn" +) +const STARTER_ISLAND_REGION_SCENE: PackedScene = preload( + "res://world/regions/starter_island_region.tscn" +) @onready var _regions_root: Node3D = $Regions @onready var _active_region: WorldRegion = _find_active_region() @@ -16,20 +23,25 @@ const PlayerStorageInteractionType = preload( @onready var _world_environment: WorldEnvironment = $Environment/WorldEnvironment @onready var _sun: DirectionalLight3D = $Environment/Sun +var _world_layout: StringName = WorldLayoutType.GENERATED +var _world_seed: int = PlayerSaveManager.DEFAULT_WORLD_SEED +var _light_performance_profile: bool = false + func get_player_water_triggers() -> Array[PlayerWaterTrigger]: var triggers: Array[PlayerWaterTrigger] = [] - for region: WorldRegion in _get_regions(): - triggers.append_array(region.get_water_recovery_triggers()) + if _active_region != null: + triggers.append_array(_active_region.get_water_recovery_triggers()) triggers.append(_below_world_failsafe) return triggers func get_safe_respawn_points() -> Array[SafeRespawnPoint]: - var points: Array[SafeRespawnPoint] = [] - for region: WorldRegion in _get_regions(): - points.append_array(region.get_safe_respawn_points()) - return points + return ( + _active_region.get_safe_respawn_points() + if _active_region != null + else [] + ) func get_fishing_shop() -> FishingShopInteractionType: @@ -53,14 +65,17 @@ func get_sun() -> DirectionalLight3D: func set_light_performance_profile(enabled: bool) -> void: - _active_region.set_light_performance_profile(enabled) + _light_performance_profile = enabled + if _active_region != null: + _active_region.set_light_performance_profile(enabled) func get_fishable_water_regions() -> Array[FishableWaterRegion]: - var waters: Array[FishableWaterRegion] = [] - for region: WorldRegion in _get_regions(): - waters.append_array(region.get_fishable_water_regions()) - return waters + return ( + _active_region.get_fishable_water_regions() + if _active_region != null + else [] + ) func get_saltwater_shoreline_mesh() -> MeshInstance3D: @@ -77,26 +92,44 @@ func get_spawn_surface_triangles( ) -func generate_world(seed: int) -> bool: - if _active_region == null or not _active_region.has_method("generate_world"): +func activate_world(layout: StringName, seed: int) -> bool: + if ( + not WorldLayoutType.is_valid(layout) + or seed <= 0 + or seed > PlayerSaveManager.MAX_WORLD_SEED + ): return false - var generated: bool = bool(_active_region.call("generate_world", seed)) - if generated: - _configure_world_coverage() - return generated + if _active_region == null or _active_region.region_id != layout: + if not _replace_active_region(layout, seed): + return false + _world_layout = layout + _world_seed = seed + if layout == WorldLayoutType.GENERATED: + if not _active_region.has_method("generate_world"): + return false + if not bool(_active_region.call("generate_world", seed)): + return false + _configure_world_coverage() + return true + + +func generate_world(seed: int) -> bool: + return activate_world(WorldLayoutType.GENERATED, seed) + + +func get_world_layout() -> StringName: + return _world_layout func get_generation_seed() -> int: - if _active_region != null and _active_region.has_method("get_generation_seed"): - return int(_active_region.call("get_generation_seed")) - return PlayerSaveManager.DEFAULT_WORLD_SEED + return _world_seed func get_diggable_area_triangles( area_id: StringName, ) -> Array[PackedVector3Array]: - for region: WorldRegion in _get_regions(): - var area: DiggableArea3D = region.get_diggable_area(area_id) + if _active_region != null: + var area: DiggableArea3D = _active_region.get_diggable_area(area_id) if area != null: return area.get_surface_triangles() return [] @@ -105,25 +138,18 @@ func get_diggable_area_triangles( func get_gatherable_spawn_positions( anchor_set_id: StringName, ) -> PackedVector3Array: - for region: WorldRegion in _get_regions(): + if _active_region != null: var anchor_set: GatherableAnchorSet3D = ( - region.get_gatherable_anchor_set(anchor_set_id) + _active_region.get_gatherable_anchor_set(anchor_set_id) ) if anchor_set != null: return anchor_set.get_spawn_positions() return PackedVector3Array() -func _get_regions() -> Array[WorldRegion]: - var regions: Array[WorldRegion] = [] - for child: Node in _regions_root.get_children(): - var region: WorldRegion = child as WorldRegion - if region != null: - regions.append(region) - return regions - - func _ready() -> void: + if _active_region != null: + _world_layout = _active_region.region_id _configure_world_coverage() @@ -135,6 +161,26 @@ func _find_active_region() -> WorldRegion: return null +func _replace_active_region(layout: StringName, seed: int) -> bool: + var region_scene: PackedScene = ( + GENERATED_REGION_SCENE + if layout == WorldLayoutType.GENERATED + else STARTER_ISLAND_REGION_SCENE + ) + var replacement := region_scene.instantiate() as WorldRegion + if replacement == null: + return false + if layout == WorldLayoutType.GENERATED: + replacement.set("initial_seed", seed) + if _active_region != null: + _regions_root.remove_child(_active_region) + _active_region.free() + _active_region = replacement + _regions_root.add_child(_active_region) + _active_region.set_light_performance_profile(_light_performance_profile) + return true + + func _configure_world_coverage() -> void: if _active_region == null: return diff --git a/world/world_layout.gd b/world/world_layout.gd new file mode 100644 index 0000000..6d27333 --- /dev/null +++ b/world/world_layout.gd @@ -0,0 +1,29 @@ +class_name WorldLayout +extends RefCounted + +const GENERATED: StringName = &"generated_world" +const STARTER_ISLAND: StringName = &"starter_island" + + +static func is_valid(value: Variant) -> bool: + if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]: + return false + var layout := StringName(str(value).strip_edges()) + return layout in [GENERATED, STARTER_ISLAND] + + +static func normalized( + value: Variant, + fallback: StringName = GENERATED, +) -> StringName: + if not is_valid(value): + return fallback + return StringName(str(value).strip_edges()) + + +static func display_name(value: Variant) -> String: + match normalized(value): + STARTER_ISLAND: + return "starter island" + _: + return "generated world" diff --git a/world/world_layout.gd.uid b/world/world_layout.gd.uid new file mode 100644 index 0000000..a9bc549 --- /dev/null +++ b/world/world_layout.gd.uid @@ -0,0 +1 @@ +uid://bi2fydywih4lv