diff --git a/audio/music/world/tulips.wav b/audio/music/world/tulips.wav new file mode 100644 index 0000000..9cd4605 Binary files /dev/null and b/audio/music/world/tulips.wav differ diff --git a/audio/music/world/tulips.wav.import b/audio/music/world/tulips.wav.import new file mode 100644 index 0000000..0f8cce1 --- /dev/null +++ b/audio/music/world/tulips.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://5hgmht4bq1my" +path="res://.godot/imported/tulips.wav-7c18a300a62f25396913efb28c2ba3f6.sample" + +[deps] + +source_file="res://audio/music/world/tulips.wav" +dest_files=["res://.godot/imported/tulips.wav-7c18a300a62f25396913efb28c2ba3f6.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/docs/PORTMASTER.md b/docs/PORTMASTER.md index e7e3e09..e8c24f6 100644 --- a/docs/PORTMASTER.md +++ b/docs/PORTMASTER.md @@ -28,18 +28,21 @@ directory. The templates in `scripts/portmaster/` are authoritative. - `port.json` uses schema version 4, names `netfishing.zip`, and marks the self-contained package ready to run. -- `NETfishing.sh` starts with `# PORTMASTER: netfishing.zip, NETfishing.sh`. +- `NETfishing.sh` does not contain a `# PORTMASTER:` signature in the source or + distributable archive. HarbourMaster adds that installation marker when it + installs the port. - The executable is `netfishing/NETfishing.aarch64`. - The package declares AArch64, two analog sticks, GLIBC 2.28, and `weston_pkg_0.2.squashfs`. - The launcher starts GPTOKEYB with the explicit `netfishing.gptk` no-op map. - Every keyboard and mouse binding is disabled, leaving only PortMaster's + Every keyboard and mouse binding is disabled, leaving only GPTOKEYB's device-specific force-quit chord while Godot and NETfishing's controller - mapping manager handle gameplay input. + mapping manager handle gameplay input. The explicit map also avoids the + anomalous controller behavior observed on ROCKNIX without a passthrough map. - The launcher calls `pm_platform_helper` for the game executable after starting GPTOKEYB and calls `pm_finish` after the game exits. -- New installations automatically create player data under - `ports/saves/netfishing/` and do not present the in-game folder picker. +- NETfishing automatically creates player data for new installations under + `ports/saves/netfishing/` and does not present the in-game folder picker. - Existing installations with a selected data root keep that location when upgraded. Device-local data, configuration, and cache remain under `netfishing/conf/data`, `netfishing/conf/config`, and @@ -103,15 +106,15 @@ Before upgrading an existing installation, preserve device. After installation: 1. Confirm the installed executable and PCK hashes match the staged release. -2. Confirm the installed launcher contains the canonical PortMaster header, - starts GPTOKEYB with `netfishing.gptk`, and calls - `pm_platform_helper` for the game executable. +2. Confirm HarbourMaster added its `# PORTMASTER:` installation signature to + the installed launcher, then confirm the launcher starts GPTOKEYB with + `netfishing.gptk` and calls `pm_platform_helper` for the game executable. 3. Confirm `conf/` and the external save directory were not replaced or removed. 4. Launch the installed port through the normal muOS menu. 5. Verify the displayed game version and controller face-button mapping. -6. Verify PortMaster's force-quit chord exits the game. This is Start+Select on - most devices. +6. Verify GPTOKEYB's device-specific force-quit chord exits the game. This is + Start+Select on most devices. Installing the public PortMaster catalog entry can still install an older catalog build until the upstream `netfishing.zip` is updated. A local release @@ -173,12 +176,12 @@ the game process. This applies the mapping at the same point as the working reconstruction. Do not move the mapping back to either side of `westonwrap.sh`. -Legacy GPTOKEYB builds can load their compiled default keyboard map when no -`-c` file is supplied. That map translates face buttons, D-pad directions, and -sticks into keyboard or mouse events on top of Godot's native controller input. Always pass `netfishing.gptk`, which explicitly unassigns every direct and -hotkey binding with GPTOKEYB's `\"` value. Do not add gameplay mappings to this -file. The Start+Select kill chord remains independent of the no-op bindings. +hotkey binding with GPTOKEYB's `\"` value. This keeps GPTOKEYB from injecting +keyboard or mouse events on top of Godot's native controller input and retains +the device-specific kill chord. Supplying the explicit passthrough map also +avoids the anomalous controller behavior observed on ROCKNIX when no map is +provided. Do not add gameplay mappings to this file. Do not call `Input.add_joy_mapping(..., true)` for a recognized connected muOS controller. Updating this virtual controller after connection can stop Godot diff --git a/docs/README-PLAYTEST.txt b/docs/README-PLAYTEST.txt index cd65918..32b07d6 100644 --- a/docs/README-PLAYTEST.txt +++ b/docs/README-PLAYTEST.txt @@ -1,6 +1,6 @@ NETfishing -v0.13.1-alpha -Alpha 0.13.1 +v0.14.0-alpha +Alpha 0.14.0 Thank you for trying this early private playtest. diff --git a/economy/fishing_shop_stock.gd b/economy/fishing_shop_stock.gd index 962ba3a..b9d3eaf 100644 --- a/economy/fishing_shop_stock.gd +++ b/economy/fishing_shop_stock.gd @@ -12,6 +12,7 @@ const MAGNET_ID: StringName = &"magnet" const BATTERIES_ID: StringName = &"batteries" const CRAB_NET_ID: StringName = &"crab_net" const STANDARD_SHOVEL_ID: StringName = &"standard_shovel" +const FISHING_NET_ID: StringName = &"fishing_net" const ITEM_PRICES: Dictionary[StringName, int] = { &"worms": 1, @@ -30,6 +31,7 @@ const ITEM_PRICES: Dictionary[StringName, int] = { BATTERIES_ID: 15, CRAB_NET_ID: 50, STANDARD_SHOVEL_ID: 75, + FISHING_NET_ID: 250, } const BAIT_UNLOCK_PRICES: Dictionary[StringName, int] = { &"snails": 400, @@ -52,6 +54,7 @@ const ITEM_ORDER: Array[StringName] = [ MAGNET_ID, CRAB_NET_ID, STANDARD_SHOVEL_ID, + FISHING_NET_ID, &"coffee", &"energy_drink", &"snack", @@ -120,6 +123,7 @@ static func is_permanent_unlock( or item_id == MAGNET_ID or item_id == CRAB_NET_ID or item_id == STANDARD_SHOVEL_ID + or item_id == FISHING_NET_ID or item is FishingRodDataType ) ) diff --git a/export_presets.cfg b/export_presets.cfg index ccc81d9..a2c2817 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.13.1-alpha/windows-x86_64/NETfishing.exe" +export_path="builds/v0.14.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.13.1.0" -application/product_version="0.13.1.0" +application/file_version="0.14.0.0" +application/product_version="0.14.0.0" application/company_name="Woofmeow" application/product_name="NETfishing" -application/file_description="NETfishing v0.13.1-alpha" +application/file_description="NETfishing v0.14.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.13.1-alpha/linux-arm64/NETfishing.arm64" +export_path="builds/v0.14.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.13.1-alpha/macos/NETfishing.zip" +export_path="builds/v0.14.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.13.1" -application/version="0.13.1" +application/short_version="0.14.0" +application/version="0.14.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.13.1-alpha/server-linux-x86_64/NETfishingServer.x86_64" +export_path="builds/v0.14.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.13.1-alpha/android/NETfishing.apk" +export_path="builds/v0.14.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=130100 -version/name="v0.13.1-alpha" +version/code=140000 +version/name="v0.14.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.13.1-alpha/linux-x86_64/NETfishing.x86_64" +export_path="builds/v0.14.0-alpha/linux-x86_64/NETfishing.x86_64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" diff --git a/fish/fish_data.gd b/fish/fish_data.gd index 2696d4b..46d4675 100644 --- a/fish/fish_data.gd +++ b/fish/fish_data.gd @@ -36,6 +36,12 @@ enum LogbookSection { SHELLFISH, } +enum CreatureGroup { + FISH, + INSECT, + SHELLFISH, +} + @export var id: StringName @export var display_name: String @export_category("Developer Catalog") @@ -94,6 +100,14 @@ func is_fishable() -> bool: return is_selectable() and collection_method == CollectionMethod.FISHING +func get_creature_group() -> CreatureGroup: + if logbook_section == LogbookSection.SHELLFISH: + return CreatureGroup.SHELLFISH + if collection_method == CollectionMethod.FISHING: + return CreatureGroup.FISH + return CreatureGroup.INSECT + + func get_habitat_label() -> String: if not habitat_label.strip_edges().is_empty(): return habitat_label.strip_edges().to_lower() diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index e2fdef0..389d40b 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -111,7 +111,9 @@ const BITE_QUICK_PROBABILITY: float = 0.30 const BITE_TYPICAL_PROBABILITY: float = 0.65 const BITE_LONG_PROBABILITY: float = 0.04 const NETWORK_INPUT_RESEND_INTERVAL_SECONDS: float = 0.1 +const ALTERNATE_REEL_ACTION: StringName = &"reel_alternate" @export_range(0.1, 10.0, 0.1) var cooldown_duration: float = 1.0 +@export_range(0.1, 5.0, 0.05) var minimum_showcase_duration: float = 0.8 @export_category("Selection") @export_range(0.0, 10.0, 0.05) var undiscovered_weight_multiplier: float = 1.5 @@ -172,6 +174,7 @@ var _selected_fish: FishDataType var _pending_catch: FishCatchType var _showcase_ready: bool = false var _put_away_press_armed: bool = false +var _showcase_input_lock_remaining: float = 0.0 var _showcase_restore_generation: int = 0 var _showcase_outcome_completed: bool = false var _network_auto_click_accumulator: float = 0.0 @@ -444,6 +447,7 @@ func _secure_showcase_catch_for_recovery() -> void: _pending_catch = null _showcase_ready = false _put_away_press_armed = false + _showcase_input_lock_remaining = 0.0 showcase_changed.emit("", "", 0.0, 0, false) if _active_player != null: _active_player.end_catch_showcase(Callable(), true) @@ -473,14 +477,21 @@ func _exit_tree() -> void: _selected_fish = null _showcase_ready = false _put_away_press_armed = false + _showcase_input_lock_remaining = 0.0 func _process(delta: float) -> void: if state == FishingState.READY and not Input.is_action_pressed("fish_primary"): _new_cast_press_armed = true + if state == FishingState.SHOWING_CATCH: + _showcase_input_lock_remaining = maxf( + _showcase_input_lock_remaining - delta, + 0.0, + ) if ( state == FishingState.SHOWING_CATCH and _showcase_ready + and _showcase_input_lock_remaining <= 0.0 and not Input.is_action_pressed("fish_primary") ): _put_away_press_armed = true @@ -501,7 +512,7 @@ func _process(delta: float) -> void: _get_effective_reel_speed(), _get_effective_barrier_damage() ) - if not Input.is_action_pressed("fish_primary"): + if not _is_reel_input_pressed(): _catch_controller.set_reel_input(false) else: _resend_network_input_state(delta) @@ -521,7 +532,11 @@ func _unhandled_input(event: InputEvent) -> void: return if _local_player == null or not _local_player.is_local_control_enabled(): return - if not event.is_action("fish_primary"): + var primary_action: bool = event.is_action("fish_primary") + var alternate_reel: bool = event.is_action(ALTERNATE_REEL_ACTION) + if not primary_action and not ( + alternate_reel and state == FishingState.FIGHTING + ): return var selected_item: ItemDataType = _get_active_item() if ( @@ -582,7 +597,11 @@ func _unhandled_input(event: InputEvent) -> void: FishingPresentationType.LineMode.TAUT ) FishingState.SHOWING_CATCH: - if _showcase_ready and _put_away_press_armed: + if ( + _showcase_ready + and _showcase_input_lock_remaining <= 0.0 + and _put_away_press_armed + ): _put_away_catch() else: return @@ -619,15 +638,23 @@ func _unhandled_input(event: InputEvent) -> void: _presentation.set_line_mode(FishingPresentationType.LineMode.SLACK) get_viewport().set_input_as_handled() elif state == FishingState.FIGHTING: + var reel_input_held: bool = _is_reel_input_pressed() if _network_fishing != null: - _network_primary_input_held = false + _network_primary_input_held = reel_input_held _network_input_resend_elapsed = 0.0 - _network_fishing.submit_local_input(false, false) + _network_fishing.submit_local_input(reel_input_held, false) else: - _catch_controller.set_reel_input(false) + _catch_controller.set_reel_input(reel_input_held) get_viewport().set_input_as_handled() +func _is_reel_input_pressed() -> bool: + return ( + Input.is_action_pressed("fish_primary") + or Input.is_action_pressed(ALTERNATE_REEL_ACTION) + ) + + func _begin_aiming(player: PlayerType) -> void: if state != FishingState.READY or _active_player != null: return @@ -756,6 +783,17 @@ func is_returning() -> bool: return state == FishingState.RETURNING +func is_fishing_sequence_active() -> bool: + return state in [ + FishingState.AIMING_CAST, + FishingState.CASTING, + FishingState.WAITING_FOR_BITE, + FishingState.FIGHTING, + FishingState.SHOWING_CATCH, + FishingState.RETURNING, + ] + + func refresh_active_item_status() -> void: if state == FishingState.READY and has_active_fishing_rod(): status_changed.emit("") @@ -780,6 +818,7 @@ func present_external_catch(fish_catch: FishCatchType) -> bool: _showcase_ready = true _showcase_outcome_completed = true _put_away_press_armed = false + _showcase_input_lock_remaining = minimum_showcase_duration _active_player.begin_catch_showcase(fish_catch) showcase_changed.emit( fish_catch.fish.display_name, @@ -1112,7 +1151,7 @@ func _activate_bite(confirmation_override: bool = false) -> void: _pending_catch.quality, ) _catch_controller.set_reel_input( - Input.is_action_pressed("fish_primary") + _is_reel_input_pressed() ) _presentation.show_bite() @@ -1197,6 +1236,7 @@ func _on_catch_completed() -> void: _showcase_ready = false _showcase_outcome_completed = false _put_away_press_armed = false + _showcase_input_lock_remaining = 0.0 _catch_controller.reset() _presentation.set_line_mode(FishingPresentationType.LineMode.TAUT) _presentation.play_outcome(&"catch") @@ -1232,6 +1272,7 @@ func _on_outcome_completed(outcome: StringName) -> void: return _showcase_outcome_completed = true _showcase_ready = true + _showcase_input_lock_remaining = minimum_showcase_duration _active_player.begin_catch_showcase(_pending_catch) showcase_changed.emit( _pending_catch.fish.display_name, @@ -1265,6 +1306,7 @@ func _put_away_catch() -> void: _showcase_ready = false _showcase_outcome_completed = false _put_away_press_armed = false + _showcase_input_lock_remaining = 0.0 showcase_changed.emit("", "", 0.0, 0, false) _showcase_restore_generation += 1 var restore_generation: int = _showcase_restore_generation @@ -1338,6 +1380,7 @@ func _cleanup_attempt( _showcase_ready = false _showcase_outcome_completed = false _put_away_press_armed = false + _showcase_input_lock_remaining = 0.0 showcase_changed.emit("", "", 0.0, 0, false) _catch_controller.reset() state = FishingState.RETURNING @@ -1387,6 +1430,7 @@ func _finalize_attempt_cleanup( _showcase_ready = false _showcase_outcome_completed = false _put_away_press_armed = false + _showcase_input_lock_remaining = 0.0 showcase_changed.emit("", "", 0.0, 0, false) _catch_controller.reset() _presentation.cleanup() @@ -1724,7 +1768,7 @@ func _on_network_bite_started(_attempt_id: String) -> void: if _active_player != null: _active_player.set_fighting_visual(true) _withdrawal_input_held = false - _network_primary_input_held = Input.is_action_pressed("fish_primary") + _network_primary_input_held = _is_reel_input_pressed() _network_input_resend_elapsed = 0.0 _network_auto_click_accumulator = 0.0 _network_active_barrier_index = -1 @@ -1796,7 +1840,7 @@ func _on_network_fishing_snapshot(snapshot: Dictionary) -> void: func _update_network_auto_click(delta: float) -> void: if ( not _catch_controller.auto_click_enabled - or not Input.is_action_pressed("fish_primary") + or not _is_reel_input_pressed() or _network_active_barrier_index < 0 ): _network_auto_click_accumulator = 0.0 @@ -1823,6 +1867,7 @@ func _on_network_catch_received(fish_catch: FishCatchType) -> void: _showcase_ready = false _showcase_outcome_completed = false _put_away_press_armed = false + _showcase_input_lock_remaining = 0.0 catch_display_changed.emit( 0.0, 0.0, PackedFloat32Array(), PackedInt32Array(), PackedInt32Array(), -1, false diff --git a/items/catalog/fishing_net.tres b/items/catalog/fishing_net.tres new file mode 100644 index 0000000..04b1919 --- /dev/null +++ b/items/catalog/fishing_net.tres @@ -0,0 +1,16 @@ +[gd_resource type="Resource" script_class="ItemData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://items/item_data.gd" id="1_item"] + +[resource] +script = ExtResource("1_item") +item_id = &"fishing_net" +display_name = "fishing net" +description = "a placeable water net reserved for a future passive fishing system." +active = true +category = 1 +stackable = false +max_stack = 1 +usable = false +equippable = false +hotbar_allowed = false diff --git a/items/catalog/item_catalog.tres b/items/catalog/item_catalog.tres index 1c006f4..63ef3ea 100644 --- a/items/catalog/item_catalog.tres +++ b/items/catalog/item_catalog.tres @@ -1,4 +1,4 @@ -[gd_resource type="Resource" script_class="ItemCatalog" load_steps=40 format=3] +[gd_resource type="Resource" script_class="ItemCatalog" load_steps=41 format=3] [ext_resource type="Script" path="res://items/item_catalog.gd" id="1_script"] [ext_resource type="Resource" path="res://items/catalog/basic_fishing_rod.tres" id="2_rod"] @@ -39,7 +39,8 @@ [ext_resource type="Resource" path="res://items/catalog/magnet.tres" id="38_magnet"] [ext_resource type="Resource" path="res://items/catalog/crab_net.tres" id="39_crab_net"] [ext_resource type="Resource" path="res://items/catalog/standard_shovel.tres" id="40_shovel"] +[ext_resource type="Resource" path="res://items/catalog/fishing_net.tres" id="41_fishing_net"] [resource] script = ExtResource("1_script") -items = [ExtResource("2_rod"), ExtResource("3_coffee"), ExtResource("4_energy"), ExtResource("5_snack"), ExtResource("6_finder"), ExtResource("8_art_kit"), ExtResource("9_worms"), ExtResource("10_snails"), ExtResource("11_shrimp"), ExtResource("12_squid"), ExtResource("14_sardine"), ExtResource("13_anchovy"), ExtResource("15_roe"), ExtResource("16_standby"), ExtResource("17_batteries"), ExtResource("18_cardboard"), ExtResource("19_pond"), ExtResource("20_river"), ExtResource("21_lake"), ExtResource("22_salt"), ExtResource("23_whisker"), ExtResource("24_reef"), ExtResource("25_moonbeam"), ExtResource("26_sun"), ExtResource("27_rain"), ExtResource("28_fog"), ExtResource("29_guide"), ExtResource("30_small"), ExtResource("31_heavy"), ExtResource("32_rocket"), ExtResource("33_lucky"), ExtResource("34_showboat"), ExtResource("35_deep"), ExtResource("36_oddity"), ExtResource("37_aurora"), ExtResource("38_magnet"), ExtResource("39_crab_net"), ExtResource("40_shovel")] +items = [ExtResource("2_rod"), ExtResource("3_coffee"), ExtResource("4_energy"), ExtResource("5_snack"), ExtResource("6_finder"), ExtResource("8_art_kit"), ExtResource("9_worms"), ExtResource("10_snails"), ExtResource("11_shrimp"), ExtResource("12_squid"), ExtResource("14_sardine"), ExtResource("13_anchovy"), ExtResource("15_roe"), ExtResource("16_standby"), ExtResource("17_batteries"), ExtResource("18_cardboard"), ExtResource("19_pond"), ExtResource("20_river"), ExtResource("21_lake"), ExtResource("22_salt"), ExtResource("23_whisker"), ExtResource("24_reef"), ExtResource("25_moonbeam"), ExtResource("26_sun"), ExtResource("27_rain"), ExtResource("28_fog"), ExtResource("29_guide"), ExtResource("30_small"), ExtResource("31_heavy"), ExtResource("32_rocket"), ExtResource("33_lucky"), ExtResource("34_showboat"), ExtResource("35_deep"), ExtResource("36_oddity"), ExtResource("37_aurora"), ExtResource("38_magnet"), ExtResource("39_crab_net"), ExtResource("40_shovel"), ExtResource("41_fishing_net")] diff --git a/jobs/job_catalog.gd b/jobs/job_catalog.gd index 754263d..eb3ea02 100644 --- a/jobs/job_catalog.gd +++ b/jobs/job_catalog.gd @@ -14,6 +14,9 @@ enum Kind { REACH_LEVEL, DISCOVER_SPECIES, MASTER_QUALITIES, + CATCH_CREATURE_GROUP, + DISCOVER_CREATURE_GROUP, + MASTER_CREATURE_GROUP, } const DAILY_JOB_COUNT: int = 4 @@ -111,6 +114,28 @@ static func generate_daily_jobs( 50, {"fish_id": String(selected.id)}, )) + if _has_creature_group(candidates, FishDataType.CreatureGroup.INSECT): + optional.append(_job( + "insect_roundup", + "bug hunt", + "catch 3 insects", + Kind.CATCH_CREATURE_GROUP, + 3, + 40, + 50, + {"creature_group": int(FishDataType.CreatureGroup.INSECT)}, + )) + if _has_creature_group(candidates, FishDataType.CreatureGroup.SHELLFISH): + optional.append(_job( + "shellfish_search", + "shore patrol", + "catch 3 shellfish", + Kind.CATCH_CREATURE_GROUP, + 3, + 45, + 50, + {"creature_group": int(FishDataType.CreatureGroup.SHELLFISH)}, + )) _shuffle(optional, rng) while jobs.size() < DAILY_JOB_COUNT and not optional.is_empty(): jobs.append(optional.pop_back()) @@ -172,6 +197,8 @@ static func generate_weather_schedule( static func lifetime_chains( registered_species_count: int, + registered_insect_count: int = 0, + registered_shellfish_count: int = 0, ) -> Array[Dictionary]: var chains: Array[Dictionary] = [ _chain("catch", Kind.CATCH_TOTAL, [100, 1000, 5000], [ @@ -197,15 +224,33 @@ static func lifetime_chains( [registered_species_count], [[1500, 2000]], )) + _append_creature_lifetime_chains( + chains, + "insect", + FishDataType.CreatureGroup.INSECT, + registered_insect_count, + ) + _append_creature_lifetime_chains( + chains, + "shellfish", + FishDataType.CreatureGroup.SHELLFISH, + registered_shellfish_count, + ) return chains static func visible_lifetime_jobs( registered_species_count: int, claimed_ids: Array[String], + registered_insect_count: int = 0, + registered_shellfish_count: int = 0, ) -> Array[Dictionary]: var visible: Array[Dictionary] = [] - for chain: Dictionary in lifetime_chains(registered_species_count): + for chain: Dictionary in lifetime_chains( + registered_species_count, + registered_insect_count, + registered_shellfish_count, + ): var targets: Array = chain.get("targets", []) var rewards: Array = chain.get("rewards", []) for tier_index: int in targets.size(): @@ -216,6 +261,9 @@ static func visible_lifetime_jobs( if job_id in claimed_ids: continue var reward: Array = rewards[tier_index] + var extra: Dictionary = {} + if chain.has("creature_group"): + extra["creature_group"] = int(chain["creature_group"]) visible.append(_job( job_id, _lifetime_title(str(chain.get("id", "")), int(targets[tier_index])), @@ -226,6 +274,7 @@ static func visible_lifetime_jobs( int(targets[tier_index]), int(reward[0]), int(reward[1]), + extra, )) break return visible @@ -256,10 +305,10 @@ static func is_valid_job(value: Variant) -> bool: and typeof(job.get("description")) == TYPE_STRING and str(job["description"]).length() <= 160 and is_bounded_integer( - job.get("kind"), Kind.CATCH_TOTAL, Kind.MASTER_QUALITIES + job.get("kind"), Kind.CATCH_TOTAL, Kind.MASTER_CREATURE_GROUP ) and kind >= Kind.CATCH_TOTAL - and kind <= Kind.MASTER_QUALITIES + and kind <= Kind.MASTER_CREATURE_GROUP and is_bounded_integer(job.get("target"), 1, 1000000000) and is_bounded_integer( job.get("fish_coin"), 0, MAX_JOB_REWARD_COINS @@ -318,6 +367,12 @@ static func is_valid_job(value: Variant) -> bool: and not str(job.get("fish_id", "")).is_empty() and str(job.get("fish_id", "")).length() <= 96 ) + Kind.CATCH_CREATURE_GROUP, Kind.DISCOVER_CREATURE_GROUP, Kind.MASTER_CREATURE_GROUP: + return is_bounded_integer( + job.get("creature_group"), + FishDataType.CreatureGroup.FISH, + FishDataType.CreatureGroup.SHELLFISH, + ) return true @@ -401,13 +456,16 @@ static func _chain( kind: Kind, targets: Array[int], rewards: Array, + extra: Dictionary = {}, ) -> Dictionary: - return { + var result: Dictionary = { "id": id, "kind": int(kind), "targets": targets, "rewards": rewards, } + result.merge(extra, true) + return result static func _lifetime_title(chain_id: String, target: int) -> String: @@ -422,6 +480,18 @@ static func _lifetime_title(chain_id: String, target: int) -> String: return "complete the catalog" "master": return "quality master" + "insect_catch": + return "seasoned bug catcher" + "insect_discover": + return "complete the insect catalog" + "insect_master": + return "insect quality master" + "shellfish_catch": + return "seasoned beachcomber" + "shellfish_discover": + return "complete the shellfish catalog" + "shellfish_master": + return "shellfish quality master" return "long-term job" @@ -437,9 +507,67 @@ static func _lifetime_description(chain_id: String, target: int) -> String: return "discover all %d cataloged fish" % target "master": return "collect every quality of all %d fish" % target + "insect_catch": + return "catch %d insects" % target + "insect_discover": + return "discover all %d cataloged insects" % target + "insect_master": + return "collect every quality of all %d insects" % target + "shellfish_catch": + return "catch %d shellfish" % target + "shellfish_discover": + return "discover all %d cataloged shellfish" % target + "shellfish_master": + return "collect every quality of all %d shellfish" % target return "keep fishing" +static func _has_creature_group( + candidates: Array[FishDataType], + creature_group: FishDataType.CreatureGroup, +) -> bool: + for fish: FishDataType in candidates: + if ( + fish != null + and fish.is_selectable() + and fish.get_creature_group() == creature_group + ): + return true + return false + + +static func _append_creature_lifetime_chains( + chains: Array[Dictionary], + id_prefix: String, + creature_group: FishDataType.CreatureGroup, + registered_count: int, +) -> void: + if registered_count <= 0: + return + var group_data := {"creature_group": int(creature_group)} + chains.append(_chain( + "%s_catch" % id_prefix, + Kind.CATCH_CREATURE_GROUP, + [10, 50, 250], + [[75, 100], [250, 350], [900, 1100]], + group_data, + )) + chains.append(_chain( + "%s_discover" % id_prefix, + Kind.DISCOVER_CREATURE_GROUP, + [registered_count], + [[250, 350]], + group_data, + )) + chains.append(_chain( + "%s_master" % id_prefix, + Kind.MASTER_CREATURE_GROUP, + [registered_count], + [[750, 1000]], + group_data, + )) + + static func _shuffle(values: Array[Dictionary], rng: RandomNumberGenerator) -> void: for index: int in range(values.size() - 1, 0, -1): var swap_index: int = rng.randi_range(0, index) diff --git a/jobs/player_job_service.gd b/jobs/player_job_service.gd index 7b19cc6..7ede866 100644 --- a/jobs/player_job_service.gd +++ b/jobs/player_job_service.gd @@ -25,6 +25,7 @@ var _session: NetworkSession var _save_manager: PlayerSaveManager var _network_fishing: NetworkFishingService var _network_sale: NetworkSaleService +var _network_world_spawns: NetworkWorldSpawnService var _progression_ready: bool = false var _host_board: Dictionary = {} @@ -35,6 +36,8 @@ var _daily_completions: Dictionary[String, int] = {} var _pending_rewards: Array[Dictionary] = [] var _lifetime_claimed: Array[String] = [] var _total_catches: int = 0 +var _total_insects: int = 0 +var _total_shellfish: int = 0 var _total_sold: int = 0 var _daily_clock_hour: float = WorldTimeService.DEFAULT_START_HOUR @@ -65,9 +68,11 @@ func setup( func bind_authoritative_services( network_fishing: NetworkFishingService, network_sale: NetworkSaleService, + network_world_spawns: NetworkWorldSpawnService, ) -> void: _network_fishing = network_fishing _network_sale = network_sale + _network_world_spawns = network_world_spawns if not _network_fishing.local_catch_received.is_connected( _on_authoritative_catch ): @@ -78,6 +83,12 @@ func bind_authoritative_services( _network_sale.local_sale_finished.connect( _on_authoritative_sale_finished ) + if not _network_world_spawns.local_capture_received.is_connected( + _on_authoritative_catch + ): + _network_world_spawns.local_capture_received.connect( + _on_authoritative_catch + ) func set_save_manager(save_manager: PlayerSaveManager) -> void: @@ -123,7 +134,10 @@ func get_daily_jobs() -> Array[Dictionary]: func get_lifetime_jobs() -> Array[Dictionary]: var result: Array[Dictionary] = JobCatalog.visible_lifetime_jobs( - _registered_species_count(), _lifetime_claimed + _registered_species_count(), + _lifetime_claimed, + _registered_creature_count(FishDataType.CreatureGroup.INSECT), + _registered_creature_count(FishDataType.CreatureGroup.SHELLFISH), ) for job: Dictionary in result: var progress: int = _lifetime_progress(job) @@ -236,6 +250,8 @@ func to_save_data() -> Dictionary: "lifetime_claimed": _lifetime_claimed.duplicate(), "statistics": { "fish_caught": _total_catches, + "insects_caught": _total_insects, + "shellfish_caught": _total_shellfish, "fish_sold": _total_sold, }, } @@ -294,6 +310,8 @@ func restore_from_save_data(data: Dictionary) -> bool: _lifetime_claimed.append(str(value)) var statistics: Dictionary = data.get("statistics", {}) _total_catches = int(statistics.get("fish_caught", 0)) + _total_insects = int(statistics.get("insects_caught", 0)) + _total_shellfish = int(statistics.get("shellfish_caught", 0)) _total_sold = int(statistics.get("fish_sold", 0)) if _session != null and _session.is_host(): _active_board = _host_board.duplicate(true) @@ -318,6 +336,8 @@ func reset_to_defaults() -> void: _pending_rewards.clear() _lifetime_claimed.clear() _total_catches = 0 + _total_insects = 0 + _total_shellfish = 0 _total_sold = 0 _daily_clock_hour = WorldTimeService.DEFAULT_START_HOUR changed.emit() @@ -333,7 +353,12 @@ static func default_save_data() -> Dictionary: "daily_clock_hour": WorldTimeService.DEFAULT_START_HOUR, "pending_rewards": [], "lifetime_claimed": [], - "statistics": {"fish_caught": 0, "fish_sold": 0}, + "statistics": { + "fish_caught": 0, + "insects_caught": 0, + "shellfish_caught": 0, + "fish_sold": 0, + }, } @@ -456,6 +481,12 @@ static func validate_save_data(value: Variant) -> bool: JobCatalog.is_bounded_integer( statistics.get("fish_caught"), 0, MAX_PROGRESS_VALUE ) + and JobCatalog.is_bounded_integer( + statistics.get("insects_caught", 0), 0, MAX_PROGRESS_VALUE + ) + and JobCatalog.is_bounded_integer( + statistics.get("shellfish_caught", 0), 0, MAX_PROGRESS_VALUE + ) and JobCatalog.is_bounded_integer( statistics.get("fish_sold"), 0, MAX_PROGRESS_VALUE ) @@ -514,7 +545,7 @@ func _generate_host_board(cycle: int) -> void: var candidates: Array[FishDataType] = [] if _catalog != null: for fish: FishDataType in _catalog.candidates: - if fish != null and fish.is_fishable(): + if fish != null and fish.is_selectable(): candidates.append(fish) var schedule_anchor_index: int = _current_weather_segment() var allow_weather_jobs: bool = ( @@ -608,7 +639,13 @@ func _on_session_state_changed(state: NetworkSession.State) -> void: func _on_authoritative_catch(fish_catch: FishCatchType) -> void: if not _progression_ready or fish_catch == null or not fish_catch.is_valid(): return - _total_catches = mini(_total_catches + 1, MAX_PROGRESS_VALUE) + match fish_catch.fish.get_creature_group(): + FishDataType.CreatureGroup.FISH: + _total_catches = mini(_total_catches + 1, MAX_PROGRESS_VALUE) + FishDataType.CreatureGroup.INSECT: + _total_insects = mini(_total_insects + 1, MAX_PROGRESS_VALUE) + FishDataType.CreatureGroup.SHELLFISH: + _total_shellfish = mini(_total_shellfish + 1, MAX_PROGRESS_VALUE) _update_daily_for_catch(fish_catch) changed.emit() @@ -630,23 +667,40 @@ func _on_authoritative_sale_finished( func _update_daily_for_catch(fish_catch: FishCatchType) -> void: + var creature_group: int = int(fish_catch.fish.get_creature_group()) + var is_fish: bool = creature_group == FishDataType.CreatureGroup.FISH for job: Dictionary in get_daily_jobs(): var matches: bool = false match int(job.get("kind", -1)): JobCatalog.Kind.CATCH_TOTAL: - matches = true + matches = is_fish JobCatalog.Kind.CATCH_WATER: - matches = fish_catch.fish.is_allowed_in_water( + matches = is_fish and fish_catch.fish.is_allowed_in_water( int(job.get("water_type", WaterType.Type.OTHER)) as WaterType.Type ) JobCatalog.Kind.CATCH_QUALITY: - matches = fish_catch.quality >= int(job.get("minimum_quality", 0)) + matches = ( + is_fish + and fish_catch.quality >= int(job.get("minimum_quality", 0)) + ) JobCatalog.Kind.CATCH_PHASE: - matches = int(_world_time.get_phase()) == int(job.get("phase", -1)) + matches = ( + is_fish + and int(_world_time.get_phase()) == int(job.get("phase", -1)) + ) JobCatalog.Kind.CATCH_WEATHER: - matches = int(_world_weather.get_weather()) == int(job.get("weather", -1)) + matches = ( + is_fish + and int(_world_weather.get_weather()) + == int(job.get("weather", -1)) + ) JobCatalog.Kind.CATCH_SPECIES: - matches = String(fish_catch.fish_id) == str(job.get("fish_id", "")) + matches = ( + is_fish + and String(fish_catch.fish_id) == str(job.get("fish_id", "")) + ) + JobCatalog.Kind.CATCH_CREATURE_GROUP: + matches = creature_group == int(job.get("creature_group", -1)) if matches: _advance_daily(job, 1) @@ -741,26 +795,74 @@ func _lifetime_progress(job: Dictionary) -> int: JobCatalog.Kind.REACH_LEVEL: return _experience.get_level() if _experience != null else 0 JobCatalog.Kind.DISCOVER_SPECIES: - return _collection.get_discovered_ids().size() if _collection != null else 0 + return _collection_progress( + FishDataType.CreatureGroup.FISH, false + ) JobCatalog.Kind.MASTER_QUALITIES: - var mastered: int = 0 - if _collection != null and _catalog != null: - for fish: FishDataType in _catalog.candidates: - if fish != null and _collection.has_mastered(fish.id): - mastered += 1 - return mastered + return _collection_progress( + FishDataType.CreatureGroup.FISH, true + ) + JobCatalog.Kind.CATCH_CREATURE_GROUP: + match int(job.get("creature_group", -1)): + FishDataType.CreatureGroup.INSECT: + return _total_insects + FishDataType.CreatureGroup.SHELLFISH: + return _total_shellfish + JobCatalog.Kind.DISCOVER_CREATURE_GROUP: + return _collection_progress( + int(job.get("creature_group", -1)) as FishDataType.CreatureGroup, + false, + ) + JobCatalog.Kind.MASTER_CREATURE_GROUP: + return _collection_progress( + int(job.get("creature_group", -1)) as FishDataType.CreatureGroup, + true, + ) return 0 func _registered_species_count() -> int: + return _registered_creature_count(FishDataType.CreatureGroup.FISH) + + +func _registered_creature_count( + creature_group: FishDataType.CreatureGroup, +) -> int: var count: int = 0 if _catalog != null: for fish: FishDataType in _catalog.candidates: - if fish != null and fish.is_fishable(): + if ( + fish != null + and fish.is_selectable() + and fish.get_creature_group() == creature_group + ): count += 1 return count +func _collection_progress( + creature_group: FishDataType.CreatureGroup, + require_mastery: bool, +) -> int: + var progress: int = 0 + if _collection == null or _catalog == null: + return progress + for fish: FishDataType in _catalog.candidates: + if ( + fish == null + or not fish.is_selectable() + or fish.get_creature_group() != creature_group + ): + continue + if ( + _collection.has_mastered(fish.id) + if require_mastery + else _collection.has_discovered(fish.id) + ): + progress += 1 + return progress + + func _expire_incomplete_daily_jobs() -> void: _daily_progress.clear() _daily_completions.clear() @@ -795,7 +897,7 @@ func _matches_canonical_board(board: Dictionary) -> bool: var candidates: Array[FishDataType] = [] if _catalog != null: for fish: FishDataType in _catalog.candidates: - if fish != null and fish.is_fishable(): + if fish != null and fish.is_selectable(): candidates.append(fish) var anchor_index: int = int(board.get("schedule_anchor_index", -1)) var allow_weather_jobs: bool = ( diff --git a/main/main.gd b/main/main.gd index 2db40ac..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" ) @@ -124,6 +125,7 @@ const TITLE_MUSIC_SILENCE_DB: float = -80.0 const TITLE_MUSIC_PATH: String = ( "res://audio/music/title/as_in_four_wolves.ogg" ) +const NEW_GAME_MUSIC_PATH: String = "res://audio/music/world/tulips.wav" const DUSK_MUSIC_PATH: String = "res://audio/music/world/craft.mp3" const TIME_CROSSING_EPSILON_HOURS: float = 0.000001 const PLAYER_MENU_PATTERN_SCALE: float = 0.85 @@ -138,6 +140,7 @@ const SHOP_PATTERN_SCALE: float = 1.75 @export_category("Title Music") @export_range(-40.0, 0.0, 0.5) var title_music_volume_db: float = -6.0 @export_range(0.0, 10.0, 0.05) var title_music_fade_out_seconds: float = 5.0 +@export_range(-40.0, 0.0, 0.5) var new_game_music_volume_db: float = -6.0 @onready var _test_world: TestWorldType = $TestWorld @onready var _player: PlayerType = %Player @@ -154,6 +157,7 @@ const SHOP_PATTERN_SCALE: float = 1.75 ) @onready var _interface_fonts: InterfaceFontController = %InterfaceFontController @onready var _title_music: AudioStreamPlayer = %TitleMusic +@onready var _new_game_music: AudioStreamPlayer = %NewGameMusic @onready var _dusk_music: AudioStreamPlayer = %DuskMusic @onready var _ui_pixelation: UIPixelationPresenterType = %UIPresentation @onready var _pixelation_reset: PixelationResetOverlayType = ( @@ -356,6 +360,7 @@ func _configure_presentation_performance_profile(light_profile: bool) -> void: func _configure_audio_performance_profile(light_profile: bool) -> void: _title_music.stream = _load_optional_audio_stream(TITLE_MUSIC_PATH) + _new_game_music.stream = _load_optional_audio_stream(NEW_GAME_MUSIC_PATH) _dusk_music.stream = _load_optional_audio_stream(DUSK_MUSIC_PATH) if light_profile: _shoreline_ambience.set_audio_enabled(false) @@ -409,10 +414,25 @@ func _start_dedicated_server() -> void: ): _fail_dedicated_server("The server operator list is invalid.") return - if not _apply_generated_world(config.world_seed, false): + if not _network_chat.configure_dedicated_history( + config.chat_logging, + config.chat_log_path, + ): + _fail_dedicated_server( + "The dedicated-server chat privacy configuration is invalid." + ) + return + 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() @@ -636,6 +656,7 @@ func _initialize_application(dedicated: bool) -> void: _world_time, _world_weather, _player_jobs, + _player, ) _player_jobs.set_save_manager(_save_manager) _save_manager.set_autosave_enabled(false) @@ -747,7 +768,11 @@ func _initialize_application(dedicated: bool) -> void: _asset_reservations, _player.inventory_layout, ) - _player_jobs.bind_authoritative_services(_network_fishing, _network_sale) + _player_jobs.bind_authoritative_services( + _network_fishing, + _network_sale, + _network_world_spawns, + ) _network_shop.setup( _network_session, _player_spawn_service, @@ -824,6 +849,7 @@ func _initialize_application(dedicated: bool) -> void: ) _game_ui.setup_data_and_identity( _data_root, + _save_manager, _identity_backups, _player_identity, _host_identity, @@ -1571,7 +1597,10 @@ func _on_natural_time_advanced(advanced_hours: float) -> void: ) ): _dusk_music_played_for_natural_day = true - if _dusk_music.stream != null: + if ( + _dusk_music.stream != null + and not _new_game_music.playing + ): _dusk_music.play(0.0) @@ -1672,26 +1701,34 @@ 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 - if not _apply_generated_world(world_seed, true): + _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 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(): + _show_title_music(true) 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) _game_ui.get_title_screen().report_network_error( "Could not initialize local progression. Existing data was preserved where possible." ) @@ -1707,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(): @@ -1740,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: @@ -1950,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() @@ -1964,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 @@ -1974,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, @@ -2001,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" @@ -2120,6 +2225,7 @@ func _on_quit_requested() -> void: func _show_title_music(restart_from_beginning: bool = false) -> void: + _new_game_music.stop() _title_music_requested = true _replace_title_music_transition() if _title_music.stream == null: @@ -2131,6 +2237,19 @@ func _show_title_music(restart_from_beginning: bool = false) -> void: _title_music.play(0.0) +func _start_new_game_music() -> void: + if _new_game_music.stream == null: + return + _title_music_requested = false + _replace_title_music_transition() + _title_music.stop() + _title_music.volume_db = title_music_volume_db + _dusk_music.stop() + _new_game_music.stop() + _new_game_music.volume_db = new_game_music_volume_db + _new_game_music.play(0.0) + + func _fade_out_title_music(on_complete: Callable = Callable()) -> void: _title_music_requested = false var generation: int = _replace_title_music_transition() @@ -2196,6 +2315,9 @@ func _exit_tree() -> void: if is_instance_valid(_title_music): _title_music.stop() _title_music.stream = null + if is_instance_valid(_new_game_music): + _new_game_music.stop() + _new_game_music.stream = null if is_instance_valid(_dusk_music): _dusk_music.stop() _dusk_music.stream = null diff --git a/main/main.tscn b/main/main.tscn index e8dba76..b9ad6fb 100644 --- a/main/main.tscn +++ b/main/main.tscn @@ -361,6 +361,10 @@ unique_name_in_owner = true volume_db = -80.0 bus = &"Music" +[node name="NewGameMusic" type="AudioStreamPlayer" parent="."] +unique_name_in_owner = true +bus = &"Music" + [node name="DuskMusic" type="AudioStreamPlayer" parent="." unique_id=992744241] unique_name_in_owner = true bus = &"Music" diff --git a/network/network_chat_protocol.gd b/network/network_chat_protocol.gd index ef7294d..2e91432 100644 --- a/network/network_chat_protocol.gd +++ b/network/network_chat_protocol.gd @@ -9,6 +9,13 @@ const MAX_HISTORY: int = 100 const LATE_JOIN_HISTORY: int = 50 const MAX_ID_LENGTH: int = 96 +static var _invisible_edge_pattern: RegEx = RegEx.create_from_string( + "^[\\s\\p{Z}\\p{Cf}\\x{2800}]+|[\\s\\p{Z}\\p{Cf}\\x{2800}]+$" +) +static var _visible_content_pattern: RegEx = RegEx.create_from_string( + "[^\\s\\p{Z}\\p{Cf}\\x{2800}]" +) + enum Kind { PLAYER, SYSTEM } @@ -16,8 +23,13 @@ static func sanitize_body(value: Variant) -> String: if typeof(value) != TYPE_STRING: return "" var result: String = str(value).replace("\r", " ").replace("\n", " ") - result = result.replace("\t", " ").strip_edges() - if result.is_empty() or result.length() > MAX_VISIBLE_CHARACTERS: + result = result.replace("\t", " ") + result = _invisible_edge_pattern.sub(result, "", true) + if ( + result.is_empty() + or _visible_content_pattern.search(result) == null + or result.length() > MAX_VISIBLE_CHARACTERS + ): return "" if result.to_utf8_buffer().size() > MAX_UTF8_BYTES: return "" diff --git a/network/network_chat_service.gd b/network/network_chat_service.gd index 0502a38..b24d558 100644 --- a/network/network_chat_service.gd +++ b/network/network_chat_service.gd @@ -4,7 +4,10 @@ extends Node const BURST_COUNT: int = 3 const WINDOW_COUNT: int = 5 const WINDOW_SECONDS: float = 10.0 -const CALL_COOLDOWN_MILLISECONDS: int = 180 +const CALL_COOLDOWN_MILLISECONDS: int = 90 +const DEDICATED_CHAT_WARNING: String = ( + "Privacy notice: This dedicated server records and retains chat messages." +) const CALL_PITCH_VARIANTS: Array[float] = [ 0.96, 1.03, @@ -38,6 +41,32 @@ var _call_variant_indices: Dictionary[int, int] = {} var _sequence: int = 0 var _peer_names: Dictionary[int, String] = {} var _relationships: PlayerRelationshipStore +var _dedicated_history_enabled: bool = false +var _chat_log_path: String = "" + + +func configure_dedicated_history(enabled: bool, log_path: String) -> bool: + _dedicated_history_enabled = false + _chat_log_path = "" + if not enabled: + return true + var normalized_path: String = log_path.strip_edges() + if normalized_path.is_empty() or not normalized_path.is_absolute_path(): + return false + var directory: String = normalized_path.get_base_dir() + if DirAccess.make_dir_recursive_absolute(directory) != OK: + return false + var file: FileAccess + if FileAccess.file_exists(normalized_path): + file = FileAccess.open(normalized_path, FileAccess.READ_WRITE) + else: + file = FileAccess.open(normalized_path, FileAccess.WRITE) + if file == null: + return false + file.close() + _dedicated_history_enabled = true + _chat_log_path = normalized_path + return true func setup(session: NetworkSession) -> void: @@ -288,7 +317,8 @@ func _handle_request(peer_id: int, data: Dictionary) -> void: var request_id: String = data["request_id"] var ledger: Dictionary = _request_ledgers.get(peer_id, {}) if ledger.has(request_id): - _send_message(peer_id, ledger[request_id]) + if typeof(ledger[request_id]) == TYPE_DICTIONARY: + _send_message(peer_id, ledger[request_id]) return if not _consume_rate(peer_id): _send_rejection(peer_id, "Slow down.") @@ -308,7 +338,11 @@ func _handle_request(peer_id: int, data: Dictionary) -> void: message["request_id"] = request_id message["sender_fingerprint"] = data["sender_fingerprint"] message["sender_signature"] = data["sender_signature"] - ledger[request_id] = message.duplicate(true) + ledger[request_id] = ( + message.duplicate(true) + if _should_store_host_history() + else true + ) while ledger.size() > 64: ledger.erase(ledger.keys().front()) _request_ledgers[peer_id] = ledger @@ -404,9 +438,12 @@ func _apply_message( kind == NetworkChatProtocol.Kind.PLAYER and is_sender_filtered(str(data.get("sender_fingerprint", ""))) ) - _history.append(stored_message) - while _history.size() > NetworkChatProtocol.MAX_HISTORY: - _history.pop_front() + if _should_store_local_history(): + _history.append(stored_message) + while _history.size() > NetworkChatProtocol.MAX_HISTORY: + _history.pop_front() + if _should_log_message(stored_message): + _append_chat_log(stored_message) if emit_live_signals and _message_is_visible(stored_message): message_received.emit(stored_message.duplicate(true)) if ( @@ -433,8 +470,26 @@ func _on_peer_authenticated(peer_id: int, display_name: String) -> void: if not _session.is_host(): return _peer_names[peer_id] = display_name - var start := maxi(0, _history.size() - NetworkChatProtocol.LATE_JOIN_HISTORY) - receive_chat_history.rpc_id(peer_id, _history.slice(start)) + var history: Array[Dictionary] = [] + if _dedicated_history_enabled and _session.is_dedicated_host(): + var start := maxi( + 0, + _history.size() - NetworkChatProtocol.LATE_JOIN_HISTORY, + ) + history = _history.slice(start) + # An explicit empty replacement is intentional for player-hosted rooms and + # dedicated servers without logging. It destroys any stale client scrollback + # instead of replaying another player's live-session chat on reconnect. + receive_chat_history.rpc_id(peer_id, history) + if _dedicated_history_enabled and _session.is_dedicated_host(): + # Deliver the disclosure as a live system message after scrollback. This + # raises the normal unread indicator without creating speech or audio. + _send_message(peer_id, _make_message( + NetworkChatProtocol.Kind.SYSTEM, + 0, + "", + DEDICATED_CHAT_WARNING, + )) _broadcast(_make_message( NetworkChatProtocol.Kind.SYSTEM, 0, "", "%s joined." % display_name )) @@ -494,6 +549,44 @@ func _consume_rate(peer_id: int) -> bool: return true +func _should_store_host_history() -> bool: + return ( + _session == null + or not _session.is_dedicated_host() + or _dedicated_history_enabled + ) + + +func _should_store_local_history() -> bool: + return _should_store_host_history() + + +func _should_log_message(message: Dictionary) -> bool: + return ( + _dedicated_history_enabled + and not _chat_log_path.is_empty() + and _session != null + and _session.is_dedicated_host() + and int(message.get("kind", -1)) == NetworkChatProtocol.Kind.PLAYER + ) + + +func _append_chat_log(message: Dictionary) -> void: + var file := FileAccess.open(_chat_log_path, FileAccess.READ_WRITE) + if file == null: + push_error("The configured dedicated-server chat log is unavailable.") + return + file.seek_end() + file.store_line(JSON.stringify({ + "recorded_at_utc": ( + Time.get_datetime_string_from_system(true, false) + "Z" + ), + "sender_display_name": str(message.get("sender_display_name", "")), + "body": str(message.get("body", "")), + })) + file.close() + + func _on_session_state_changed(state: NetworkSession.State) -> void: if state in [ NetworkSession.State.INACTIVE, 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/network/player_data_root.gd b/network/player_data_root.gd index 9e50fe7..998e380 100644 --- a/network/player_data_root.gd +++ b/network/player_data_root.gd @@ -188,7 +188,7 @@ func activate_process_root(path: String) -> bool: func path_for(store_owner: StringName) -> String: var relative: String = { - &"player_save": "player/player_save.json", + &"player_save": "player/player_save.nfsave", &"network_profile": "player/network_profile.json", &"player_appearance": "player/player_appearance.json", &"saved_servers": "social/saved_servers.json", @@ -208,6 +208,10 @@ func identity_backup_directory() -> String: return root_path.path_join("identity-backups") +func progression_backup_directory() -> String: + return root_path.path_join("progression-backups") + + func migration_backup_directory() -> String: return root_path.path_join("backups/migrations") @@ -347,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", - "backups/conflicts", "identity-backups", + "backups/conflicts", "identity-backups", "progression-backups", ]: if DirAccess.make_dir_recursive_absolute(path.path_join(relative)) != OK: return false @@ -369,6 +373,7 @@ func _create_layout(path: String, id: String) -> bool: + "This folder is safe to synchronize with tools such as Syncthing.\n" + "Active private identity keys remain device-local.\n" + "Encrypted identity backups require their passphrase.\n" + + "Progression exports do not contain identity keys or social data.\n" + "Chat and Session Mail are not stored here.\n" + "Do not play the same profile on two devices at the same time.\n" + "Conflicting edits are preserved under backups/conflicts; they are not merged.\n" diff --git a/network/portable_data_migration.gd b/network/portable_data_migration.gd index ab8f21d..a74db6d 100644 --- a/network/portable_data_migration.gd +++ b/network/portable_data_migration.gd @@ -122,6 +122,7 @@ static func migrate_active_to( _remove_tree(staging) return {"ok": false, "message": str(created.get("message", ""))} for relative: String in [ + "player/player_save.nfsave", "player/player_save.json", "player/network_profile.json", "player/player_appearance.json", @@ -134,7 +135,11 @@ static func migrate_active_to( var source: String = data_root.root_path.path_join(relative) if not FileAccess.file_exists(source): continue - if not bool(_copy_verified_json(source, staging.path_join(relative)).get("ok", false)): + if not bool( + _copy_verified_owned_file( + source, staging.path_join(relative) + ).get("ok", false) + ): _remove_tree(staging) return {"ok": false, "message": "Migration validation failed for %s." % relative} if DirAccess.dir_exists_absolute(normalized): @@ -248,6 +253,30 @@ static func _copy_verified_json(source: String, destination: String) -> Dictiona } +static func _copy_verified_owned_file( + source: String, + destination: String, +) -> Dictionary: + if source.get_file() != "player_save.nfsave": + return _copy_verified_json(source, destination) + var decoded: Dictionary = ProgressionSaveCodec.read_local_save(source) + if not bool(decoded.get("ok", false)): + return {"ok": false} + var save_data: Dictionary = decoded["data"] + var version: int = int(save_data.get("save_version", -1)) + if version < 1 or version > PlayerSaveManager.SAVE_VERSION: + return {"ok": false} + var bytes: PackedByteArray = PortableFileGuard.read_bytes(source) + if bytes.is_empty() or not _write_bytes(destination, bytes): + return {"ok": false} + return { + "ok": ( + PortableFileGuard.hash_file(destination) + == PortableFileGuard.hash_bytes(bytes) + ) + } + + static func _valid_owned_data(filename: String, data: Dictionary) -> bool: if filename == "player_save.json": var version: int = int(data.get("save_version", -1)) diff --git a/project.godot b/project.godot index c09f8a1..f12f6fd 100644 --- a/project.godot +++ b/project.godot @@ -11,7 +11,7 @@ config_version=5 [application] config/name="NETFISHING" -config/version="0.13.1-alpha" +config/version="0.14.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" @@ -107,6 +107,11 @@ fish_primary={ , Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":10,"pressure":0.0,"pressed":false,"script":null) ] } +reel_alternate={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":96,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} camera_drag={ "deadzone": 0.2, "events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":32,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":2,"canceled":false,"pressed":false,"double_click":false,"script":null) diff --git a/save/player_save_manager.gd b/save/player_save_manager.gd index 7a2e626..1b4c1d2 100644 --- a/save/player_save_manager.gd +++ b/save/player_save_manager.gd @@ -31,9 +31,13 @@ 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") -const SAVE_VERSION: int = 9 +const SAVE_VERSION: int = 10 +const LEGACY_SAVE_FILENAME := "player_save.json" +const ARCHIVE_EXTENSION := ".nfsave" const BASIC_ROD_ID: StringName = &"basic_fishing_rod" const MAX_SAFE_BALANCE: int = 1000000000000 const DEFAULT_WORLD_SEED: int = 13001 @@ -49,6 +53,8 @@ class LoadSnapshot: var next_catch_sequence: int = 1 var bag_items: Array[OwnedItemType] = [] var unlocked_bait_ids: Array[StringName] = [] + var active_bait_id: StringName + var active_lure_id: StringName var hotbar_slots: Array[StringName] = [] var fish_hotbar_slots: Array[StringName] = [] var selected_hotbar_slot: int = 0 @@ -58,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 @@ -87,6 +94,7 @@ var _experience: PlayerExperienceType var _world_time: WorldTimeServiceType var _world_weather: WorldWeatherServiceType var _jobs: PlayerJobServiceType +var _player: PlayerType var _autosave_timer: Timer var _is_configured: bool = false var _is_restoring: bool = false @@ -96,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 @@ -112,6 +121,21 @@ func _backup_path() -> String: return _save_path + ".backup" +func _codec_scratch_path() -> String: + return _save_path + ".codec.tmp" + + +func _legacy_save_path() -> String: + return _save_path.get_base_dir().path_join(LEGACY_SAVE_FILENAME) + + +func _read_path() -> String: + if FileAccess.file_exists(_save_path): + return _save_path + var legacy: String = _legacy_save_path() + return legacy if FileAccess.file_exists(legacy) else "" + + func _ready() -> void: _autosave_timer = Timer.new() _autosave_timer.one_shot = true @@ -135,6 +159,7 @@ func setup( world_time: WorldTimeServiceType, world_weather: WorldWeatherServiceType, jobs: PlayerJobServiceType, + player: PlayerType, ) -> void: _inventory = inventory _collection_log = collection_log @@ -151,6 +176,7 @@ func setup( _world_time = world_time _world_weather = world_weather _jobs = jobs + _player = player _is_configured = ( _inventory != null and _collection_log != null @@ -167,6 +193,7 @@ func setup( and _world_time != null and _world_weather != null and _jobs != null + and _player != null ) if not _is_configured: push_error("PlayerSaveManager setup is missing required references.") @@ -209,6 +236,10 @@ func setup( _experience.experience_changed.connect(_on_experience_changed) if not _jobs.changed.is_connected(_mark_dirty): _jobs.changed.connect(_mark_dirty) + if not _player.active_bait_changed.is_connected(_on_active_tackle_changed): + _player.active_bait_changed.connect(_on_active_tackle_changed) + if not _player.active_lure_changed.is_connected(_on_active_tackle_changed): + _player.active_lure_changed.connect(_on_active_tackle_changed) func load_player_data() -> bool: @@ -216,24 +247,22 @@ func load_player_data() -> bool: return false _automatic_saving_blocked = false _recover_interrupted_write() - if _save_path.is_empty() or not FileAccess.file_exists(_save_path): + var read_path: String = _read_path() + if _save_path.is_empty() or read_path.is_empty(): _is_dirty = false return true - - var save_file := FileAccess.open(_save_path, FileAccess.READ) - if save_file == null: - _handle_corrupt_save("Unable to open player save.") + var decoded: Dictionary = ProgressionSaveCodec.read_local_save( + read_path, + read_path == _legacy_save_path(), + ) + if not bool(decoded.get("ok", false)): + _handle_corrupt_save("Player save could not be decoded.", read_path) return false - var json_text: String = save_file.get_as_text() - save_file.close() - _expected_hash = PortableFileGuard.hash_file(_save_path) - - var json := JSON.new() - var parse_error: Error = json.parse(json_text) - if parse_error != OK or typeof(json.data) != TYPE_DICTIONARY: - _handle_corrupt_save("Player save contains malformed JSON.") - return false - var save_data: Dictionary = json.data + _expected_hash = ( + PortableFileGuard.hash_file(_save_path) + if read_path == _save_path else "" + ) + var save_data: Dictionary = decoded["data"] var version: int = _read_integer(save_data.get("save_version"), -1) if version > SAVE_VERSION: _automatic_saving_blocked = true @@ -250,13 +279,16 @@ func load_player_data() -> bool: save_data = _migrate_save(save_data, version) if save_data.is_empty(): _handle_corrupt_save( - "Player save version %d is unsupported." % version + "Player save version %d is unsupported." % version, + read_path, ) return false var snapshot: LoadSnapshot = _build_load_snapshot(save_data) if snapshot == null: - _handle_corrupt_save("Player save failed structural validation.") + _handle_corrupt_save( + "Player save failed structural validation.", read_path + ) return false _is_restoring = true @@ -277,6 +309,7 @@ func load_player_data() -> bool: _bag.replace_all_items(snapshot.bag_items) and _bag.replace_unlocked_bait_ids(snapshot.unlocked_bait_ids) ) + var tackle_restored: bool = _restore_tackle_selection(snapshot) var upgrades_restored: bool = _fishing_upgrades.restore_levels( snapshot.reel_speed_level, snapshot.barrier_power_level @@ -302,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: @@ -316,6 +350,7 @@ func load_player_data() -> bool: or not collection_restored or not wallet_restored or not bag_restored + or not tackle_restored or not hotbar_restored or not upgrades_restored or not cooler_restored @@ -329,7 +364,7 @@ func load_player_data() -> bool: push_error( ( "Validated player save could not be restored: " - + "inventory=%s collection=%s wallet=%s bag=%s hotbar=%s " + + "inventory=%s collection=%s wallet=%s bag=%s tackle=%s hotbar=%s " + "upgrades=%s cooler=%s layout=%s art=%s experience=%s " + "time=%s weather=%s jobs=%s" ) @@ -338,6 +373,7 @@ func load_player_data() -> bool: collection_restored, wallet_restored, bag_restored, + tackle_restored, hotbar_restored, upgrades_restored, cooler_restored, @@ -352,6 +388,8 @@ func load_player_data() -> bool: return false _is_dirty = false + if read_path == _legacy_save_path(): + _migrate_legacy_plaintext_save(save_data, read_path) print( "Loaded player save version %d with %d catches." % [SAVE_VERSION, snapshot.catches.size()] @@ -362,24 +400,21 @@ func load_player_data() -> bool: func inspect_save() -> SaveInspectionType: var result := SaveInspectionType.new() _recover_interrupted_write() - result.has_primary_file = FileAccess.file_exists(_save_path) + var read_path: String = _read_path() + result.has_primary_file = not read_path.is_empty() if not result.has_primary_file: result.status = SaveInspectionType.Status.MISSING result.message = "no save found." return result - var save_file := FileAccess.open(_save_path, FileAccess.READ) - if save_file == null: + var decoded: Dictionary = ProgressionSaveCodec.read_local_save( + read_path, + read_path == _legacy_save_path(), + ) + if not bool(decoded.get("ok", false)): result.status = SaveInspectionType.Status.IO_ERROR result.message = "the save could not be read." return result - var json := JSON.new() - var parse_error: Error = json.parse(save_file.get_as_text()) - save_file.close() - if parse_error != OK or typeof(json.data) != TYPE_DICTIONARY: - result.status = SaveInspectionType.Status.MALFORMED - result.message = "the save is corrupt and was preserved." - return result - var save_data: Dictionary = json.data + var save_data: Dictionary = decoded["data"] result.detected_version = _read_integer(save_data.get("save_version"), -1) if result.detected_version > SAVE_VERSION: result.status = SaveInspectionType.Status.UNSUPPORTED_VERSION @@ -408,18 +443,112 @@ func inspect_save() -> SaveInspectionType: return result -func initialize_new_game(world_seed: int = DEFAULT_WORLD_SEED) -> bool: +func export_progression_archive(path: String) -> Dictionary: + if not _is_configured or path.is_empty(): + return {"ok": false, "message": "progression export is unavailable."} + if _is_dirty and not save_if_dirty(): + return {"ok": false, "message": "progression could not be saved first."} + 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( + source_path, + source_path == _legacy_save_path(), + ) + if not bool(decoded.get("ok", false)): + return {"ok": false, "message": "the active 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", + ) + if bytes.is_empty(): + return {"ok": false, "message": "the progression archive could not be encoded."} + var result: Dictionary = PortableFileGuard.write_guarded( + path, + bytes, + PortableFileGuard.hash_file(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) + if not bool(verified.get("ok", false)): + return {"ok": false, "message": "the progression archive could not be verified."} + verified["message"] = "progression archive created." + return verified + + +func inspect_progression_archive(path: String) -> Dictionary: + var decoded: Dictionary = ProgressionSaveCodec.read_archive(path) + if not bool(decoded.get("ok", false)): + return { + "ok": false, + "message": str(decoded.get("message", "could not open progression archive.")), + } + var prepared: Dictionary = _prepare_external_save_data(decoded["data"]) + if not bool(prepared.get("ok", false)): + return prepared + prepared["game_version"] = str(decoded.get("game_version", "")) + prepared["created_at_unix"] = int(decoded.get("created_at_unix", 0)) + return prepared + + +func import_progression_archive(path: String) -> Dictionary: + if not _is_configured: + return {"ok": false, "message": "progression import is unavailable."} + var inspected: Dictionary = inspect_progression_archive(path) + if not bool(inspected.get("ok", false)): + return inspected + var current_path: String = _read_path() + if not current_path.is_empty() and not _archive_replaced_save(current_path): + return {"ok": false, "message": "the current progression could not be backed up."} + var expected_hash: String = PortableFileGuard.hash_file(_save_path) + var result: Dictionary = _write_current_save_data( + inspected["data"], expected_hash + ) + if not bool(result.get("ok", false)): + return {"ok": false, "message": "the imported progression could not be installed."} + _expected_hash = str(result.get("hash", "")) + _remove_legacy_save_files() + _is_dirty = false + _automatic_saving_blocked = false + return { + "ok": true, + "message": "progression imported.", + "catch_count": inspected["catch_count"], + "wallet_balance": inspected["wallet_balance"], + "discovered_species_count": inspected["discovered_species_count"], + } + + +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 @@ -431,9 +560,19 @@ static func roll_world_seed() -> int: func delete_progression_save() -> bool: - if FileAccess.file_exists(_save_path) and not _remove_if_present(_save_path): - return false - for path: String in [_temp_path(), _backup_path()]: + for primary_path: String in [_save_path, _legacy_save_path()]: + if ( + FileAccess.file_exists(primary_path) + and not _remove_if_present(primary_path) + ): + return false + for path: String in [ + _temp_path(), + _backup_path(), + _codec_scratch_path(), + _legacy_save_path() + ".tmp", + _legacy_save_path() + ".backup", + ]: if FileAccess.file_exists(path) and not _remove_if_present(path): push_warning("Unable to remove stale player-save auxiliary file.") if _autosave_timer != null: @@ -485,16 +624,8 @@ func save_now() -> bool: var save_data: Dictionary = _build_save_dictionary() if save_data.is_empty(): return false - var json_text: String = JSON.stringify(save_data, "\t") - if json_text.is_empty(): - return false - - var result := PortableFileGuard.write_guarded( - _save_path, - json_text.to_utf8_buffer(), - _expected_hash, - _data_root.conflict_directory(), - _data_root.device_id, + var result: Dictionary = _write_current_save_data( + save_data, _expected_hash ) if bool(result.get("conflict", false)): _data_root.report_conflict( @@ -574,6 +705,17 @@ func _build_save_dictionary() -> Dictionary: ) ): return {} + var active_bait_id: StringName = _player.active_bait_id + var active_lure_id: StringName = _player.active_lure_id + if ( + (not active_bait_id.is_empty() and not _valid_active_tackle( + active_bait_id, true + )) + or (not active_lure_id.is_empty() and not _valid_active_tackle( + active_lure_id, false + )) + ): + return {} return { "save_version": SAVE_VERSION, @@ -592,6 +734,10 @@ func _build_save_dictionary() -> Dictionary: "items": serialized_items, "unlocked_bait_ids": serialized_unlocked_baits, }, + "tackle": { + "active_bait_id": String(active_bait_id), + "active_lure_id": String(active_lure_id), + }, "hotbar": { "selected_slot": _hotbar.get_selected_slot(), "slots": serialized_slots, @@ -603,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()), @@ -620,6 +767,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: or typeof(save_data.get("collection")) != TYPE_DICTIONARY or typeof(save_data.get("inventory")) != TYPE_DICTIONARY or typeof(save_data.get("bag")) != TYPE_DICTIONARY + or typeof(save_data.get("tackle")) != TYPE_DICTIONARY or typeof(save_data.get("hotbar")) != TYPE_DICTIONARY or typeof(save_data.get("inventory_layout")) != TYPE_DICTIONARY or typeof(save_data.get("experience")) != TYPE_DICTIONARY @@ -630,6 +778,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: var collection_data: Dictionary = save_data["collection"] var inventory_data: Dictionary = save_data["inventory"] var bag_data: Dictionary = save_data["bag"] + var tackle_data: Dictionary = save_data["tackle"] var hotbar_data: Dictionary = save_data["hotbar"] var inventory_layout_data: Dictionary = save_data["inventory_layout"] var experience_data: Dictionary = save_data["experience"] @@ -665,6 +814,12 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: and typeof(bag_data.get("unlocked_bait_ids")) != TYPE_ARRAY ) or typeof(hotbar_data.get("slots")) != TYPE_ARRAY + or typeof(tackle_data.get("active_bait_id")) not in [ + TYPE_STRING, TYPE_STRING_NAME, + ] + or typeof(tackle_data.get("active_lure_id")) not in [ + TYPE_STRING, TYPE_STRING_NAME, + ] or not hotbar_data.has("selected_slot") or not experience_data.has("total_experience") ): @@ -684,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"], @@ -897,6 +1058,16 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: ): seen_unlocked_baits[owned.item_id] = true snapshot.unlocked_bait_ids.append(owned.item_id) + snapshot.active_bait_id = _validated_tackle_id( + StringName(str(tackle_data.get("active_bait_id", ""))), + true, + seen_items, + ) + snapshot.active_lure_id = _validated_tackle_id( + StringName(str(tackle_data.get("active_lure_id", ""))), + false, + seen_items, + ) var slot_values: Array = hotbar_data["slots"] snapshot.hotbar_slots.resize(PlayerHotbarType.SLOT_COUNT) @@ -995,6 +1166,8 @@ func _migrate_save( migrated = _migrate_version_7_to_8(migrated) 8: migrated = _migrate_version_8_to_9(migrated) + 9: + migrated = _migrate_version_9_to_10(migrated) _: return {} if migrated.is_empty(): @@ -1242,6 +1415,16 @@ func _migrate_version_8_to_9(data: Dictionary) -> Dictionary: return migrated +func _migrate_version_9_to_10(data: Dictionary) -> Dictionary: + var migrated: Dictionary = data.duplicate(true) + migrated["tackle"] = { + "active_bait_id": "", + "active_lure_id": "", + } + migrated["save_version"] = 10 + return migrated + + func _mark_dirty() -> void: if ( _is_restoring @@ -1291,33 +1474,169 @@ func _on_experience_changed(_total_experience: int, _level: int) -> void: _mark_dirty() +func _on_active_tackle_changed(_item_id: StringName) -> void: + _mark_dirty() + + func _on_autosave_timeout() -> void: if _is_dirty: save_now() func _recover_interrupted_write() -> void: - if FileAccess.file_exists(_save_path): - _remove_if_present(_temp_path()) - _remove_if_present(_backup_path()) + _remove_if_present(_codec_scratch_path()) + _recover_path_write(_save_path) + _recover_path_write(_legacy_save_path()) + + +func _recover_path_write(path: String) -> void: + var temporary: String = path + ".tmp" + var backup: String = path + ".backup" + if FileAccess.file_exists(path): + _remove_if_present(temporary) + _remove_if_present(backup) return - if FileAccess.file_exists(_backup_path()): - _rename_file(_backup_path(), _save_path) - _remove_if_present(_temp_path()) + if FileAccess.file_exists(backup): + _rename_file(backup, path) + _remove_if_present(temporary) -func _handle_corrupt_save(message: String) -> void: +func _write_current_save_data( + save_data: Dictionary, + expected_hash: String, +) -> Dictionary: + var bytes: PackedByteArray = ProgressionSaveCodec.encode_local_save( + save_data, + _codec_scratch_path(), + ) + if bytes.is_empty(): + return {"ok": false} + return PortableFileGuard.write_guarded( + _save_path, + bytes, + expected_hash, + _data_root.conflict_directory(), + _data_root.device_id, + ) + + +func _prepare_external_save_data(value: Variant) -> Dictionary: + if typeof(value) != TYPE_DICTIONARY: + return {"ok": false, "message": "progression data is malformed."} + var save_data: Dictionary = (value as Dictionary).duplicate(true) + var version: int = _read_integer(save_data.get("save_version"), -1) + if version > SAVE_VERSION: + return { + "ok": false, + "message": "this progression belongs to a newer game version.", + } + if version < 1: + return {"ok": false, "message": "the progression version is unsupported."} + if version != SAVE_VERSION: + save_data = _migrate_save(save_data, version) + if save_data.is_empty(): + return { + "ok": false, + "message": "the progression version is unsupported.", + } + var snapshot: LoadSnapshot = _build_load_snapshot(save_data) + if snapshot == null: + return {"ok": false, "message": "the progression is structurally invalid."} + return { + "ok": true, + "data": save_data, + "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, + } + + +func _migrate_legacy_plaintext_save( + save_data: Dictionary, + legacy_path: String, +) -> void: + var result: Dictionary = _write_current_save_data(save_data, "") + if not bool(result.get("ok", false)): + push_warning("Legacy progression loaded but could not be encrypted yet.") + return + _expected_hash = str(result.get("hash", "")) + if not _archive_replaced_save(legacy_path, "plaintext-migration"): + push_warning( + "Legacy progression was encrypted, but its migration backup failed." + ) + return + _remove_legacy_save_files() + print("Upgraded legacy plaintext progression to the opaque save format.") + + +func _archive_replaced_save( + source_path: String, + reason: String = "before-import", +) -> bool: + if source_path.is_empty() or not FileAccess.file_exists(source_path): + return true + var timestamp: String = Time.get_datetime_string_from_system().replace( + ":", "-" + ) + var destination: String = _data_root.root_path.path_join( + "backups/saves/player-save-%s-%s%s" + % [reason, timestamp, ARCHIVE_EXTENSION] + ) + if FileAccess.file_exists(destination): + destination = destination.trim_suffix(ARCHIVE_EXTENSION) + ( + "-%s%s" % [Time.get_ticks_usec(), ARCHIVE_EXTENSION] + ) + var decoded: Dictionary = ProgressionSaveCodec.read_local_save( + source_path, + source_path == _legacy_save_path(), + ) + var bytes: PackedByteArray + if bool(decoded.get("ok", false)): + var prepared: Dictionary = _prepare_external_save_data(decoded["data"]) + if bool(prepared.get("ok", false)): + bytes = ProgressionSaveCodec.encode_archive( + prepared["data"], destination + ".codec.tmp" + ) + if bytes.is_empty(): + bytes = PortableFileGuard.read_bytes(source_path) + destination = ( + destination.trim_suffix(ARCHIVE_EXTENSION) + ".preserved" + ) + if bytes.is_empty(): + return false + var result: Dictionary = PortableFileGuard.write_guarded( + destination, + bytes, + "", + _data_root.conflict_directory(), + _data_root.device_id, + ) + return bool(result.get("ok", false)) + + +func _remove_legacy_save_files() -> void: + for path: String in [ + _legacy_save_path(), + _legacy_save_path() + ".tmp", + _legacy_save_path() + ".backup", + ]: + _remove_if_present(path) + + +func _handle_corrupt_save(message: String, source_path: String) -> void: push_warning(message) _restore_defaults() - if not FileAccess.file_exists(_save_path): + if not FileAccess.file_exists(source_path): return var timestamp: int = int(Time.get_unix_time_from_system()) var corrupt_path: String = ( - "%s.corrupt-%d" % [_save_path, timestamp] + "%s.corrupt-%d" % [source_path, timestamp] ) if FileAccess.file_exists(corrupt_path): corrupt_path += "-%d" % Time.get_ticks_usec() - if not _rename_file(_save_path, corrupt_path): + if not _rename_file(source_path, corrupt_path): _automatic_saving_blocked = true push_warning( "Corrupt player save was left in place; automatic saving is " @@ -1350,6 +1669,8 @@ func _restore_defaults() -> void: _bag.replace_all_items(default_items) var default_unlocked_baits: Array[StringName] = [] _bag.replace_unlocked_bait_ids(default_unlocked_baits) + _player.unequip_bait() + _player.unequip_lure() _fishing_upgrades.reset_to_defaults() _cooler_capacity.reset_to_defaults() _inventory_layout.reset_to_defaults() @@ -1360,12 +1681,60 @@ 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 _is_dirty = false +func _valid_active_tackle(item_id: StringName, bait: bool) -> bool: + var item = _item_catalog.get_item_by_id(item_id) + return ( + item != null + and item.is_available() + and (item.is_bait() if bait else item.is_lure()) + and _bag.owns_item(item_id) + ) + + +func _validated_tackle_id( + item_id: StringName, + bait: bool, + seen_items: Dictionary[StringName, bool], +) -> StringName: + if item_id.is_empty(): + return StringName() + var item = _item_catalog.get_item_by_id(item_id) + if ( + item == null + or not item.is_available() + or not seen_items.has(item_id) + or not (item.is_bait() if bait else item.is_lure()) + ): + push_warning("Skipped invalid saved active tackle '%s'." % String(item_id)) + return StringName() + return item_id + + +func _restore_tackle_selection(snapshot: LoadSnapshot) -> bool: + _player.unequip_bait() + _player.unequip_lure() + var bait_restored: bool = ( + snapshot.active_bait_id.is_empty() + or _player.equip_bait( + _item_catalog.get_item_by_id(snapshot.active_bait_id) + ) + ) + var lure_restored: bool = ( + snapshot.active_lure_id.is_empty() + or _player.equip_lure( + _item_catalog.get_item_by_id(snapshot.active_lure_id) + ) + ) + return bait_restored and lure_restored + + func _read_integer( value: Variant, invalid_value: int, diff --git a/save/progression_save_codec.gd b/save/progression_save_codec.gd new file mode 100644 index 0000000..7844e16 --- /dev/null +++ b/save/progression_save_codec.gd @@ -0,0 +1,179 @@ +class_name ProgressionSaveCodec +extends RefCounted + +const MAGIC := "NETFISHING_PROGRESSION_CONTAINER" +const FORMAT_VERSION := 1 +const LOCAL_KIND := "local_save" +const ARCHIVE_KIND := "portable_archive" +const MAX_CONTAINER_BYTES := 16 * 1024 * 1024 + +# This key keeps routine progression files opaque to casual editing. Since the +# client is open source, it is intentionally not treated as a security secret. +const CONTAINER_PASSPHRASE := ( + "NETfishing progression container v1 / not an identity credential" +) + + +static func encode_local_save( + save_data: Dictionary, + scratch_path: String, +) -> PackedByteArray: + return _encode_container(LOCAL_KIND, save_data, scratch_path) + + +static func encode_archive( + save_data: Dictionary, + scratch_path: String, +) -> PackedByteArray: + return _encode_container(ARCHIVE_KIND, save_data, scratch_path) + + +static func read_local_save( + path: String, + allow_legacy_plaintext: bool = false, +) -> Dictionary: + if allow_legacy_plaintext and not _looks_encrypted(path): + var legacy: Dictionary = _read_plaintext_dictionary(path) + if bool(legacy.get("ok", false)): + legacy["legacy_plaintext"] = true + return legacy + return _read_container(path, LOCAL_KIND) + + +static func read_archive(path: String) -> Dictionary: + if not _looks_encrypted(path): + return {"ok": false, "message": "progression archive is not recognized."} + return _read_container(path, ARCHIVE_KIND) + + +static func _encode_container( + kind: String, + save_data: Dictionary, + scratch_path: String, +) -> PackedByteArray: + if save_data.is_empty() or scratch_path.is_empty(): + return PackedByteArray() + var payload_json: String = JSON.stringify(save_data) + if payload_json.is_empty(): + return PackedByteArray() + var payload_bytes: PackedByteArray = payload_json.to_utf8_buffer() + var envelope: Dictionary = { + "magic": MAGIC, + "format_version": FORMAT_VERSION, + "kind": kind, + "game_version": str( + ProjectSettings.get_setting("application/config/version", "") + ), + "created_at_unix": int(Time.get_unix_time_from_system()), + "payload_sha256": PortableFileGuard.hash_bytes(payload_bytes), + "payload_json": payload_json, + } + if DirAccess.make_dir_recursive_absolute( + scratch_path.get_base_dir() + ) != OK: + return PackedByteArray() + _remove_if_present(scratch_path) + var file: FileAccess = FileAccess.open_encrypted_with_pass( + scratch_path, + FileAccess.WRITE, + CONTAINER_PASSPHRASE, + ) + if file == null: + return PackedByteArray() + file.store_string(JSON.stringify(envelope)) + file.flush() + var write_ok: bool = file.get_error() == OK + file.close() + if not write_ok: + _remove_if_present(scratch_path) + return PackedByteArray() + var encoded: PackedByteArray = PortableFileGuard.read_bytes( + scratch_path, + MAX_CONTAINER_BYTES, + ) + _remove_if_present(scratch_path) + return encoded + + +static func _read_container(path: String, expected_kind: String) -> Dictionary: + if ( + path.is_empty() + or not FileAccess.file_exists(path) + or FileAccess.get_size(path) > MAX_CONTAINER_BYTES + ): + return {"ok": false, "message": "progression file is unavailable."} + var file: FileAccess = FileAccess.open_encrypted_with_pass( + path, + FileAccess.READ, + CONTAINER_PASSPHRASE, + ) + if file == null: + return {"ok": false, "message": "progression file could not be opened."} + var envelope_text: String = file.get_as_text() + file.close() + var envelope_json := JSON.new() + if ( + envelope_json.parse(envelope_text) != OK + or typeof(envelope_json.data) != TYPE_DICTIONARY + ): + return {"ok": false, "message": "progression container is malformed."} + var envelope: Dictionary = envelope_json.data + if ( + envelope.get("magic") != MAGIC + or int(envelope.get("format_version", -1)) != FORMAT_VERSION + or str(envelope.get("kind", "")) != expected_kind + ): + return {"ok": false, "message": "progression container is unsupported."} + var payload_json: String = str(envelope.get("payload_json", "")) + var payload_bytes: PackedByteArray = payload_json.to_utf8_buffer() + if ( + payload_json.is_empty() + or str(envelope.get("payload_sha256", "")) + != PortableFileGuard.hash_bytes(payload_bytes) + ): + return {"ok": false, "message": "progression container failed validation."} + var payload_parser := JSON.new() + if ( + payload_parser.parse(payload_json) != OK + or typeof(payload_parser.data) != TYPE_DICTIONARY + ): + return {"ok": false, "message": "progression payload is malformed."} + return { + "ok": true, + "data": payload_parser.data, + "game_version": str(envelope.get("game_version", "")), + "created_at_unix": int(envelope.get("created_at_unix", 0)), + "legacy_plaintext": false, + } + + +static func _read_plaintext_dictionary(path: String) -> Dictionary: + var bytes: PackedByteArray = PortableFileGuard.read_bytes( + path, + MAX_CONTAINER_BYTES, + ) + if bytes.is_empty(): + return {"ok": false} + var text: String = bytes.get_string_from_utf8() + if not text.strip_edges().begins_with("{"): + return {"ok": false} + var parser := JSON.new() + if parser.parse(text) != OK or typeof(parser.data) != TYPE_DICTIONARY: + return {"ok": false} + return {"ok": true, "data": parser.data} + + +static func _looks_encrypted(path: String) -> bool: + if not FileAccess.file_exists(path) or FileAccess.get_size(path) < 4: + return false + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return false + var header: PackedByteArray = file.get_buffer(4) + file.close() + return header == PackedByteArray([0x47, 0x44, 0x45, 0x43]) + + +static func _remove_if_present(path: String) -> void: + if FileAccess.file_exists(path): + DirAccess.remove_absolute(path) diff --git a/save/progression_save_codec.gd.uid b/save/progression_save_codec.gd.uid new file mode 100644 index 0000000..6db5823 --- /dev/null +++ b/save/progression_save_codec.gd.uid @@ -0,0 +1 @@ +uid://lad8mvedktww diff --git a/scripts/build_playtest.sh b/scripts/build_playtest.sh index a1874c4..d409d7a 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.13.1-alpha" +readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.14.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.13.1-alpha-windows-x86_64.zip" -readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.13.1-alpha-linux-x86_64.zip" +readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.14.0-alpha-windows-x86_64.zip" +readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.14.0-alpha-linux-x86_64.zip" readonly GODOT_BIN="${GODOT_BIN:-godot}" if [[ ! -f "${PROJECT_ROOT}/project.godot" ]]; then diff --git a/scripts/build_portmaster.sh b/scripts/build_portmaster.sh index b5ad5ae..833305b 100755 --- a/scripts/build_portmaster.sh +++ b/scripts/build_portmaster.sh @@ -223,7 +223,7 @@ music while omitting rain and shoreline ambience in both profiles. See ## Controls -NETfishing reads the handheld controller directly. PortMaster's system +NETfishing reads the handheld controller directly. GPTOKEYB's device-specific force-quit chord remains available; this is Start+Select on most devices. EOF @@ -249,8 +249,11 @@ if unzip -Z1 "${ARCHIVE}" | grep -qx 'port.json'; then echo "port.json must be inside netfishing/, not at the archive root." >&2 exit 1 fi -grep -q '^# PORTMASTER: netfishing.zip, NETfishing.sh$' \ - "${STAGE_ROOT}/NETfishing.sh" +if grep -Eq '^[[:space:]]*#[[:space:]]*PORTMASTER:' \ + "${STAGE_ROOT}/NETfishing.sh"; then + echo "The distributable launcher contains an installed PortMaster signature." >&2 + exit 1 +fi grep -Fq 'PROFILE_PATH="$CONFDIR/performance_profile"' \ "${STAGE_ROOT}/NETfishing.sh" grep -Fq 'NETFISHING_PERFORMANCE_PROFILE=light' \ diff --git a/scripts/portmaster/NETfishing.sh b/scripts/portmaster/NETfishing.sh index fe6d778..8e118da 100755 --- a/scripts/portmaster/NETfishing.sh +++ b/scripts/portmaster/NETfishing.sh @@ -1,5 +1,4 @@ #!/bin/bash -# PORTMASTER: netfishing.zip, NETfishing.sh XDG_DATA_HOME=${XDG_DATA_HOME:-$HOME/.local/share} @@ -13,19 +12,11 @@ else controlfolder="/roms/ports/PortMaster" fi -if [ ! -f "$controlfolder/control.txt" ]; then - echo "PortMaster control.txt was not found at $controlfolder" >&2 - exit 1 -fi - source "$controlfolder/control.txt" [ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt" get_controls GAMEDIR="/${directory}/ports/netfishing" -if [ ! -d "$GAMEDIR" ] && [ -d "/mnt/mmc/ports/netfishing" ]; then - GAMEDIR="/mnt/mmc/ports/netfishing" -fi CONFDIR="$GAMEDIR/conf" PORTS_ROOT="${GAMEDIR%/netfishing}" @@ -38,16 +29,13 @@ CONTROLLER_MAPPING_FILE="$CONFDIR/cache/controller_mapping.txt" WESTON_DIR="/tmp/netfishing-weston" WESTON_RUNTIME="weston_pkg_0.2" HARBOURMASTER="$controlfolder/harbourmaster" -if [ ! -x "$HARBOURMASTER" ] && [ -x "/mnt/mmc/MUOS/PortMaster/harbourmaster" ]; then - HARBOURMASTER="/mnt/mmc/MUOS/PortMaster/harbourmaster" -fi mkdir -p "$CONFDIR/data" "$CONFDIR/config" "$CONFDIR/cache" "$WESTON_DIR" chmod +x "$GAME_EXECUTABLE" -# New PortMaster installs use a predictable save directory beside the port -# instead of presenting a folder picker on a small screen. Preserve an -# established data-root choice when upgrading an existing installation. +# NETfishing uses a predictable save directory beside the port instead of +# presenting a folder picker on a small screen. Preserve an established +# data-root choice when upgrading an existing installation. NETFISHING_DATA_ENVIRONMENT=() if [ -f "$SAVEDIR/netfishing_data.json" ] || { [ ! -f "$DATA_BOOTSTRAP_DIR/data_root_bootstrap.json" ] && @@ -139,10 +127,9 @@ case "$PERFORMANCE_PROFILE" in ;; esac -# Keep NETfishing's native controller input separate from PortMaster's exit -# handling. Legacy GPTOKEYB builds can load their default keyboard map when no -# config is supplied, so the explicit no-op map preserves only the -# device-specific force-quit chord. +# NETfishing reads gameplay input natively. The explicit no-op map prevents +# GPTOKEYB from injecting duplicate keyboard or mouse input while retaining +# its device-specific force-quit chord, including on ROCKNIX. $GPTOKEYB "NETfishing.aarch64" -c "$GPTOKEYB_CONFIG" & pm_platform_helper "$GAME_EXECUTABLE" diff --git a/scripts/portmaster/netfishing.gptk b/scripts/portmaster/netfishing.gptk index ac03270..ff1ad36 100644 --- a/scripts/portmaster/netfishing.gptk +++ b/scripts/portmaster/netfishing.gptk @@ -1,5 +1,6 @@ -# NETfishing reads the controller directly. GPTOKEYB is retained only for the -# PortMaster force-quit chord, so every keyboard and mouse binding is disabled. +# NETfishing reads the controller directly. GPTOKEYB is retained only for its +# device-specific force-quit chord, so every keyboard and mouse binding is +# disabled to prevent duplicate input. back = \" guide = \" diff --git a/scripts/run_validations.sh b/scripts/run_validations.sh index 67b8abe..6bf5b1f 100755 --- a/scripts/run_validations.sh +++ b/scripts/run_validations.sh @@ -15,6 +15,7 @@ readonly -a QUICK_TESTS=( "tests/android_readiness_validation.gd" "tests/camera_drag_validation.gd" "tests/character_rig_validation.gd" + "tests/chat_validation.gd" "tests/controller_focus_presentation_validation.gd" "tests/controller_menu_accessibility_validation.gd" "tests/controller_mapping_validation.gd" @@ -22,6 +23,7 @@ readonly -a QUICK_TESTS=( "tests/controller_ui_navigation_validation.gd" "tests/dedicated_server_config_validation.gd" "tests/digging_prototype_validation.gd" + "tests/equipment_catalog_validation.gd" "tests/exported_decal_hotfix_validation.gd" "tests/file_dialog_controller_navigation_validation.gd" "tests/fish_catalog_content_validation.gd" @@ -50,6 +52,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" ) @@ -72,10 +75,12 @@ readonly -a HOST_TESTS=( "tests/gathering_showcase_validation.gd" "tests/inventory_storage_persistence_validation.gd" "tests/job_system_validation.gd" + "tests/progression_archive_validation.gd" "tests/surface_drawing_runtime_validation.gd" ) readonly -a NETWORK_TESTS=( + "tests/chat_privacy_multiplayer_validation.gd" "tests/economy_regression_validation.gd" "tests/fish_showcase_multiplayer_validation.gd" "tests/fishing_multiplayer_validation.gd" @@ -85,6 +90,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" ) @@ -140,7 +146,10 @@ wait_for_network_host() { sleep 2 return 0 fi - for _attempt in {1..100}; do + # A cold generated-world startup can take longer than ten seconds before + # NetworkSession is ready to bind. Keep the client launch tied to the actual + # listener instead of treating that normal startup work as a host failure. + for _attempt in {1..600}; do if ! kill -0 "${host_pid}" 2>/dev/null; then return 1 fi @@ -226,7 +235,7 @@ run_session_switch_network_test() { --headless --path "${PROJECT_ROOT}" --script "${script}" -- second_host \ >"${second_root}/output.log" 2>&1 & second_pid=$! - for _attempt in {1..100}; do + for _attempt in {1..600}; do if ! kill -0 "${first_pid}" 2>/dev/null \ || ! kill -0 "${second_pid}" 2>/dev/null; then break diff --git a/server/dedicated_server_config.gd b/server/dedicated_server_config.gd index 7812800..2f515c4 100644 --- a/server/dedicated_server_config.gd +++ b/server/dedicated_server_config.gd @@ -17,6 +17,8 @@ var world_seed: int = DEFAULT_WORLD_SEED var public_listing: bool = false var discovery_url: String = "" var data_directory: String = "" +var chat_logging: bool = false +var chat_log_path: String = "" var operator_fingerprints: PackedStringArray = PackedStringArray() var error_message: String = "" @@ -71,6 +73,12 @@ func _load_file(path: String) -> bool: discovery_url = str(file.get_value( "discovery", "url", discovery_url )) + chat_logging = bool(file.get_value( + "privacy", "chat_logging", chat_logging + )) + chat_log_path = str(file.get_value( + "privacy", "chat_log_path", chat_log_path + )) operator_fingerprints = _parse_fingerprint_list(file.get_value( "moderation", "operators", operator_fingerprints )) @@ -100,6 +108,12 @@ func _apply_environment() -> void: data_directory = _environment_string( "NETFISHING_DATA_DIR", data_directory ) + chat_logging = _environment_bool( + "NETFISHING_CHAT_LOGGING", chat_logging + ) + chat_log_path = _environment_string( + "NETFISHING_CHAT_LOG_PATH", chat_log_path + ) if OS.has_environment("NETFISHING_SERVER_OPERATORS"): operator_fingerprints = _parse_fingerprint_list( OS.get_environment("NETFISHING_SERVER_OPERATORS") @@ -130,10 +144,16 @@ func _apply_arguments(arguments: PackedStringArray) -> void: operator_fingerprints = _parse_fingerprint_list( argument.trim_prefix("--operators=") ) + elif argument.begins_with("--chat-log-path="): + chat_log_path = argument.trim_prefix("--chat-log-path=") elif argument == "--public": public_listing = true elif argument == "--private": public_listing = false + elif argument == "--chat-logging": + chat_logging = true + elif argument == "--no-chat-logging": + chat_logging = false func _validate() -> void: @@ -141,6 +161,9 @@ func _validate() -> void: bind_address = bind_address.strip_edges() discovery_url = discovery_url.strip_edges().trim_suffix("/") data_directory = data_directory.strip_edges() + chat_log_path = chat_log_path.strip_edges() + if chat_logging and chat_log_path.is_empty() and not data_directory.is_empty(): + chat_log_path = data_directory.path_join("logs/chat.jsonl") operator_fingerprints = _normalized_fingerprints(operator_fingerprints) if server_name.is_empty(): error_message = "Server name cannot be empty." @@ -156,6 +179,12 @@ func _validate() -> void: error_message = "World seed must be between 1 and %d." % MAX_WORLD_SEED elif not data_directory.is_empty() and not data_directory.is_absolute_path(): error_message = "Server data directory must be an absolute path." + elif chat_logging and ( + chat_log_path.is_empty() or not chat_log_path.is_absolute_path() + ): + error_message = ( + "Chat logging requires an absolute chat log path." + ) elif public_listing and not ( discovery_url.begins_with("https://") or discovery_url.begins_with("http://") diff --git a/settings/keyboard_mouse_mapping_manager.gd b/settings/keyboard_mouse_mapping_manager.gd index a8d4f09..d9cb03d 100644 --- a/settings/keyboard_mouse_mapping_manager.gd +++ b/settings/keyboard_mouse_mapping_manager.gd @@ -3,7 +3,8 @@ extends Node signal mapping_changed -const FORMAT_VERSION: int = 1 +const FORMAT_VERSION: int = 2 +const LEGACY_FORMAT_VERSION: int = 1 const MAPPING_PATH: String = "user://keyboard_mouse_bindings.json" const MAPPING_TEMP_PATH: String = "user://keyboard_mouse_bindings.json.tmp" const MAPPING_BACKUP_PATH: String = ( @@ -21,6 +22,7 @@ const ROLE_SNEAK: StringName = &"sneak" const ROLE_SLOW_WALK: StringName = &"slow_walk" const ROLE_INTERACT: StringName = &"interact" const ROLE_PRIMARY_ACTION: StringName = &"fish_primary" +const ROLE_ALTERNATE_REEL: StringName = &"reel_alternate" const ROLE_CAMERA_DRAG: StringName = &"camera_drag" const ROLE_CAMERA_ZOOM_IN: StringName = &"camera_zoom_in" const ROLE_CAMERA_ZOOM_OUT: StringName = &"camera_zoom_out" @@ -46,6 +48,7 @@ const ROLE_ORDER: Array[StringName] = [ ROLE_SLOW_WALK, ROLE_INTERACT, ROLE_PRIMARY_ACTION, + ROLE_ALTERNATE_REEL, ROLE_CAMERA_DRAG, ROLE_CAMERA_ZOOM_IN, ROLE_CAMERA_ZOOM_OUT, @@ -81,6 +84,7 @@ const ROLE_LABELS: Dictionary = { ROLE_SLOW_WALK: "slow walk", ROLE_INTERACT: "interact", 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", @@ -138,19 +142,28 @@ func load_mapping() -> bool: _bindings = {} return false var data := json.data as Dictionary - if int(data.get("format_version", -1)) != FORMAT_VERSION: + var format_version: int = int(data.get("format_version", -1)) + if format_version not in [LEGACY_FORMAT_VERSION, FORMAT_VERSION]: push_warning("Keyboard binding version is unsupported; using defaults.") _bindings = {} return false var raw_bindings: Variant = data.get("bindings", {}) if ( typeof(raw_bindings) != TYPE_DICTIONARY - or not _validate_complete_bindings(raw_bindings as Dictionary) ): push_warning("Keyboard bindings are incomplete; using defaults.") _bindings = {} return false - _bindings = (raw_bindings as Dictionary).duplicate(true) + var loaded_bindings := (raw_bindings as Dictionary).duplicate(true) + if format_version == LEGACY_FORMAT_VERSION: + loaded_bindings[str(ROLE_ALTERNATE_REEL)] = ( + _default_bindings[str(ROLE_ALTERNATE_REEL)].duplicate(true) + ) + if not _validate_complete_bindings(loaded_bindings): + push_warning("Keyboard bindings are incomplete; using defaults.") + _bindings = {} + return false + _bindings = loaded_bindings return true diff --git a/tests/art_tools_validation.gd b/tests/art_tools_validation.gd index 78a020f..aeebe05 100644 --- a/tests/art_tools_validation.gd +++ b/tests/art_tools_validation.gd @@ -86,6 +86,11 @@ func _run() -> void: var chat_ui := game_ui.get_node("%ChatUI") as ChatUI assert(player != null and service != null and toolbar != null) assert(chat_ui != null) + assert(toolbar.get_parent() == chat_ui.get_parent()) + assert( + toolbar.get_index() > chat_ui.get_index(), + "The art-kit toolbar must receive overlapping pointer input before Chat.", + ) assert(bool(game_ui.call("_can_start_virtual_mouse"))) var virtual_pointer_states: Array[bool] = [] game_ui.virtual_pointer_mode_changed.connect( @@ -317,6 +322,22 @@ func _run() -> void: assert(not ( game_ui.get_node("%ExperiencePresentation") as Control ).visible) + var hud_fishing_spot := main.get_node("%FishingSpot") as FishingSpot + assert(hud_fishing_spot != null) + hud_fishing_spot.state = FishingSpot.FishingState.SHOWING_CATCH + game_ui.call("_refresh_gameplay_hud_visibility") + assert(( + game_ui.get_node("%GameplayTransientHUD") as Control + ).visible) + assert(not ( + game_ui.get_node("%ExperiencePresentation") as Control + ).visible) + assert(chat_ui.is_hud_hidden()) + hud_fishing_spot.state = FishingSpot.FishingState.READY + game_ui.call("_refresh_gameplay_hud_visibility") + assert(not ( + game_ui.get_node("%GameplayTransientHUD") as Control + ).visible) quick_menu.open_menu(true) assert(quick_menu.visible and quick_menu.is_open()) quick_menu.close_menu() @@ -365,6 +386,12 @@ func _run() -> void: await process_frame assert(service.is_active() and not service.is_placement_mode()) assert(toolbar.visible) + chat_ui.open_chat() + assert(chat_ui.is_open()) + assert(not service.is_active() and not toolbar.visible) + chat_ui.close_chat() + assert(not chat_ui.is_open()) + assert(service.is_active() and toolbar.visible) var ui_root := game_ui.get_node("%UIRoot") as Control assert(toolbar.get_parent() == ui_root) assert(is_zero_approx(toolbar.position.y)) diff --git a/tests/chat_privacy_multiplayer_validation.gd b/tests/chat_privacy_multiplayer_validation.gd new file mode 100644 index 0000000..5d38b08 --- /dev/null +++ b/tests/chat_privacy_multiplayer_validation.gd @@ -0,0 +1,120 @@ +extends SceneTree + +const MainScene: PackedScene = preload("res://main/main.tscn") +const TEST_PORT: int = 18152 +const PREJOIN_MESSAGE: String = "private prejoin message" +const LIVE_MESSAGE: String = "live message after join" + + +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("Chat privacy validation needs host or client mode.") + quit(1) + + +func _run_host() -> void: + var main: Node = await _create_initialized_main() + var session := main.get_node("%NetworkSession") as NetworkSession + var save_manager := main.get("_save_manager") as PlayerSaveManager + assert(session.start_private_host(TEST_PORT)) + assert(save_manager.initialize_new_game()) + main.call("_enter_gameplay") + for _frame: int in 4: + await physics_frame + assert(session.set_host_open(true)) + var chat_service := main.get_node( + "%NetworkChatService" + ) as NetworkChatService + assert(chat_service.send_local_message(PREJOIN_MESSAGE)) + var remote_peer_id: int = await _wait_for_remote_peer(session) + assert(remote_peer_id > 1) + await create_timer(1.0).timeout + assert(chat_service.send_local_message(LIVE_MESSAGE)) + var disconnect_deadline: int = Time.get_ticks_msec() + 8000 + while ( + Time.get_ticks_msec() < disconnect_deadline + and session.is_authenticated_peer(remote_peer_id) + ): + await process_frame + assert(not session.is_authenticated_peer(remote_peer_id)) + print("Chat privacy multiplayer host validation: PASS") + await _session_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 join_deadline: int = Time.get_ticks_msec() + 20000 + while Time.get_ticks_msec() < join_deadline: + await process_frame + if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY: + main.call("_confirm_server_trust") + if session.is_joined_client() and bool(main.get("_gameplay_started")): + break + assert(session.is_joined_client()) + var chat_service := main.get_node( + "%NetworkChatService" + ) as NetworkChatService + await create_timer(0.5).timeout + assert(not _history_contains(chat_service, PREJOIN_MESSAGE)) + var live_deadline: int = Time.get_ticks_msec() + 8000 + while ( + Time.get_ticks_msec() < live_deadline + and not _history_contains(chat_service, LIVE_MESSAGE) + ): + await process_frame + assert(_history_contains(chat_service, LIVE_MESSAGE)) + assert(not _history_contains(chat_service, PREJOIN_MESSAGE)) + print("Chat privacy multiplayer client validation: PASS") + await _session_cleanup(main, session) + + +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 _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 _session_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/chat_privacy_multiplayer_validation.gd.uid b/tests/chat_privacy_multiplayer_validation.gd.uid new file mode 100644 index 0000000..33b210d --- /dev/null +++ b/tests/chat_privacy_multiplayer_validation.gd.uid @@ -0,0 +1 @@ +uid://ivtqrnppnmli diff --git a/tests/chat_validation.gd b/tests/chat_validation.gd new file mode 100644 index 0000000..fa0cd8e --- /dev/null +++ b/tests/chat_validation.gd @@ -0,0 +1,38 @@ +extends SceneTree + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + assert(NetworkChatProtocol.sanitize_body(42).is_empty()) + assert(NetworkChatProtocol.sanitize_body(" a ") == "a") + assert(NetworkChatProtocol.sanitize_body("a b") == "a b") + assert(NetworkChatProtocol.sanitize_body("a\tb\nc") == "a b c") + assert(NetworkChatProtocol.sanitize_body(" !hello!? ") == "!hello!?") + assert(NetworkChatProtocol.sanitize_body("!!!") == "!!!") + + const em_space := " " # U+2003 EM SPACE + const zero_width_space := "​" # U+200B ZERO WIDTH SPACE + const braille_blank := "⠀" # U+2800 BRAILLE PATTERN BLANK + const invisible_padding := em_space + zero_width_space + braille_blank + assert( + NetworkChatProtocol.sanitize_body(invisible_padding + "a" + invisible_padding) + == "a" + ) + assert(NetworkChatProtocol.sanitize_body(invisible_padding).is_empty()) + assert(NetworkChatProtocol.sanitize_body(" \t\n" + invisible_padding).is_empty()) + + assert( + NetworkChatProtocol.sanitize_body("a".repeat( + NetworkChatProtocol.MAX_VISIBLE_CHARACTERS + )).length() == NetworkChatProtocol.MAX_VISIBLE_CHARACTERS + ) + assert( + NetworkChatProtocol.sanitize_body("a".repeat( + NetworkChatProtocol.MAX_VISIBLE_CHARACTERS + 1 + )).is_empty() + ) + + print("Chat validation: PASS") + quit(0) diff --git a/tests/chat_validation.gd.uid b/tests/chat_validation.gd.uid new file mode 100644 index 0000000..54e6823 --- /dev/null +++ b/tests/chat_validation.gd.uid @@ -0,0 +1 @@ +uid://ohemhv2sf5g4 diff --git a/tests/controller_menu_accessibility_validation.gd b/tests/controller_menu_accessibility_validation.gd index 975ce79..3e37223 100644 --- a/tests/controller_menu_accessibility_validation.gd +++ b/tests/controller_menu_accessibility_validation.gd @@ -353,6 +353,8 @@ 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 @@ -362,6 +364,8 @@ func _validate_data_settings_navigation() -> void: data_tab, open_data_folder, change_data_folder, + export_progression, + import_progression, copy_fingerprint, export_player, import_player, @@ -385,7 +389,7 @@ func _validate_data_settings_navigation() -> void: _assert_neighbor( open_data_folder, &"focus_neighbor_bottom", - copy_fingerprint, + export_progression, ) _assert_neighbor( change_data_folder, @@ -395,12 +399,42 @@ func _validate_data_settings_navigation() -> void: _assert_neighbor( change_data_folder, &"focus_neighbor_bottom", + import_progression, + ) + _assert_neighbor( + export_progression, + &"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", - open_data_folder, + export_progression, ) _assert_neighbor( copy_fingerprint, diff --git a/tests/dedicated_server_config_validation.gd b/tests/dedicated_server_config_validation.gd index c9a82c0..02a881d 100644 --- a/tests/dedicated_server_config_validation.gd +++ b/tests/dedicated_server_config_validation.gd @@ -19,6 +19,12 @@ func _run() -> void: defaults.call("_validate") assert(defaults.is_valid()) assert(not bool(defaults.get("public_listing"))) + assert(not bool(defaults.get("chat_logging"))) + assert(str(defaults.get("chat_log_path")).is_empty()) + assert(NetworkChatService.BURST_COUNT == 3) + assert(NetworkChatService.WINDOW_COUNT == 5) + assert(is_equal_approx(NetworkChatService.WINDOW_SECONDS, 10.0)) + assert(NetworkChatService.CALL_COOLDOWN_MILLISECONDS == 90) var path: String = ProjectSettings.globalize_path( "user://dedicated-server-validation.cfg" @@ -32,6 +38,12 @@ func _run() -> void: file.set_value("server", "public", true) file.set_value("server", "data_directory", "/tmp/configured-server") file.set_value("discovery", "url", "https://discovery.netfishing.org/") + file.set_value("privacy", "chat_logging", true) + file.set_value( + "privacy", + "chat_log_path", + "/tmp/configured-server/logs/chat.jsonl", + ) file.set_value( "moderation", "operators", PackedStringArray([OPERATOR_B, OPERATOR_A]) ) @@ -48,6 +60,11 @@ func _run() -> void: assert(int(configured.get("world_seed")) == 928374) assert(bool(configured.get("public_listing"))) assert(str(configured.get("discovery_url")) == "https://discovery.netfishing.org") + assert(bool(configured.get("chat_logging"))) + assert( + str(configured.get("chat_log_path")) + == "/tmp/configured-server/logs/chat.jsonl" + ) assert( configured.get("operator_fingerprints") == PackedStringArray([OPERATOR_A, OPERATOR_B]) @@ -89,6 +106,51 @@ func _run() -> void: invalid_seed.call("_validate") assert(not invalid_seed.is_valid()) + var default_chat_path := ConfigType.new() + default_chat_path.set("data_directory", "/tmp/default-chat-path-server") + default_chat_path.set("chat_logging", true) + default_chat_path.call("_validate") + assert(default_chat_path.is_valid()) + assert( + str(default_chat_path.get("chat_log_path")) + == "/tmp/default-chat-path-server/logs/chat.jsonl" + ) + + var invalid_chat_path := ConfigType.new() + invalid_chat_path.set("data_directory", "/tmp/invalid-chat-path-server") + invalid_chat_path.set("chat_logging", true) + invalid_chat_path.set("chat_log_path", "relative/chat.jsonl") + invalid_chat_path.call("_validate") + assert(not invalid_chat_path.is_valid()) + + var chat_log_validation_path: String = ProjectSettings.globalize_path( + "user://dedicated-chat-log-validation.jsonl" + ) + var chat_service := NetworkChatService.new() + assert(not chat_service.configure_dedicated_history( + true, + "relative/chat.jsonl", + )) + assert(chat_service.configure_dedicated_history( + true, + chat_log_validation_path, + )) + assert(FileAccess.file_exists(chat_log_validation_path)) + chat_service.call("_append_chat_log", { + "sender_display_name": "Privacy Tester", + "body": "explicitly retained message", + }) + var logged_message: Variant = JSON.parse_string( + FileAccess.get_file_as_string(chat_log_validation_path).strip_edges() + ) + assert(typeof(logged_message) == TYPE_DICTIONARY) + assert(str(logged_message.get("recorded_at_utc", "")).ends_with("Z")) + assert(logged_message.get("sender_display_name") == "Privacy Tester") + assert(logged_message.get("body") == "explicitly retained message") + assert(chat_service.configure_dedicated_history(false, "")) + assert(DirAccess.remove_absolute(chat_log_validation_path) == OK) + chat_service.free() + var discovery := DiscoveryClient.new() assert( str(discovery.call("_default_room_name", "River")) diff --git a/tests/equipment_catalog_validation.gd b/tests/equipment_catalog_validation.gd index a74d066..bc549aa 100644 --- a/tests/equipment_catalog_validation.gd +++ b/tests/equipment_catalog_validation.gd @@ -54,6 +54,21 @@ func _initialize() -> void: assert(FishingShopStockType.get_price(&"magnet") == 250) assert(FishingShopStockType.get_stock_item_ids().has(&"magnet")) assert(FishingShopStockType.is_permanent_unlock(&"magnet", magnet)) + var fishing_net: ItemDataType = ( + ItemCatalogResource.get_available_item_by_id(&"fishing_net") + ) + assert(fishing_net != null) + assert(fishing_net.category == ItemDataType.Category.TOOL) + assert(fishing_net.icon == null) + assert(not fishing_net.usable) + assert(not fishing_net.equippable) + assert(not fishing_net.hotbar_allowed) + assert(FishingShopStockType.get_price(&"fishing_net") == 250) + assert(FishingShopStockType.get_stock_item_ids().has(&"fishing_net")) + assert(FishingShopStockType.is_permanent_unlock( + &"fishing_net", fishing_net + )) + assert(not FishingShopStockType.is_gathering_tool(&"fishing_net")) var crab_net: ItemDataType = ItemCatalogResource.get_available_item_by_id( &"crab_net" ) diff --git a/tests/fish_hotbar_showcase_validation.gd b/tests/fish_hotbar_showcase_validation.gd index d61d002..9421816 100644 --- a/tests/fish_hotbar_showcase_validation.gd +++ b/tests/fish_hotbar_showcase_validation.gd @@ -98,29 +98,27 @@ func _run() -> void: assert(save_manager.save_now()) var save_path: String = str(save_manager.get("_save_path")) - var save_file := FileAccess.open(save_path, FileAccess.READ) - assert(save_file != null) - var parsed: Variant = JSON.parse_string(save_file.get_as_text()) - save_file.close() - assert(typeof(parsed) == TYPE_DICTIONARY) - var hotbar_data: Dictionary = (parsed as Dictionary)["hotbar"] + var decoded: Dictionary = ProgressionSaveCodec.read_local_save(save_path) + assert(bool(decoded.get("ok", false))) + var parsed: Dictionary = decoded["data"] + var hotbar_data: Dictionary = parsed["hotbar"] assert(typeof(hotbar_data.get("fish_slots")) == TYPE_ARRAY) assert(str((hotbar_data["fish_slots"] as Array)[1]) == fish_catch.catch_id) - assert(int((parsed as Dictionary)["save_version"]) == 9) + assert(int(parsed["save_version"]) == 10) assert( - int((parsed as Dictionary)["experience"]["total_experience"]) + int(parsed["experience"]["total_experience"]) == 125 ) assert(absf( - float((parsed as Dictionary)["world"]["time_hours"]) - saved_time_hours + float(parsed["world"]["time_hours"]) - saved_time_hours ) < 0.01) assert( - int((parsed as Dictionary)["world"]["weather"]) + int(parsed["world"]["weather"]) == int(saved_weather) ) assert(absf( float( - (parsed as Dictionary)["world"][ + parsed["world"][ "weather_seconds_remaining" ] ) - saved_weather_seconds diff --git a/tests/fish_quality_validation.gd b/tests/fish_quality_validation.gd index 31e4cc1..9c5d338 100644 --- a/tests/fish_quality_validation.gd +++ b/tests/fish_quality_validation.gd @@ -433,7 +433,7 @@ func _validate_version_four_migration() -> void: version_four, 4, ) - assert(int(migrated.get("save_version", -1)) == 9) + assert(int(migrated.get("save_version", -1)) == 10) assert(int((migrated["experience"] as Dictionary)["total_experience"]) == 0) assert( is_equal_approx( diff --git a/tests/fishing_multiplayer_validation.gd b/tests/fishing_multiplayer_validation.gd index e19a92e..7965af6 100644 --- a/tests/fishing_multiplayer_validation.gd +++ b/tests/fishing_multiplayer_validation.gd @@ -2,6 +2,7 @@ extends SceneTree const MainScene = preload("res://main/main.tscn") const FishingSpotType = preload("res://fishing/fishing_spot.gd") +const TEST_SEED: int = 864209 func _initialize() -> void: @@ -22,9 +23,23 @@ func _run() -> void: func _run_host() -> void: var main: Node = await _create_initialized_main() + 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(bool(main.call("_prepare_private_host"))) var save_manager := main.get("_save_manager") as PlayerSaveManager - assert(save_manager.initialize_new_game()) + assert(save_manager.initialize_new_game( + TEST_SEED, + WorldLayout.STARTER_ISLAND, + )) main.call("_enter_gameplay") var session := main.get_node("%NetworkSession") as NetworkSession assert(session.set_host_open(true)) @@ -217,7 +232,9 @@ func _run_client() -> void: int(player.get("_fishing_visual_phase")) == Player.FishingVisualPhase.CASTING ) - for _frame: int in 30: + # Keep the transient pull-back pose visible long enough for the separate host + # process to observe it even when a cold validation run is CPU-bound. + for _frame: int in 120: await physics_frame player.set_fishing_visual(false) player.call("_set_sitting", true, true) diff --git a/tests/gathering_showcase_validation.gd b/tests/gathering_showcase_validation.gd index 0138bad..e565beb 100644 --- a/tests/gathering_showcase_validation.gd +++ b/tests/gathering_showcase_validation.gd @@ -32,6 +32,7 @@ func _run() -> void: var fishing_spot := main.get_node("%FishingSpot") as FishingSpot var catalog := main.get("fish_catalog") as FishPool assert(player != null and fishing_spot != null and catalog != null) + fishing_spot.minimum_showcase_duration = 0.15 assert(player.bag.add_item(&"crab_net")) assert(player.hotbar.assign_item(0, &"crab_net")) assert(player.hotbar.select_slot(0)) @@ -60,13 +61,20 @@ func _run() -> void: assert(not player.is_movement_enabled()) Input.action_release("fish_primary") await process_frame - assert(bool(fishing_spot.get("_put_away_press_armed"))) + assert(not bool(fishing_spot.get("_put_away_press_armed"))) var pocket_event := InputEventAction.new() pocket_event.action = &"fish_primary" pocket_event.pressed = true Input.parse_input_event(pocket_event) await process_frame + assert(fishing_spot.get("_pending_catch") == crab_catch) + Input.action_release("fish_primary") + await create_timer(0.2).timeout + await process_frame + assert(bool(fishing_spot.get("_put_away_press_armed"))) + Input.parse_input_event(pocket_event) + await process_frame assert(fishing_spot.get("_pending_catch") == null) var pocket_deadline: int = Time.get_ticks_msec() + 6000 diff --git a/tests/generated_world_runtime_validation.gd b/tests/generated_world_runtime_validation.gd index c6ac945..501deb9 100644 --- a/tests/generated_world_runtime_validation.gd +++ b/tests/generated_world_runtime_validation.gd @@ -82,18 +82,42 @@ func _validate_generated_region( generator: TerrainChunkGenerator, ) -> void: var placements := generator.placement_keys() - assert(placements.size() == 49) - assert(placements[24].begins_with("chunk_spawn@")) + var expected_chunk_count := generator.grid_size.x * generator.grid_size.y + var center := Vector2i( + generator.grid_size.x / 2, + generator.grid_size.y / 2, + ) + var center_index := center.y * generator.grid_size.x + center.x + assert(placements.size() == expected_chunk_count) + assert(placements[center_index].begins_with("chunk_spawn@")) assert(_placement_count(placements, "chunk_spawn") == 1) assert(_placement_count(placements, "chunk_0001") >= 1) assert(_placement_count(placements, "chunk_0002") >= 1) assert(_placement_count(placements, "chunk_0003") >= 1) + assert(_placement_count(placements, "chunk_0016") >= 1) assert(_placement_count(placements, "chunk_0004") >= 1) assert(_placement_count(placements, "chunk_0005") >= 1) assert(_placement_count(placements, "chunk_0006") >= 1) assert(_placement_count(placements, "chunk_0007") >= 1) - assert(generator.get_generated_chunks_root().get_child_count() == 49) - assert(generator.get_primary_terrain_meshes().size() == 49) + assert(_placement_count(placements, "chunk_0008") >= 1) + assert(_placement_count(placements, "chunk_0013") >= 1) + assert(_placement_count(placements, "chunk_0009") == 1) + assert(_placement_count(placements, "chunk_0010") == 4) + assert( + _placement_count(placements, "chunk_0011") + + _placement_count(placements, "chunk_0012") + == 4 + ) + assert(_placement_count(placements, "chunk_0012") <= 1) + assert( + generator.get_generated_chunks_root().get_child_count() + == expected_chunk_count + ) + assert( + generator.get_primary_terrain_meshes().size() + == expected_chunk_count + ) + _validate_elevated_cliff_feature(generator) var shop := region.get_node("Interactables/FishingShopWorld") as Node3D var storage := region.get_node("Interactables/PlayerStorageBox") as Node3D @@ -111,6 +135,7 @@ func _validate_generated_region( var fresh_root := region.get_node("WaterBodies/FreshWaterBodies") as Node3D assert(ocean != null and fresh_root != null) assert(ocean.water_type == WaterType.Type.SALT_WATER) + assert(is_equal_approx(ocean.position.y, GeneratedWorldRegion.WATER_HEIGHT)) var fresh_placement_count := 0 for record: Dictionary in generator.placement_records(): var tags: PackedStringArray = record.get("tags", PackedStringArray()) @@ -123,6 +148,7 @@ func _validate_generated_region( var fresh := child as WaterBodyAuthoring assert(fresh != null) assert(fresh.water_type == WaterType.Type.FRESH_WATER) + assert(is_equal_approx(fresh.position.y, GeneratedWorldRegion.WATER_HEIGHT)) assert(fresh.visible) assert(fresh.surface_size.x < 10.0 or fresh.surface_size.y < 10.0) assert(not fresh.visual_surface_enabled) @@ -155,13 +181,13 @@ func _validate_generated_region( &"terrain_chunk_coordinate", Vector2i.ZERO, ) - var center := Vector2i( + var spawn_coordinate := Vector2i( generator.grid_size.x / 2, generator.grid_size.y / 2, ) var spawn_distance := ( - absi(coordinate.x - center.x) - + absi(coordinate.y - center.y) + absi(coordinate.x - spawn_coordinate.x) + + absi(coordinate.y - spawn_coordinate.y) ) assert(spawn_distance >= definition.minimum_spawn_chunk_distance) _validate_decoration_transform( @@ -169,6 +195,13 @@ func _validate_generated_region( child as Node3D, definition, ) + if prop_id in [&"prop_tree_1", &"prop_tree_2", &"prop_tree_3"]: + assert( + _has_material_variant_override( + child.get_node_or_null("Visual"), + definition.material_variants, + ) + ) assert(_decoration_group_count(region, &"grass_tree") > 0) assert(_decoration_group_count(region, &"sand_tree") > 0) assert( @@ -187,6 +220,69 @@ func _validate_generated_region( ) +func _validate_elevated_cliff_feature( + generator: TerrainChunkGenerator, +) -> void: + var elevated_coordinates: Dictionary[Vector2i, StringName] = {} + var top_coordinate := Vector2i(-1, -1) + for record: Dictionary in generator.placement_records(): + var stable_id: StringName = record.get("stable_id", &"") + if stable_id not in [ + &"chunk_0009", + &"chunk_0010", + &"chunk_0011", + &"chunk_0012", + ]: + continue + var coordinate: Vector2i = record.get("coordinate", Vector2i.ZERO) + assert(coordinate.x > 0 and coordinate.x < generator.grid_size.x - 1) + assert(coordinate.y > 0 and coordinate.y < generator.grid_size.y - 1) + elevated_coordinates[coordinate] = stable_id + if stable_id == &"chunk_0009": + top_coordinate = coordinate + assert(elevated_coordinates.size() == 9) + assert(top_coordinate != Vector2i(-1, -1)) + for row_offset: int in range(-1, 2): + for column_offset: int in range(-1, 2): + assert( + elevated_coordinates.has( + top_coordinate + Vector2i(column_offset, row_offset) + ) + ) + + var generated := generator.get_generated_chunks_root() + assert(generated != null) + var layered_count := 0 + for chunk_root: Node in generated.get_children(): + var stable_id := StringName( + chunk_root.get_meta(&"terrain_chunk_id", &"") + ) + if stable_id not in [ + &"chunk_0009", + &"chunk_0010", + &"chunk_0011", + &"chunk_0012", + ]: + continue + layered_count += 1 + var base_layer := chunk_root.get_node_or_null("TerrainBaseLayer") + var overlay := chunk_root.get_node_or_null("TerrainOverlay") + assert(base_layer != null and overlay != null) + var base_mesh := TerrainChunkAnalyzer.find_primary_mesh( + base_layer, + &"chunk_0000", + ) + var overlay_mesh := TerrainChunkAnalyzer.find_primary_mesh( + overlay, + stable_id, + ) + 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 == 9) + + func _validate_ocean_facing_record( record: Dictionary, grid_size: Vector2i, @@ -224,6 +320,23 @@ func _validate_prop_catalog(region: GeneratedWorldRegion) -> void: assert(instance.position.is_zero_approx()) assert(_find_mesh_instance(instance) != null) instance.free() + var mushroom := catalog.definition_for_id(&"prop_mushroom") + assert(mushroom != null and mushroom.has_collision()) + assert(mushroom.visual_offset.y < 0.0) + for tree_id: StringName in [ + &"prop_tree_1", + &"prop_tree_2", + &"prop_tree_3", + ]: + var tree := catalog.definition_for_id(tree_id) + assert(tree != null) + assert(tree.minimum_visual_scale < tree.maximum_visual_scale) + assert(tree.material_variants.size() >= 3) + assert(not tree.variant_material_slot_names.is_empty()) + var pine := catalog.definition_for_id(&"prop_pine") + assert(pine != null) + assert(pine.minimum_visual_scale < pine.maximum_visual_scale) + assert(pine.material_variants.is_empty()) func _validate_biome_catalog( @@ -239,7 +352,9 @@ func _validate_biome_catalog( var coordinate: Vector2i = record.get("coordinate", Vector2i.ZERO) var biome_id := region.get_biome_at(coordinate) var tags: PackedStringArray = record.get("tags", PackedStringArray()) - if "grass" in tags or "sand" in tags: + if "mixed_surface" in tags: + assert(biome_id == &"") + elif "grass" in tags or "sand" in tags: assert(biome_id != &"") var biome := catalog.definition_for_id(biome_id) assert(biome != null and biome.supports_chunk_tags(tags)) @@ -276,7 +391,16 @@ func _validate_decoration_transform( var collision := prop.get_node_or_null( "TrunkCollision/CollisionShape" ) as CollisionShape3D - assert(visual_root != null and visual_root.position.is_zero_approx()) + assert(visual_root != null) + assert(visual_root.position.is_equal_approx(definition.visual_offset)) + var visual_scale := float( + prop.get_meta(&"terrain_prop_visual_scale", 1.0) + ) + assert(visual_scale >= definition.minimum_visual_scale - 0.001) + assert(visual_scale <= definition.maximum_visual_scale + 0.001) + assert( + visual_root.scale.is_equal_approx(Vector3.ONE * visual_scale) + ) assert(visual != null and visual.mesh != null) if definition.has_collision(): assert(collision != null) @@ -319,16 +443,40 @@ func _find_mesh_instance(root_node: Node) -> MeshInstance3D: return null +func _has_material_variant_override( + root_node: Node, + variants: Array[Material], +) -> bool: + if root_node == null: + return false + var mesh_instance := root_node as MeshInstance3D + if mesh_instance != null and mesh_instance.mesh != null: + for surface_index: int in mesh_instance.mesh.get_surface_count(): + var override := mesh_instance.get_surface_override_material( + surface_index + ) + if override != null and override in variants: + return true + for child: Node in root_node.get_children(): + if _has_material_variant_override(child, variants): + return true + return false + + func _validate_authored_chunk_surfaces( region: GeneratedWorldRegion, generator: TerrainChunkGenerator, ) -> void: var generated := generator.get_generated_chunks_root() var found_beach := false + var found_stream := false + var found_alternate_stream := false var found_pond := false var found_grass_ocean_edge := false var found_grass_beach_transition := false var found_grass_ocean_corner := false + var found_beach_ocean_corner := false + var found_grass_sand_diagonal := false for chunk_root: Node in generated.get_children(): if chunk_root.name.begins_with("chunk_0002r"): var beach := chunk_root.find_child( @@ -338,6 +486,27 @@ func _validate_authored_chunk_surfaces( var material := beach.get_active_material(0) assert(material != null and material.resource_name == "sand") found_beach = true + elif ( + chunk_root.name.begins_with("chunk_0003r") + or chunk_root.name.begins_with("chunk_0016r") + ): + var stream_id := ( + &"chunk_0016" + if chunk_root.name.begins_with("chunk_0016r") + else &"chunk_0003" + ) + var stream := chunk_root.find_child( + String(stream_id), true, false + ) as MeshInstance3D + assert(stream != null and stream.mesh != null) + var stream_materials := _material_names(stream) + assert("dirt" in stream_materials) + assert("grass_lite" in stream_materials) + assert("dirt_wall" in stream_materials) + if stream_id == &"chunk_0016": + found_alternate_stream = true + else: + found_stream = true elif chunk_root.name.begins_with("chunk_0004r"): var pond := chunk_root.find_child( "chunk_0004", true, false @@ -383,11 +552,32 @@ func _validate_authored_chunk_surfaces( assert("grass_lite" in corner_materials) assert("cliff_wall" in corner_materials) found_grass_ocean_corner = true + elif chunk_root.name.begins_with("chunk_0008r"): + var beach_ocean_corner := chunk_root.find_child( + "chunk_0008", true, false + ) as MeshInstance3D + assert(beach_ocean_corner != null and beach_ocean_corner.mesh != null) + var beach_corner_materials := _material_names(beach_ocean_corner) + assert("sand" in beach_corner_materials) + found_beach_ocean_corner = true + elif chunk_root.name.begins_with("chunk_0013r"): + var grass_sand_diagonal := chunk_root.find_child( + "chunk_0013", true, false + ) as MeshInstance3D + assert(grass_sand_diagonal != null and grass_sand_diagonal.mesh != null) + var diagonal_materials := _material_names(grass_sand_diagonal) + assert("grass_lite" in diagonal_materials) + assert("sand" in diagonal_materials) + found_grass_sand_diagonal = true assert(found_beach) + assert(found_stream) + assert(found_alternate_stream) assert(found_pond) assert(found_grass_ocean_edge) assert(found_grass_beach_transition) assert(found_grass_ocean_corner) + assert(found_beach_ocean_corner) + assert(found_grass_sand_diagonal) func _material_names(mesh_instance: MeshInstance3D) -> PackedStringArray: diff --git a/tests/inventory_storage_persistence_validation.gd b/tests/inventory_storage_persistence_validation.gd index 013ebbf..794f438 100644 --- a/tests/inventory_storage_persistence_validation.gd +++ b/tests/inventory_storage_persistence_validation.gd @@ -1,6 +1,9 @@ extends SceneTree const MainScene: PackedScene = preload("res://main/main.tscn") +const ItemCatalogResource: ItemCatalog = preload( + "res://items/catalog/item_catalog.tres" +) func _initialize() -> void: @@ -23,33 +26,43 @@ 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")) + assert(player.bag.add_item(&"worms")) + assert(player.bag.add_item(&"the_standby")) + assert(player.equip_bait(ItemCatalogResource.get_item_by_id(&"worms"))) + assert(player.equip_lure(ItemCatalogResource.get_item_by_id(&"the_standby"))) assert(player.bag.move_item_to_storage_slot(&"basic_fishing_rod", 14)) assert(player.bag.move_item_to_storage_slot(&"coffee", 12)) assert(save_manager.save_now()) - var save_file := FileAccess.open( - str(save_manager.get("_save_path")), - FileAccess.READ, + var decoded: Dictionary = ProgressionSaveCodec.read_local_save( + str(save_manager.get("_save_path")) ) - assert(save_file != null) - var parsed: Variant = JSON.parse_string(save_file.get_as_text()) - save_file.close() - assert(typeof(parsed) == TYPE_DICTIONARY) - var records: Array = (parsed as Dictionary)["bag"]["items"] - assert(int((parsed as Dictionary)["world"]["seed"]) == TEST_WORLD_SEED) + 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) assert(player.bag.move_item_to_storage_slot(&"basic_fishing_rod", 0)) assert(player.bag.move_item_to_storage_slot(&"coffee", 0)) + 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) + assert(player.active_bait_id == &"worms") + assert(player.active_lure_id == &"the_standby") main.queue_free() for _frame: int in 4: diff --git a/tests/job_system_validation.gd b/tests/job_system_validation.gd index d2f2ae7..1526e44 100644 --- a/tests/job_system_validation.gd +++ b/tests/job_system_validation.gd @@ -49,6 +49,7 @@ func _run() -> void: _validate_weather_guarantees(board) _validate_late_board_weather_fallback() _validate_remote_tamper_rejection(jobs, board) + _validate_creature_jobs(jobs) var sell_job: Dictionary = _find_job( jobs.get_daily_jobs(), JobCatalog.Kind.SELL_TOTAL @@ -195,15 +196,12 @@ func _run() -> void: assert(session.set_host_open(false)) assert(save_manager.save_now()) - var save_file := FileAccess.open( - str(save_manager.get("_save_path")), FileAccess.READ + var decoded: Dictionary = ProgressionSaveCodec.read_local_save( + str(save_manager.get("_save_path")) ) - assert(save_file != null) - var parsed: Variant = JSON.parse_string(save_file.get_as_text()) - save_file.close() - assert(typeof(parsed) == TYPE_DICTIONARY) - var save_data: Dictionary = parsed - assert(int(save_data.get("save_version", -1)) == 9) + assert(bool(decoded.get("ok", false))) + var save_data: Dictionary = decoded["data"] + assert(int(save_data.get("save_version", -1)) == 10) assert(PlayerJobService.validate_save_data(save_data.get("jobs", {}))) _validate_pause_session_switch(main, session) @@ -263,6 +261,100 @@ func _validate_unavailable_fishnet_layout() -> void: await process_frame +func _validate_creature_jobs(jobs: PlayerJobService) -> void: + var fish_count: int = 0 + var insect_count: int = 0 + var shellfish_count: int = 0 + var candidates: Array[FishData] = [] + for fish: FishData in Catalog.candidates: + if fish == null or not fish.is_selectable(): + continue + candidates.append(fish) + match fish.get_creature_group(): + FishData.CreatureGroup.FISH: + fish_count += 1 + FishData.CreatureGroup.INSECT: + insect_count += 1 + FishData.CreatureGroup.SHELLFISH: + shellfish_count += 1 + assert(fish_count > 0 and insect_count > 0 and shellfish_count > 0) + + var lifetime: Array[Dictionary] = jobs.get_lifetime_jobs() + var fish_discovery: Dictionary = _find_job( + lifetime, JobCatalog.Kind.DISCOVER_SPECIES + ) + assert(int(fish_discovery.get("target", -1)) == fish_count) + for creature_group: int in [ + FishData.CreatureGroup.INSECT, + FishData.CreatureGroup.SHELLFISH, + ]: + assert(not _find_creature_job( + lifetime, + JobCatalog.Kind.CATCH_CREATURE_GROUP, + creature_group, + ).is_empty()) + assert(not _find_creature_job( + lifetime, + JobCatalog.Kind.DISCOVER_CREATURE_GROUP, + creature_group, + ).is_empty()) + assert(not _find_creature_job( + lifetime, + JobCatalog.Kind.MASTER_CREATURE_GROUP, + creature_group, + ).is_empty()) + + var daily_groups: Dictionary[int, bool] = {} + for seed_index: int in 64: + for job: Dictionary in JobCatalog.generate_daily_jobs( + "creature-job-validation-%d" % seed_index, + candidates, + true, + ): + if int(job.get("kind", -1)) == JobCatalog.Kind.CATCH_CREATURE_GROUP: + daily_groups[int(job.get("creature_group", -1))] = true + assert(daily_groups.has(FishData.CreatureGroup.INSECT)) + assert(daily_groups.has(FishData.CreatureGroup.SHELLFISH)) + + var beetle: FishData = Catalog.get_fish_by_id(&"beetle_stag_common") + assert(beetle != null) + var beetle_catch := _make_test_catch(beetle, &"job-beetle") + jobs.call("_on_authoritative_catch", beetle_catch) + var statistics: Dictionary = jobs.to_save_data().get("statistics", {}) + assert(int(statistics.get("fish_caught", -1)) == 0) + assert(int(statistics.get("insects_caught", -1)) == 1) + + +func _find_creature_job( + jobs: Array[Dictionary], + kind: JobCatalog.Kind, + creature_group: int, +) -> Dictionary: + for job: Dictionary in jobs: + if ( + int(job.get("kind", -1)) == kind + and int(job.get("creature_group", -1)) == creature_group + ): + return job + return {} + + +func _make_test_catch(fish: FishData, catch_id: StringName) -> FishCatch: + var fish_catch := FishCatch.new() + fish_catch.fish = fish + fish_catch.fish_id = fish.id + fish_catch.catch_id = catch_id + fish_catch.catch_sequence = 1 + fish_catch.weight_lb = fish.get_minimum_weight() + fish_catch.display_scale = fish.get_display_scale_for_weight( + fish_catch.weight_lb + ) + fish_catch.quality = FishQuality.Tier.BORING + fish_catch.sale_value = fish.get_sale_value_for_weight(fish_catch.weight_lb) + assert(fish_catch.is_valid()) + return fish_catch + + func _validate_weather_guarantees(board: Dictionary) -> void: var jobs: Array = board.get("jobs", []) var schedule: Array = board.get("weather_schedule", []) diff --git a/tests/keyboard_mouse_mapping_validation.gd b/tests/keyboard_mouse_mapping_validation.gd index cca2ac2..7d02736 100644 --- a/tests/keyboard_mouse_mapping_validation.gd +++ b/tests/keyboard_mouse_mapping_validation.gd @@ -7,6 +7,12 @@ const KeyboardMouseMappingPanelType = preload( "res://ui/keyboard_mouse_mapping_panel.gd" ) const SettingsPanelScene = preload("res://ui/settings_panel.tscn") +const FishingSpotScene = preload("res://fishing/fishing_spot.tscn") +const FishingSpotType = preload("res://fishing/fishing_spot.gd") +const CatchDifficultyProfileType = preload( + "res://fishing/catch_difficulty_profile.gd" +) +const PlayerType = preload("res://player/player.gd") func _initialize() -> void: @@ -27,6 +33,13 @@ func _run() -> void: str(KeyboardMouseMappingManagerType.ROLE_PRIMARY_ACTION) ].get("kind", "")) == "mouse_button" ) + assert( + str(defaults[ + str(KeyboardMouseMappingManagerType.ROLE_ALTERNATE_REEL) + ].get("kind", "")) == "key" + ) + assert(_has_physical_key(&"reel_alternate", KEY_QUOTELEFT)) + assert(_event_count(&"reel_alternate", true) == 0) assert( str(defaults[ str(KeyboardMouseMappingManagerType.ROLE_CHAT) @@ -60,6 +73,15 @@ func _run() -> void: )) assert(_has_mouse_button(&"fish_primary", MOUSE_BUTTON_XBUTTON1)) assert(not _has_mouse_button(&"fish_primary", MOUSE_BUTTON_LEFT)) + var alternate_reel_key := InputEventKey.new() + alternate_reel_key.physical_keycode = KEY_G + alternate_reel_key.pressed = true + assert(manager.set_binding( + KeyboardMouseMappingManagerType.ROLE_ALTERNATE_REEL, + manager.binding_from_event(alternate_reel_key), + )) + assert(_has_physical_key(&"reel_alternate", KEY_G)) + assert(_event_count(&"reel_alternate", true) == 0) var joypad_event := InputEventJoypadButton.new() joypad_event.button_index = JOY_BUTTON_A @@ -117,11 +139,64 @@ func _run() -> void: )) assert(_has_physical_key(&"jump", KEY_SPACE)) assert(_has_mouse_button(&"fish_primary", MOUSE_BUTTON_LEFT)) + assert(_has_physical_key(&"reel_alternate", KEY_QUOTELEFT)) + assert(_event_count(&"reel_alternate", true) == 0) assert(_has_physical_key(&"open_chat", KEY_T)) assert(_event_count(&"jump", true) == joypad_events_before) + var legacy_bindings: Dictionary = manager.get_active_bindings() + legacy_bindings.erase(str( + KeyboardMouseMappingManagerType.ROLE_ALTERNATE_REEL + )) + legacy_bindings[str(KeyboardMouseMappingManagerType.ROLE_JUMP)] = ( + manager.binding_from_event(rebind_key) + ) + var legacy_file := FileAccess.open( + KeyboardMouseMappingManagerType.MAPPING_PATH, + FileAccess.WRITE, + ) + assert(legacy_file != null) + legacy_file.store_string(JSON.stringify({ + "format_version": KeyboardMouseMappingManagerType.LEGACY_FORMAT_VERSION, + "bindings": legacy_bindings, + })) + legacy_file.close() + var migrated_manager := KeyboardMouseMappingManagerType.new() + root.add_child(migrated_manager) + await process_frame + assert(migrated_manager.has_custom_mapping()) + assert(_has_physical_key(&"jump", KEY_R)) + assert(_has_physical_key(&"reel_alternate", KEY_QUOTELEFT)) + assert(migrated_manager.reset_mapping()) + + var fishing_spot := FishingSpotScene.instantiate() as FishingSpotType + root.add_child(fishing_spot) + await process_frame + var local_player := PlayerType.new() + fishing_spot.set("_local_player", local_player) + fishing_spot.set("_gameplay_input_enabled", true) + fishing_spot.state = FishingSpotType.FishingState.FIGHTING + var catch_controller := fishing_spot.get_node("%CatchController") + var catch_profile := CatchDifficultyProfileType.new() + catch_profile.barrier_count_min = 0 + catch_profile.barrier_count_max = 0 + catch_controller.start_encounter(catch_profile, 1.0, 1) + var alternate_press := InputEventAction.new() + alternate_press.action = &"reel_alternate" + alternate_press.pressed = true + fishing_spot._unhandled_input(alternate_press) + assert(bool(catch_controller.get("_reel_input_held"))) + var alternate_release := InputEventAction.new() + alternate_release.action = &"reel_alternate" + alternate_release.pressed = false + fishing_spot._unhandled_input(alternate_release) + assert(not bool(catch_controller.get("_reel_input_held"))) + fishing_spot.free() + local_player.free() + settings_panel.queue_free() panel.queue_free() + migrated_manager.queue_free() manager.queue_free() print("Keyboard and mouse mapping validation: PASS") quit(0) diff --git a/tests/light_performance_profile_validation.gd b/tests/light_performance_profile_validation.gd index 9421901..4533abb 100644 --- a/tests/light_performance_profile_validation.gd +++ b/tests/light_performance_profile_validation.gd @@ -43,6 +43,7 @@ func _run() -> void: await process_frame assert(bool(normal_main.get("_application_initialized"))) _validate_normal_profile(normal_main) + _validate_new_game_music_transition(normal_main) _stop_audio_players(normal_main) normal_main.queue_free() for _frame: int in 8: @@ -115,6 +116,7 @@ func _validate_main_profile(main: Node) -> void: assert(player_menu != null) assert(not player_menu.is_cooler_water_effect_enabled()) assert((main.get_node("%TitleMusic") as AudioStreamPlayer).stream != null) + assert((main.get_node("%NewGameMusic") as AudioStreamPlayer).stream != null) assert((main.get_node("%DuskMusic") as AudioStreamPlayer).stream != null) assert( (main.get_node("%WavesAudio") as AudioStreamPlayer).stream == null @@ -183,6 +185,7 @@ func _validate_normal_profile(main: Node) -> void: assert(clouds.get_node_or_null("CloudField") is MultiMeshInstance3D) assert(clouds.get_node_or_null("CloudCeiling") == null) assert((main.get_node("%TitleMusic") as AudioStreamPlayer).stream != null) + assert((main.get_node("%NewGameMusic") as AudioStreamPlayer).stream != null) assert((main.get_node("%DuskMusic") as AudioStreamPlayer).stream != null) assert( (main.get_node("%WavesAudio") as AudioStreamPlayer).stream != null @@ -192,6 +195,18 @@ func _validate_normal_profile(main: Node) -> void: ) +func _validate_new_game_music_transition(main: Node) -> void: + var title_music := main.get_node("%TitleMusic") as AudioStreamPlayer + var new_game_music := main.get_node("%NewGameMusic") as AudioStreamPlayer + assert(title_music.playing) + main.call("_start_new_game_music") + assert(not title_music.playing) + assert(new_game_music.playing) + main.call("_show_title_music", true) + assert(not new_game_music.playing) + assert(title_music.playing) + + func _first_fresh_water_visual(main: Node) -> MeshInstance3D: var root := main.get_node( "TestWorld/Regions/GeneratedWorldRegion/WaterBodies/FreshWaterBodies" diff --git a/tests/manual/terrain_chunk_generator/terrain_chunk_compatibility_report.gd b/tests/manual/terrain_chunk_generator/terrain_chunk_compatibility_report.gd index 64d5f1f..94b557e 100644 --- a/tests/manual/terrain_chunk_generator/terrain_chunk_compatibility_report.gd +++ b/tests/manual/terrain_chunk_generator/terrain_chunk_compatibility_report.gd @@ -119,6 +119,8 @@ func _make_generator() -> TerrainChunkGenerator: "chunk_0005", "chunk_0006", "chunk_0007", + "chunk_0008", ] ) + generator.grass_sand_smoothing_enabled = true return generator diff --git a/tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.tscn b/tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.tscn index 964da9a..34a74e2 100644 --- a/tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.tscn +++ b/tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.tscn @@ -21,12 +21,14 @@ shadow_enabled = true [node name="TerrainChunkGenerator" type="Node3D" parent="."] script = ExtResource("2_generator") catalog = ExtResource("3_catalog") -grid_size = Vector2i(7, 7) +grid_size = Vector2i(11, 9) generation_seed = 13001 build_collision = true show_chunk_labels = false force_center_chunk_id = &"chunk_spawn" -required_chunk_ids = PackedStringArray("chunk_spawn", "chunk_0001", "chunk_0002", "chunk_0003", "chunk_0004", "chunk_0005", "chunk_0006", "chunk_0007") +required_chunk_ids = PackedStringArray("chunk_spawn", "chunk_0001", "chunk_0002", "chunk_0003", "chunk_0004", "chunk_0005", "chunk_0006", "chunk_0007", "chunk_0008") +grass_sand_smoothing_enabled = true +elevated_cliff_feature_enabled = true [node name="Player" parent="." instance=ExtResource("4_player")] position = Vector3(0, 0.3, 0) diff --git a/tests/player_experience_validation.gd b/tests/player_experience_validation.gd index 0c1a9f7..5e9dbef 100644 --- a/tests/player_experience_validation.gd +++ b/tests/player_experience_validation.gd @@ -143,7 +143,7 @@ func _validate_save_migration() -> void: version_five, 5, ) - assert(int(migrated.get("save_version", -1)) == 9) + assert(int(migrated.get("save_version", -1)) == 10) var experience_data: Dictionary = migrated.get("experience", {}) assert(int(experience_data.get("total_experience", -1)) == 0) var world_data: Dictionary = migrated.get("world", {}) diff --git a/tests/profile_multiplayer_validation.gd b/tests/profile_multiplayer_validation.gd index 669c155..ec7a669 100644 --- a/tests/profile_multiplayer_validation.gd +++ b/tests/profile_multiplayer_validation.gd @@ -152,15 +152,10 @@ func _run_client() -> void: var chat_service := main.get_node( "%NetworkChatService" ) as NetworkChatService - var history_deadline: int = Time.get_ticks_msec() + 8000 - while Time.get_ticks_msec() < history_deadline: - if chat_service.get_history().any( - func(message: Dictionary) -> bool: - return str(message.get("body", "")) == HOST_HISTORY_MESSAGE - ): - break - await process_frame - assert(chat_service.get_history().any( + # Player-hosted rooms deliberately send no prior scrollback to joining or + # reconnecting clients. Live messages sent after authentication still work. + await create_timer(0.5).timeout + assert(not chat_service.get_history().any( func(message: Dictionary) -> bool: return str(message.get("body", "")) == HOST_HISTORY_MESSAGE )) diff --git a/tests/progression_archive_validation.gd b/tests/progression_archive_validation.gd new file mode 100644 index 0000000..11fc51c --- /dev/null +++ b/tests/progression_archive_validation.gd @@ -0,0 +1,135 @@ +extends SceneTree + +const MainScene: PackedScene = preload("res://main/main.tscn") + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + 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"))) + + var save_manager := main.get("_save_manager") as PlayerSaveManager + var player := main.get("_player") as Player + var data_root := main.get("_data_root") as PlayerDataRoot + assert(save_manager != null and player != null and data_root != null) + assert(save_manager.initialize_new_game(864209)) + save_manager.set_autosave_enabled(true) + assert(player.wallet.restore_balance(4321)) + assert(player.bag.add_item(&"coffee")) + assert(save_manager.save_now()) + + var save_path: String = str(save_manager.get("_save_path")) + assert(save_path.ends_with("player/player_save.nfsave")) + assert(FileAccess.file_exists(save_path)) + assert(not FileAccess.file_exists(save_path.get_base_dir().path_join( + "player_save.json" + ))) + _assert_opaque(save_path) + var decoded: Dictionary = ProgressionSaveCodec.read_local_save(save_path) + assert(bool(decoded.get("ok", false))) + assert(int((decoded["data"] as Dictionary)["save_version"]) == 10) + + var archive_path: String = data_root.progression_backup_directory().path_join( + "progression-test.nfsave" + ) + var exported: Dictionary = save_manager.export_progression_archive( + archive_path + ) + assert(bool(exported.get("ok", false))) + assert(FileAccess.file_exists(archive_path)) + _assert_opaque(archive_path) + var inspected: Dictionary = save_manager.inspect_progression_archive( + archive_path + ) + assert(bool(inspected.get("ok", false))) + assert(int(inspected.get("wallet_balance", -1)) == 4321) + assert(int(inspected.get("world_seed", -1)) == 864209) + + assert(player.wallet.restore_balance(7)) + assert(save_manager.save_now()) + var imported: Dictionary = save_manager.import_progression_archive( + archive_path + ) + assert(bool(imported.get("ok", false))) + assert(save_manager.load_player_data()) + assert(player.wallet.get_balance() == 4321) + assert(save_manager.get_world_seed() == 864209) + + var legacy_data: Dictionary = ( + ProgressionSaveCodec.read_local_save(save_path)["data"] + ) + assert(save_manager.delete_progression_save()) + var legacy_path: String = save_path.get_base_dir().path_join( + "player_save.json" + ) + var legacy_file := FileAccess.open(legacy_path, FileAccess.WRITE) + assert(legacy_file != null) + legacy_file.store_string(JSON.stringify(legacy_data, "\t")) + legacy_file.close() + assert(save_manager.load_player_data()) + assert(FileAccess.file_exists(save_path)) + assert(not FileAccess.file_exists(legacy_path)) + _assert_opaque(save_path) + + 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 + ) + + var migrated_root: String = data_root.root_path.get_base_dir().path_join( + "progression-migrated-data" + ) + var data_migration: Dictionary = PortableDataMigration.migrate_active_to( + data_root, migrated_root + ) + assert(bool(data_migration.get("ok", false))) + var migrated_save: String = migrated_root.path_join( + "player/player_save.nfsave" + ) + assert(FileAccess.file_exists(migrated_save)) + assert(bool( + ProgressionSaveCodec.read_local_save(migrated_save).get("ok", false) + )) + + main.queue_free() + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout + print("Progression archive validation: PASS") + quit() + + +func _assert_opaque(path: String) -> void: + var bytes: PackedByteArray = PortableFileGuard.read_bytes(path) + assert(not bytes.is_empty()) + var encoded_hex: String = bytes.hex_encode() + assert("save_version".to_utf8_buffer().hex_encode() not in encoded_hex) + assert("wallet".to_utf8_buffer().hex_encode() not in encoded_hex) diff --git a/tests/progression_archive_validation.gd.uid b/tests/progression_archive_validation.gd.uid new file mode 100644 index 0000000..59699fd --- /dev/null +++ b/tests/progression_archive_validation.gd.uid @@ -0,0 +1 @@ +uid://ccl47ftc1cu5l diff --git a/tests/terrain_chunk_generator_validation.gd b/tests/terrain_chunk_generator_validation.gd index 0d70816..b583a1f 100644 --- a/tests/terrain_chunk_generator_validation.gd +++ b/tests/terrain_chunk_generator_validation.gd @@ -12,6 +12,15 @@ const EXPECTED_IDS: Array[String] = [ "chunk_0005", "chunk_0006", "chunk_0007", + "chunk_0008", + "chunk_0009", + "chunk_0010", + "chunk_0011", + "chunk_0012", + "chunk_0013", + "chunk_0014", + "chunk_0015", + "chunk_0016", "chunk_spawn", ] const REQUIRED_IDS: Array[String] = [ @@ -23,10 +32,11 @@ const REQUIRED_IDS: Array[String] = [ "chunk_0005", "chunk_0006", "chunk_0007", + "chunk_0008", ] const TEST_SEED := 13001 const CONNECTOR_STRESS_SEED := 287 -const EXPECTED_SOLVER_VARIANT_COUNT := 27 +const EXPECTED_SOLVER_VARIANT_COUNT := 35 const EXPECTED_VARIANT_COUNTS: Dictionary[StringName, int] = { &"chunk_0000": 4, &"chunk_0001": 4, @@ -36,6 +46,15 @@ const EXPECTED_VARIANT_COUNTS: Dictionary[StringName, int] = { &"chunk_0005": 4, &"chunk_0006": 4, &"chunk_0007": 4, + &"chunk_0008": 4, + &"chunk_0009": 4, + &"chunk_0010": 4, + &"chunk_0011": 4, + &"chunk_0012": 4, + &"chunk_0013": 4, + &"chunk_0014": 4, + &"chunk_0015": 4, + &"chunk_0016": 4, &"chunk_spawn": 1, } @@ -49,6 +68,7 @@ func _initialize() -> void: func _run() -> void: _validate_catalog() _validate_profiles() + _validate_second_tier_coastal_layout() _validate_generation() _finish() @@ -75,6 +95,15 @@ func _validate_catalog() -> void: var grass_ocean_edge := CATALOG.definition_for_id(&"chunk_0005") var grass_beach_transition := CATALOG.definition_for_id(&"chunk_0006") var grass_ocean_corner := CATALOG.definition_for_id(&"chunk_0007") + var beach_ocean_corner := CATALOG.definition_for_id(&"chunk_0008") + var grass_sand_diagonal := CATALOG.definition_for_id(&"chunk_0013") + var cliff_sea_edge := CATALOG.definition_for_id(&"chunk_0014") + var cliff_sea_corner := CATALOG.definition_for_id(&"chunk_0015") + var stream_variant := CATALOG.definition_for_id(&"chunk_0016") + var cliff_top := CATALOG.definition_for_id(&"chunk_0009") + var cliff_corner := CATALOG.definition_for_id(&"chunk_0010") + var cliff_edge := CATALOG.definition_for_id(&"chunk_0011") + var cliff_ramp := CATALOG.definition_for_id(&"chunk_0012") var spawn := CATALOG.definition_for_id(&"chunk_spawn") _check( ( @@ -84,6 +113,15 @@ func _validate_catalog() -> void: and grass_ocean_edge != null and grass_beach_transition != null and grass_ocean_corner != null + and beach_ocean_corner != null + and grass_sand_diagonal != null + and cliff_sea_edge != null + and cliff_sea_corner != null + and stream_variant != null + and cliff_top != null + and cliff_corner != null + and cliff_edge != null + and cliff_ramp != null and spawn != null ), "The authored biome-rule definitions must be available.", @@ -95,6 +133,15 @@ func _validate_catalog() -> void: and grass_ocean_edge != null and grass_beach_transition != null and grass_ocean_corner != null + and beach_ocean_corner != null + and grass_sand_diagonal != null + and cliff_sea_edge != null + and cliff_sea_corner != null + and stream_variant != null + and cliff_top != null + and cliff_corner != null + and cliff_edge != null + and cliff_ramp != null and spawn != null ): _check( @@ -193,6 +240,124 @@ func _validate_catalog() -> void: ), "Both corner land seams must accept straight grass ocean edges.", ) + _check( + beach_ocean_corner.ocean_facing_edges + == ( + (1 << int(TerrainChunkTopology.Edge.EAST)) + | (1 << int(TerrainChunkTopology.Edge.SOUTH)) + ), + "The authored beach corner must expose east and south to ocean.", + ) + _check( + beach_ocean_corner.minimum_required_neighbors == 2 + and ( + "beach_ocean_edge" + in beach_ocean_corner.required_neighbor_tags + ), + "The beach corner must join two straight beach ocean edges.", + ) + _check( + grass_sand_diagonal.must_be_interior + and not grass_sand_diagonal.participates_in_base_solver + and "mixed_surface" in grass_sand_diagonal.tags, + ( + "The grass-sand diagonal must remain an inland post-process " + + "mixed-surface chunk." + ), + ) + _check( + grass_sand_diagonal.surface_tags_for_edge( + TerrainChunkTopology.Edge.NORTH + ) == PackedStringArray(["sand"]) + and grass_sand_diagonal.surface_tags_for_edge( + TerrainChunkTopology.Edge.WEST + ) == PackedStringArray(["sand"]) + and grass_sand_diagonal.surface_tags_for_edge( + TerrainChunkTopology.Edge.EAST + ) == PackedStringArray(["grass"]) + and grass_sand_diagonal.surface_tags_for_edge( + TerrainChunkTopology.Edge.SOUTH + ) == PackedStringArray(["grass"]), + "The diagonal's authored edges must retain two sand and two grass sides.", + ) + var stream := CATALOG.definition_for_id(&"chunk_0003") + _check( + stream != null + and is_equal_approx(stream.selection_weight, 0.3) + and is_equal_approx(stream_variant.selection_weight, 0.3) + and stream_variant.tags == stream.tags + and stream_variant.allowed_neighbor_tags + == stream.allowed_neighbor_tags + and stream_variant.water_inlet_edges == stream.water_inlet_edges + and stream_variant.water_outlet_edges == stream.water_outlet_edges + and stream_variant.water_surface_size == stream.water_surface_size + and stream_variant.water_surface_offset + == stream.water_surface_offset, + ( + "Both stream visuals must share one combined selection weight " + + "and identical terrain/water behavior." + ), + ) + for cliff_definition: TerrainChunkDefinition in [ + cliff_top, + cliff_corner, + cliff_edge, + cliff_ramp, + ]: + _check( + cliff_definition.overlay_only + and cliff_definition.must_be_interior + and cliff_definition.base_layer_scene != null + and cliff_definition.base_layer_mesh_name == &"chunk_0000", + "Every second-tier piece must overlay level-one grass inland.", + ) + for coastal_cliff: TerrainChunkDefinition in [ + cliff_sea_edge, + cliff_sea_corner, + ]: + _check( + coastal_cliff.overlay_only + and coastal_cliff.base_layer_scene == null + and not coastal_cliff.must_be_interior + and coastal_cliff.prefers_map_boundary + and "coast" in coastal_cliff.tags + and "elevation_2_sea_edge" in coastal_cliff.tags, + ( + "Each second-tier sea piece must be a complete coastal " + + "overlay without an inland grass base." + ), + ) + _check( + cliff_sea_edge.ocean_facing_edges + == (1 << int(TerrainChunkTopology.Edge.EAST)), + "The second-tier sea edge must face east before rotation.", + ) + _check( + cliff_sea_corner.ocean_facing_edges + == ( + (1 << int(TerrainChunkTopology.Edge.EAST)) + | (1 << int(TerrainChunkTopology.Edge.SOUTH)) + ), + "The second-tier sea corner must face east and south before rotation.", + ) + _check( + cliff_sea_corner.minimum_required_neighbors == 2 + and ( + "elevation_2_sea_edge" + in cliff_sea_corner.required_neighbor_tags + ), + "The second-tier sea corner must join two straight sea edges.", + ) + _check( + cliff_top.minimum_required_neighbors == 4 + and "elevation_2" in cliff_top.required_neighbor_tags, + "The second-tier top must be enclosed on all four sides.", + ) + _check( + "ramp" in cliff_ramp.tags + and cliff_ramp.maximum_placements > 0, + "The second-tier ramp must remain available without being required.", + ) func _validate_profiles() -> void: @@ -228,6 +393,91 @@ func _validate_profiles() -> void: ) +func _validate_second_tier_coastal_layout() -> void: + var generator := TerrainChunkGenerator.new() + generator.catalog = CATALOG + generator.grid_size = Vector2i(4, 4) + generator.generate_on_ready = false + generator.build_collision = false + generator.force_center_chunk_id = &"" + root.add_child(generator) + var keys := PackedStringArray() + for row: int in generator.grid_size.y: + for column: int in generator.grid_size.x: + var at_north := row == 0 + var at_east := column == generator.grid_size.x - 1 + var at_south := row == generator.grid_size.y - 1 + var at_west := column == 0 + if at_north and at_west: + keys.append("chunk_0015@2") + elif at_north and at_east: + keys.append("chunk_0015@1") + elif at_south and at_east: + keys.append("chunk_0015@0") + elif at_south and at_west: + keys.append("chunk_0015@3") + elif at_north: + keys.append("chunk_0014@1") + elif at_east: + keys.append("chunk_0014@0") + elif at_south: + keys.append("chunk_0014@3") + elif at_west: + keys.append("chunk_0014@2") + else: + keys.append("chunk_0009@0") + _check( + generator.generate_from_placement_keys(keys), + ( + "The second-tier sea edge and corner must form a complete " + + "raised coastal perimeter." + ), + ) + if generator.placement_keys().size() == keys.size(): + _check( + _all_neighbor_edges_match(generator), + "Every raised coastal-perimeter seam must match.", + ) + _check( + _count_placements(generator.placement_keys(), "chunk_0014") == 8 + and _count_placements( + generator.placement_keys(), "chunk_0015" + ) == 4, + "The raised coastal perimeter must use its straight and corner pieces.", + ) + for record: Dictionary in generator.placement_records(): + var stable_id: StringName = record.get("stable_id", &"") + if stable_id not in [&"chunk_0014", &"chunk_0015"]: + continue + var coordinate: Vector2i = record.get( + "coordinate", Vector2i.ZERO + ) + var variant := _find_variant( + generator, + stable_id, + int(record.get("rotation_quarters", 0)), + ) + _check( + variant != null + and generator._variant_respects_ocean_boundary( + variant, coordinate + ), + "Every second-tier sea opening must face outside the terrain grid.", + ) + var generated := generator.get_generated_chunks_root() + if generated != null: + for child: Node in generated.get_children(): + var stable_id := StringName( + child.get_meta(&"terrain_chunk_id", &"") + ) + if stable_id in [&"chunk_0014", &"chunk_0015"]: + _check( + child.get_node_or_null("TerrainBaseLayer") == null, + "Second-tier sea pieces must not render inland grass over ocean.", + ) + generator.free() + + func _validate_generation() -> void: var first := _make_generator() root.add_child(first) @@ -243,6 +493,10 @@ func _validate_generation() -> void: _placements_contain(first_keys, stable_id), "Generated diagnostic must contain %s." % stable_id, ) + _check( + _placements_contain(first_keys, "chunk_0013"), + "Generated terrain must replace sharp grass-sand corners with diagonals.", + ) _check( _all_neighbor_edges_match(first), "Every generated neighboring edge must match.", @@ -351,8 +605,12 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void: ) var stream := CATALOG.definition_for_id(&"chunk_0003") - _check(stream != null, "The stream definition must be available.") - if stream == null: + var alternate_stream := CATALOG.definition_for_id(&"chunk_0016") + _check( + stream != null and alternate_stream != null, + "Both stream visual definitions must be available.", + ) + if stream == null or alternate_stream == null: return for quarter_turns: int in 4: var stream_variant := _find_variant( @@ -364,7 +622,16 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void: stream_variant != null, "The stream must support rotation %d." % quarter_turns, ) - if stream_variant == null: + var alternate_variant := _find_variant( + generator, + &"chunk_0016", + quarter_turns, + ) + _check( + alternate_variant != null, + "The alternate stream must support rotation %d." % quarter_turns, + ) + if stream_variant == null or alternate_variant == null: continue var expected_inlet := 1 << int(TerrainChunkTopology.rotated_edge( TerrainChunkTopology.Edge.NORTH, @@ -384,6 +651,25 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void: == expected_outlet, "The rotated stream outlet must follow its authored orientation.", ) + _check( + alternate_variant.rotated_edge_mask( + alternate_stream.water_inlet_edges + ) == expected_inlet + and alternate_variant.rotated_edge_mask( + alternate_stream.water_outlet_edges + ) == expected_outlet, + "The alternate stream connectors must rotate exactly like the original.", + ) + for edge_value: int in TerrainChunkTopology.Edge.values(): + var edge := edge_value as TerrainChunkTopology.Edge + _check( + stream_variant.profile(edge).signature( + TerrainChunkGenerator.CONSTRAINT_PROFILE_QUANTIZATION + ) == alternate_variant.profile(edge).signature( + TerrainChunkGenerator.CONSTRAINT_PROFILE_QUANTIZATION + ), + "Both stream visuals must expose identical terrain edge profiles.", + ) var beach := CATALOG.definition_for_id(&"chunk_0002") _check(beach != null, "The beach definition must be available.") @@ -443,11 +729,13 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void: var grass_ocean_edge := CATALOG.definition_for_id(&"chunk_0005") var grass_beach_transition := CATALOG.definition_for_id(&"chunk_0006") var grass_ocean_corner := CATALOG.definition_for_id(&"chunk_0007") + var beach_ocean_corner := CATALOG.definition_for_id(&"chunk_0008") _check( ( grass_ocean_edge != null and grass_beach_transition != null and grass_ocean_corner != null + and beach_ocean_corner != null ), "The authored coastline additions must be available.", ) @@ -455,6 +743,7 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void: grass_ocean_edge == null or grass_beach_transition == null or grass_ocean_corner == null + or beach_ocean_corner == null ): return var beach_zero := _find_variant(generator, &"chunk_0002", 0) @@ -462,12 +751,14 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void: var grass_edge_south := _find_variant(generator, &"chunk_0005", 3) var transition_zero := _find_variant(generator, &"chunk_0006", 0) var corner_zero := _find_variant(generator, &"chunk_0007", 0) + var beach_corner_zero := _find_variant(generator, &"chunk_0008", 0) _check( beach_zero != null and grass_edge_zero != null and grass_edge_south != null and transition_zero != null - and corner_zero != null, + and corner_zero != null + and beach_corner_zero != null, "The unrotated coastline variants must be available.", ) if ( @@ -476,6 +767,7 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void: and grass_edge_south != null and transition_zero != null and corner_zero != null + and beach_corner_zero != null ): _check( grass_edge_zero.profile( @@ -522,6 +814,80 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void: ), "The corner's west side must join a straight grass ocean edge.", ) + _check( + beach_corner_zero.profile(TerrainChunkTopology.Edge.NORTH).matches( + beach_zero.profile(TerrainChunkTopology.Edge.SOUTH), + generator.edge_match_tolerance, + ), + "The beach corner's north side must join a straight beach.", + ) + var beach_south := _find_variant(generator, &"chunk_0002", 3) + _check( + beach_south != null + and beach_corner_zero.profile( + TerrainChunkTopology.Edge.WEST + ).matches( + beach_south.profile(TerrainChunkTopology.Edge.EAST), + generator.edge_match_tolerance, + ), + "The beach corner's west side must join a rotated straight beach.", + ) + var cliff_top_zero := _find_variant(generator, &"chunk_0009", 0) + var cliff_sea_edge_zero := _find_variant(generator, &"chunk_0014", 0) + var cliff_sea_edge_south := _find_variant(generator, &"chunk_0014", 3) + var cliff_sea_corner_zero := _find_variant(generator, &"chunk_0015", 0) + _check( + cliff_top_zero != null + and cliff_sea_edge_zero != null + and cliff_sea_edge_south != null + and cliff_sea_corner_zero != null, + "The second-tier sea edge variants must be available.", + ) + if ( + cliff_top_zero != null + and cliff_sea_edge_zero != null + and cliff_sea_edge_south != null + and cliff_sea_corner_zero != null + ): + _check( + cliff_sea_edge_zero.profile( + TerrainChunkTopology.Edge.EAST + ).points.is_empty(), + "The second-tier sea edge must remain open on its ocean side.", + ) + _check( + cliff_sea_corner_zero.profile( + TerrainChunkTopology.Edge.EAST + ).points.is_empty() + and cliff_sea_corner_zero.profile( + TerrainChunkTopology.Edge.SOUTH + ).points.is_empty(), + "The second-tier sea corner must retain both ocean openings.", + ) + _check( + generator._edges_are_compatible( + cliff_sea_edge_zero, + TerrainChunkTopology.Edge.WEST, + cliff_top_zero, + TerrainChunkTopology.Edge.EAST, + ), + "The second-tier sea edge must join the raised grass top inland.", + ) + _check( + generator._edges_are_compatible( + cliff_sea_corner_zero, + TerrainChunkTopology.Edge.NORTH, + cliff_sea_edge_zero, + TerrainChunkTopology.Edge.SOUTH, + ) + and generator._edges_are_compatible( + cliff_sea_corner_zero, + TerrainChunkTopology.Edge.WEST, + cliff_sea_edge_south, + TerrainChunkTopology.Edge.EAST, + ), + "The second-tier sea corner must join two straight sea edges.", + ) var flat_grass := _find_variant(generator, &"chunk_0000", 0) var flat_sand := _find_variant(generator, &"chunk_0001", 0) _check( @@ -560,7 +926,58 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void: ), "No transition rotation may accept sand behind it.", ) - for stable_id: StringName in [&"chunk_0005", &"chunk_0006", &"chunk_0007"]: + for quarter_turns: int in 4: + var diagonal := _find_variant( + generator, + &"chunk_0013", + quarter_turns, + ) + _check( + diagonal != null, + "The grass-sand diagonal must support rotation %d." + % quarter_turns, + ) + if diagonal == null: + continue + for source_edge_value: int in TerrainChunkTopology.Edge.values(): + var source_edge := source_edge_value as TerrainChunkTopology.Edge + var edge := TerrainChunkTopology.rotated_edge( + source_edge, + quarter_turns, + ) + var neighbor_edge := TerrainChunkTopology.opposite_edge(edge) + var expects_sand := source_edge in [ + TerrainChunkTopology.Edge.NORTH, + TerrainChunkTopology.Edge.WEST, + ] + var expected := flat_sand if expects_sand else flat_grass + var rejected := flat_grass if expects_sand else flat_sand + _check( + generator._edges_are_compatible( + diagonal, + edge, + expected, + neighbor_edge, + ), + "Each rotated diagonal edge must accept its authored surface.", + ) + _check( + not generator._edges_are_compatible( + diagonal, + edge, + rejected, + neighbor_edge, + ), + "No rotated diagonal edge may accept the opposite surface.", + ) + for stable_id: StringName in [ + &"chunk_0005", + &"chunk_0006", + &"chunk_0007", + &"chunk_0008", + &"chunk_0014", + &"chunk_0015", + ]: for quarter_turns: int in 4: var coast_variant := _find_variant( generator, @@ -580,11 +997,12 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void: ), "%s must never place its ocean edge inside the map." % stable_id, ) - if stable_id != &"chunk_0007": + var coast_definition := CATALOG.definition_for_id(stable_id) + if coast_definition == null: continue var boundary_coordinate := Vector2i(3, 3) var ocean_edges := coast_variant.rotated_edge_mask( - grass_ocean_corner.ocean_facing_edges + coast_definition.ocean_facing_edges ) for edge_value: int in TerrainChunkTopology.Edge.values(): if (ocean_edges & (1 << edge_value)) == 0: @@ -603,15 +1021,15 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void: coast_variant, boundary_coordinate, ), - "Each grass corner rotation must fit its matching map corner.", + "Each coast rotation must fit its matching open map boundary.", ) func _validate_generation_summary(generator: TerrainChunkGenerator) -> void: var summary := generator._build_summary() _check( - int(summary.get("variant_count", 0)) == 33, - "The current catalog must expose all 33 authored rotations.", + int(summary.get("variant_count", 0)) == 69, + "The current catalog must expose all 69 authored rotations.", ) _check( int(summary.get("solver_variant_count", 0)) @@ -664,6 +1082,7 @@ func _make_generator() -> TerrainChunkGenerator: generator.build_collision = false generator.force_center_chunk_id = &"chunk_spawn" generator.required_chunk_ids = PackedStringArray(REQUIRED_IDS) + generator.grass_sand_smoothing_enabled = true generator.maximum_backtracks = 100000 return generator @@ -811,6 +1230,27 @@ func _validate_layout_rules(generator: TerrainChunkGenerator) -> void: straight_edge_neighbors == 2, "Every grass corner must join two straight grass ocean edges.", ) + if placement.definition.stable_id == &"chunk_0008": + var straight_beach_neighbors := 0 + for edge_value: int in TerrainChunkTopology.Edge.values(): + var neighbor_coordinate := ( + coordinate + + TerrainChunkTopology.grid_offset( + edge_value as TerrainChunkTopology.Edge + ) + ) + if not generator._coordinate_is_inside_grid(neighbor_coordinate): + continue + var neighbor := generator._placements[ + neighbor_coordinate.y * generator.grid_size.x + + neighbor_coordinate.x + ] + if neighbor.definition.stable_id == &"chunk_0002": + straight_beach_neighbors += 1 + _check( + straight_beach_neighbors == 2, + "Every beach corner must join two straight beach ocean edges.", + ) if placement.definition.stable_id != &"chunk_0001": continue var touches_coast := false 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/tools/blender/export_terrain_chunks.py b/tools/blender/export_terrain_chunks.py index 27b775d..02c7b95 100644 --- a/tools/blender/export_terrain_chunks.py +++ b/tools/blender/export_terrain_chunks.py @@ -282,10 +282,22 @@ def _discover_chunks(tolerance: float) -> list[ChunkSource]: # Coastline meshes may intentionally stop before the positive-X edge, # leaving the surrounding ocean visible. Positive X is the canonical # authored ocean direction; Godot supplies the quarter-turn variants. - allow_open_east_edge = "ocean_edge" in label + # Elevated inland cliff overlays also stop at their downhill edge, + # while second-tier sea pieces carry that edge down to ocean depth. + layered_cliff = "cliff_2" in label + allow_open_east_edge = ( + "ocean_edge" in label + or "sea_edge" in label + or "sea_corner" in label + or layered_cliff + ) # Corner pieces additionally leave canonical negative Y open. Their - # two marked ocean edges rotate together at runtime. - allow_open_south_edge = "ocean_edge_corner" in label + # two marked outer edges rotate together at runtime. + allow_open_south_edge = ( + "ocean_edge_corner" in label + or "sea_corner" in label + or (layered_cliff and "corner" in label) + ) bound_problems = _validate_bounds( bounds, tolerance, diff --git a/ui/chat_ui.gd b/ui/chat_ui.gd index c79d80c..0b26ffb 100644 --- a/ui/chat_ui.gd +++ b/ui/chat_ui.gd @@ -893,12 +893,14 @@ func _on_entry_text_submitted(_value: String) -> void: func _send() -> void: if _send_pending: return - var body := _entry.text - if body.strip_edges().is_empty(): + var body := NetworkChatProtocol.sanitize_body(_entry.text) + if body.is_empty(): close_chat() return + if _handle_editor_world_command(body): + return _send_pending = true - _pending_send_body = NetworkChatProtocol.sanitize_body(body) + _pending_send_body = body _entry.editable = false if not _service.send_local_message( body, @@ -912,6 +914,82 @@ func _send() -> void: _set_status("Sending…") +func _handle_editor_world_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. + 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) + var command: String = String(parts[0]).trim_prefix("/").to_lower() + var result: String = "" + if _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 + _entry.clear() + _flush_draft() + _set_status(result) + close_chat(true) + return true + + +func _apply_editor_time_command(parts: PackedStringArray) -> String: + if parts.size() != 2 or _world_time == null: + return "Usage: /time [dawn, day, dusk, night]" + var phase_name: String = String(parts[1]).to_lower() + var target_hour: float = -1.0 + match phase_name: + "dawn": + target_hour = WorldTimeServiceType.DAWN_START_HOUR + "day": + target_hour = WorldTimeServiceType.DAWN_END_HOUR + "dusk": + target_hour = WorldTimeServiceType.DUSK_START_HOUR + "night": + target_hour = WorldTimeServiceType.DUSK_END_HOUR + _: + return "Usage: /time [dawn, day, dusk, night]" + if not _world_time.set_authoritative_time(target_hour): + return "Editor world time could not be changed." + return "Editor time: %s (%s)." % [ + phase_name, + _world_time.get_clock_text(), + ] + + +func _apply_editor_weather_command(parts: PackedStringArray) -> String: + if parts.size() != 2 or _world_weather == null: + return "Usage: /weather [sunny, cloudy, rainy, foggy]" + var weather_name: String = String(parts[1]).to_lower() + var target_weather: WorldWeatherServiceType.Weather + match weather_name: + "clear", "sunny": + weather_name = "sunny" + target_weather = WorldWeatherServiceType.Weather.SUNNY + "cloudy": + target_weather = WorldWeatherServiceType.Weather.CLOUDY + "rainy": + target_weather = WorldWeatherServiceType.Weather.RAINY + "foggy": + target_weather = WorldWeatherServiceType.Weather.FOGGY + _: + return "Usage: /weather [sunny, cloudy, rainy, foggy]" + if not _world_weather.set_authoritative_weather(target_weather): + return "Editor world weather could not be changed." + return "Editor weather: %s." % weather_name + + func _on_local_message_confirmed(message: Dictionary) -> void: if ( not _send_pending diff --git a/ui/components/general_inventory_slot.gd b/ui/components/general_inventory_slot.gd index bdb132f..9204d0a 100644 --- a/ui/components/general_inventory_slot.gd +++ b/ui/components/general_inventory_slot.gd @@ -168,6 +168,12 @@ func _on_pressed() -> void: func _on_gui_input(event: InputEvent) -> void: var mouse_event := event as InputEventMouseButton + if mouse_event != null and mouse_event.pressed: + # Pointer clicks give Buttons keyboard focus after their GUI callback. + # Release that pointer-created focus on the next frame so a tooltip does + # not remain pinned after the pointer leaves. Controller navigation still + # keeps focus normally because it does not arrive as a mouse event. + call_deferred("_release_pointer_focus") if ( mouse_event != null and mouse_event.button_index == MOUSE_BUTTON_RIGHT @@ -179,6 +185,11 @@ func _on_gui_input(event: InputEvent) -> void: accept_event() +func _release_pointer_focus() -> void: + if has_focus(): + release_focus() + + func _set_context_hovered(active: bool) -> void: _context_hovered = active _update_context_presence() 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 9d9ab32..c8d6f34 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -214,6 +214,7 @@ var _controller_text_entry_is_open: Callable func _ready() -> void: + _prioritize_surface_drawing_pointer_input() _bite_prompt_button.pressed.connect(_on_bite_prompt_pressed) _apply_active_bait_indicator_style() _refresh_active_bait_indicator() @@ -487,6 +488,37 @@ 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 + # follow it in the tree to own their overlap while the art kit is active. + # Opening Chat deactivates surface drawing and hides the toolbar, returning + # the same area to Chat without any special-case pointer forwarding. + if ( + _surface_drawing_toolbar == null + or _chat_ui == null + or _surface_drawing_toolbar.get_parent() != _chat_ui.get_parent() + ): + return + var ui_root: Node = _surface_drawing_toolbar.get_parent() + var toolbar_index: int = _surface_drawing_toolbar.get_index() + var chat_index: int = _chat_ui.get_index() + if toolbar_index > chat_index: + return + # Removing the earlier toolbar shifts Chat one position toward the start. + ui_root.move_child(_surface_drawing_toolbar, chat_index) + + func _input(event: InputEvent) -> void: # The on-screen keyboard owns controller input while it is open. Its # overlay is processed before the UI beneath it and consumes the event. @@ -1264,6 +1296,7 @@ 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, @@ -1275,6 +1308,7 @@ func setup_data_and_identity( ]: panel.setup_data_and_identity( data_root, + progression_saves, identity_backups, player_identity, host_identity, @@ -1603,9 +1637,14 @@ func _can_toggle_gameplay_hud() -> bool: func _refresh_gameplay_hud_visibility() -> void: + var fishing_override_active: bool = ( + _gameplay_hud_hidden + and _fishing_spot != null + and _fishing_spot.is_fishing_sequence_active() + ) var show_world_hud: bool = ( _gameplay_ui_enabled - and not _gameplay_hud_hidden + and (not _gameplay_hud_hidden or fishing_override_active) and not _system_menu_open and not _player_menu_open and not _shop_open @@ -1660,6 +1699,7 @@ func set_storage_prompt_visible( _storage_prompt.visible = ( requested_visible and _gameplay_ui_enabled + and not _gameplay_hud_hidden and not _system_menu_open and not _player_menu_open and not _shop_open @@ -1697,6 +1737,7 @@ func set_shop_prompt_visible( _shop_prompt.visible = ( requested_visible and _gameplay_ui_enabled + and not _gameplay_hud_hidden and not _system_menu_open and not _player_menu_open and not _shop_open @@ -1923,6 +1964,7 @@ func _refresh_fishing_panel_visibility() -> void: _gameplay_ui_enabled and has_content ) + _refresh_gameplay_hud_visibility() func _on_bite_prompt_changed(prompt_visible: bool) -> void: diff --git a/ui/new_game_setup_page.gd b/ui/new_game_setup_page.gd new file mode 100644 index 0000000..68e2aa8 --- /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.\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. 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/on_screen_keyboard.gd b/ui/on_screen_keyboard.gd index a6ed070..ee7475c 100644 --- a/ui/on_screen_keyboard.gd +++ b/ui/on_screen_keyboard.gd @@ -430,7 +430,8 @@ func _show_native_keyboard_for(control: Control) -> void: func _hide_native_keyboard() -> void: - DisplayServer.virtual_keyboard_hide() + if DisplayServer.has_feature(DisplayServer.FEATURE_VIRTUAL_KEYBOARD): + DisplayServer.virtual_keyboard_hide() static func _line_edit_keyboard_type( diff --git a/ui/player_menu.gd b/ui/player_menu.gd index 263c5e4..3bb428e 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -1693,6 +1693,7 @@ func close_menu( _release_controller_ownership(false, true) get_viewport().gui_cancel_drag() _close_sale_confirmation() + _hide_inventory_context_tooltip() if reason in [ CloseReason.BITE_STARTED, CloseReason.WATER_RECOVERY, @@ -2497,6 +2498,8 @@ func _populate_tackle_column( owned.quantity, item.max_stack, &"QuantityBadge", + UtilityPageStyle.SUPPLY_BADGE_EDGE_MARGIN, + 0.0, ) button.pressed.connect(_select_tackle_item.bind(owned.item_id)) button.gui_input.connect( @@ -2540,6 +2543,8 @@ func _on_tackle_button_gui_input( button: Button, ) -> void: var mouse_event := event as InputEventMouseButton + if mouse_event != null and mouse_event.pressed: + call_deferred("_release_pointer_focus", button) if ( mouse_event == null or mouse_event.button_index != MOUSE_BUTTON_RIGHT @@ -2555,6 +2560,11 @@ func _on_tackle_button_gui_input( button.accept_event() +func _release_pointer_focus(control: Control) -> void: + if control != null and is_instance_valid(control) and control.has_focus(): + control.release_focus() + + func _configure_tackle_item_focus() -> void: if not is_node_ready(): return 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 b49db11..e93f0a2 100644 --- a/ui/settings_panel.gd +++ b/ui/settings_panel.gd @@ -125,6 +125,7 @@ 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 @@ -134,6 +135,8 @@ 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 @@ -143,6 +146,7 @@ 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: @@ -244,6 +248,8 @@ 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") ) @@ -337,6 +343,7 @@ 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, @@ -344,6 +351,7 @@ 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 @@ -611,6 +619,11 @@ 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 "" ) @@ -661,6 +674,122 @@ 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." diff --git a/ui/settings_panel.tscn b/ui/settings_panel.tscn index 2af0256..94f627c 100644 --- a/ui/settings_panel.tscn +++ b/ui/settings_panel.tscn @@ -567,7 +567,7 @@ size_flags_horizontal = 3 focus_mode = 2 focus_neighbor_right = NodePath("../ChangeDataFolder") focus_neighbor_top = NodePath("../../../../../../../../TabBar/DataTab") -focus_neighbor_bottom = NodePath("../../CopyPlayerFingerprint") +focus_neighbor_bottom = NodePath("../../ProgressionRow/ExportProgression") text = "open data folder" [node name="ChangeDataFolder" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/DataPage/DataScroll/DataButtons/FolderRow"] @@ -578,15 +578,41 @@ size_flags_horizontal = 3 focus_mode = 2 focus_neighbor_left = NodePath("../OpenDataFolder") focus_neighbor_top = NodePath("../../../../../../../../TabBar/DataTab") -focus_neighbor_bottom = NodePath("../../CopyPlayerFingerprint") +focus_neighbor_bottom = NodePath("../../ProgressionRow/ImportProgression") 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("../FolderRow/OpenDataFolder") +focus_neighbor_top = NodePath("../ProgressionRow/ExportProgression") focus_neighbor_bottom = NodePath("../PlayerIdentityRow/ExportPlayerIdentity") text = "copy player fingerprint" diff --git a/ui/title_screen.gd b/ui/title_screen.gd index 757f005..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." @@ -1295,7 +1339,7 @@ func _on_settings_applied() -> void: func _on_settings_closed() -> void: - pass + _refresh_save_inspection() func _on_settings_closing() -> void: diff --git a/ui/title_screen.tscn b/ui/title_screen.tscn index 323e689..90074a6 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") @@ -225,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.13.1-alpha" +text = "v0.14.0-alpha" horizontal_alignment = 1 [node name="Spacer" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"] @@ -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/ui/utility_page_style.gd b/ui/utility_page_style.gd index edaa837..e31f6ac 100644 --- a/ui/utility_page_style.gd +++ b/ui/utility_page_style.gd @@ -53,6 +53,7 @@ static func add_supply_quantity_badge( total: int, badge_name: StringName = &"SupplyQuantityBadge", top_margin: float = SUPPLY_BADGE_EDGE_MARGIN, + x_offset: float = SUPPLY_BADGE_X_OFFSET, ) -> Panel: var badge := Panel.new() badge.name = badge_name @@ -68,7 +69,7 @@ static func add_supply_quantity_badge( quantity_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER badge.add_child(quantity_label) configure_supply_quantity_badge( - badge, quantity_label, current, total, top_margin + badge, quantity_label, current, total, top_margin, x_offset ) return badge @@ -79,6 +80,7 @@ static func configure_supply_quantity_badge( current: int, total: int, top_margin: float = SUPPLY_BADGE_EDGE_MARGIN, + x_offset: float = SUPPLY_BADGE_X_OFFSET, ) -> void: var supply_text := supply_quantity_text(current, total) var text_width: float = TuffyFont.get_string_size( @@ -92,10 +94,10 @@ static func configure_supply_quantity_badge( ceilf(text_width + SUPPLY_BADGE_HORIZONTAL_PADDING), ) badge.offset_left = ( - -badge_width - SUPPLY_BADGE_EDGE_MARGIN + SUPPLY_BADGE_X_OFFSET + -badge_width - SUPPLY_BADGE_EDGE_MARGIN + x_offset ) badge.offset_top = top_margin - badge.offset_right = -SUPPLY_BADGE_EDGE_MARGIN + SUPPLY_BADGE_X_OFFSET + badge.offset_right = -SUPPLY_BADGE_EDGE_MARGIN + x_offset badge.offset_bottom = top_margin + SUPPLY_BADGE_HEIGHT badge.pivot_offset = Vector2( badge_width * 0.5, diff --git a/world/generation/biomes/definitions/biome_coast.tres b/world/generation/biomes/definitions/biome_coast.tres index d1864d3..635181b 100644 --- a/world/generation/biomes/definitions/biome_coast.tres +++ b/world/generation/biomes/definitions/biome_coast.tres @@ -17,4 +17,5 @@ script = ExtResource("1_definition") stable_id = &"biome_coast" label = "coast" required_chunk_tags = PackedStringArray("sand") +forbidden_chunk_tags = PackedStringArray("mixed_surface") prop_rules = Array[ExtResource("2_rule")]([SubResource("Rule_sand_tree")]) diff --git a/world/generation/biomes/definitions/biome_forest.tres b/world/generation/biomes/definitions/biome_forest.tres index 0d11be2..21cd724 100644 --- a/world/generation/biomes/definitions/biome_forest.tres +++ b/world/generation/biomes/definitions/biome_forest.tres @@ -6,10 +6,11 @@ [sub_resource type="Resource" id="Rule_grass_tree"] script = ExtResource("2_rule") procedural_group = &"grass_tree" -placement_chance = 9000 -adjacency_bonus = 1000 -maximum_density = 10000 +placement_chance = 8200 +adjacency_bonus = 1200 +maximum_density = 8500 minimum_placements = 2 +placement_attempts_per_chunk = 3 allowed_prop_ids = PackedStringArray("prop_tree_1", "prop_tree_2", "prop_tree_3") [sub_resource type="Resource" id="Rule_grass_detail"] @@ -26,6 +27,6 @@ script = ExtResource("1_definition") stable_id = &"biome_forest" label = "forest" required_chunk_tags = PackedStringArray("grass") -forbidden_chunk_tags = PackedStringArray("spawn") +forbidden_chunk_tags = PackedStringArray("spawn", "mixed_surface") region_weight = 1.4 prop_rules = Array[ExtResource("2_rule")]([SubResource("Rule_grass_tree"), SubResource("Rule_grass_detail")]) diff --git a/world/generation/biomes/definitions/biome_pine_forest.tres b/world/generation/biomes/definitions/biome_pine_forest.tres index 293acbc..eec39a4 100644 --- a/world/generation/biomes/definitions/biome_pine_forest.tres +++ b/world/generation/biomes/definitions/biome_pine_forest.tres @@ -6,10 +6,11 @@ [sub_resource type="Resource" id="Rule_grass_tree"] script = ExtResource("2_rule") procedural_group = &"grass_tree" -placement_chance = 9000 -adjacency_bonus = 1000 -maximum_density = 10000 +placement_chance = 8200 +adjacency_bonus = 1200 +maximum_density = 8500 minimum_placements = 2 +placement_attempts_per_chunk = 3 allowed_prop_ids = PackedStringArray("prop_pine") [sub_resource type="Resource" id="Rule_grass_detail"] @@ -26,6 +27,6 @@ script = ExtResource("1_definition") stable_id = &"biome_pine_forest" label = "pine forest" required_chunk_tags = PackedStringArray("grass") -forbidden_chunk_tags = PackedStringArray("spawn") +forbidden_chunk_tags = PackedStringArray("spawn", "mixed_surface") region_weight = 1.2 prop_rules = Array[ExtResource("2_rule")]([SubResource("Rule_grass_tree"), SubResource("Rule_grass_detail")]) diff --git a/world/generation/biomes/definitions/biome_plains.tres b/world/generation/biomes/definitions/biome_plains.tres index c979cb8..46b8c71 100644 --- a/world/generation/biomes/definitions/biome_plains.tres +++ b/world/generation/biomes/definitions/biome_plains.tres @@ -24,5 +24,6 @@ script = ExtResource("1_definition") stable_id = &"biome_plains" label = "plains" required_chunk_tags = PackedStringArray("grass") +forbidden_chunk_tags = PackedStringArray("mixed_surface") region_weight = 1.0 prop_rules = Array[ExtResource("2_rule")]([SubResource("Rule_grass_tree"), SubResource("Rule_grass_detail")]) diff --git a/world/generation/chunks/assets/chunk_0001.glb b/world/generation/chunks/assets/chunk_0001.glb index 0b9bc50..6014230 100644 Binary files a/world/generation/chunks/assets/chunk_0001.glb and b/world/generation/chunks/assets/chunk_0001.glb differ diff --git a/world/generation/chunks/assets/chunk_0002.glb b/world/generation/chunks/assets/chunk_0002.glb index db18d8c..2a36b6e 100644 Binary files a/world/generation/chunks/assets/chunk_0002.glb and b/world/generation/chunks/assets/chunk_0002.glb differ diff --git a/world/generation/chunks/assets/chunk_0003.glb b/world/generation/chunks/assets/chunk_0003.glb index 45708a3..a1996eb 100644 Binary files a/world/generation/chunks/assets/chunk_0003.glb and b/world/generation/chunks/assets/chunk_0003.glb differ diff --git a/world/generation/chunks/assets/chunk_0004.glb b/world/generation/chunks/assets/chunk_0004.glb index 62219b9..2839d03 100644 Binary files a/world/generation/chunks/assets/chunk_0004.glb and b/world/generation/chunks/assets/chunk_0004.glb differ diff --git a/world/generation/chunks/assets/chunk_0005_cliff_wall.png.import b/world/generation/chunks/assets/chunk_0005_cliff_wall.png.import index 0796279..51d8e11 100644 --- a/world/generation/chunks/assets/chunk_0005_cliff_wall.png.import +++ b/world/generation/chunks/assets/chunk_0005_cliff_wall.png.import @@ -28,7 +28,7 @@ compress/rdo_quality_loss=0.0 compress/hdr_compression=1 compress/normal_map=0 compress/channel_pack=0 -mipmaps/generate=true +mipmaps/generate=false mipmaps/limit=-1 roughness/mode=0 roughness/src_normal="" diff --git a/world/generation/chunks/assets/chunk_0005_grass_lite.png.import b/world/generation/chunks/assets/chunk_0005_grass_lite.png.import index c1b1dd6..01e84d4 100644 --- a/world/generation/chunks/assets/chunk_0005_grass_lite.png.import +++ b/world/generation/chunks/assets/chunk_0005_grass_lite.png.import @@ -28,7 +28,7 @@ compress/rdo_quality_loss=0.0 compress/hdr_compression=1 compress/normal_map=0 compress/channel_pack=0 -mipmaps/generate=true +mipmaps/generate=false mipmaps/limit=-1 roughness/mode=0 roughness/src_normal="" diff --git a/world/generation/chunks/assets/chunk_0006_cliff_wall.png.import b/world/generation/chunks/assets/chunk_0006_cliff_wall.png.import index 238e929..411a5b1 100644 --- a/world/generation/chunks/assets/chunk_0006_cliff_wall.png.import +++ b/world/generation/chunks/assets/chunk_0006_cliff_wall.png.import @@ -28,7 +28,7 @@ compress/rdo_quality_loss=0.0 compress/hdr_compression=1 compress/normal_map=0 compress/channel_pack=0 -mipmaps/generate=true +mipmaps/generate=false mipmaps/limit=-1 roughness/mode=0 roughness/src_normal="" diff --git a/world/generation/chunks/assets/chunk_0006_grass_lite.png.import b/world/generation/chunks/assets/chunk_0006_grass_lite.png.import index fb37b89..2d01a58 100644 --- a/world/generation/chunks/assets/chunk_0006_grass_lite.png.import +++ b/world/generation/chunks/assets/chunk_0006_grass_lite.png.import @@ -28,7 +28,7 @@ compress/rdo_quality_loss=0.0 compress/hdr_compression=1 compress/normal_map=0 compress/channel_pack=0 -mipmaps/generate=true +mipmaps/generate=false mipmaps/limit=-1 roughness/mode=0 roughness/src_normal="" diff --git a/world/generation/chunks/assets/chunk_0006_sand.png.import b/world/generation/chunks/assets/chunk_0006_sand.png.import index bc9f61a..d829b05 100644 --- a/world/generation/chunks/assets/chunk_0006_sand.png.import +++ b/world/generation/chunks/assets/chunk_0006_sand.png.import @@ -28,7 +28,7 @@ compress/rdo_quality_loss=0.0 compress/hdr_compression=1 compress/normal_map=0 compress/channel_pack=0 -mipmaps/generate=true +mipmaps/generate=false mipmaps/limit=-1 roughness/mode=0 roughness/src_normal="" diff --git a/world/generation/chunks/assets/chunk_0007_cliff_wall.png.import b/world/generation/chunks/assets/chunk_0007_cliff_wall.png.import index 007f60d..d78acc6 100644 --- a/world/generation/chunks/assets/chunk_0007_cliff_wall.png.import +++ b/world/generation/chunks/assets/chunk_0007_cliff_wall.png.import @@ -28,7 +28,7 @@ compress/rdo_quality_loss=0.0 compress/hdr_compression=1 compress/normal_map=0 compress/channel_pack=0 -mipmaps/generate=true +mipmaps/generate=false mipmaps/limit=-1 roughness/mode=0 roughness/src_normal="" diff --git a/world/generation/chunks/assets/chunk_0007_grass_lite.png.import b/world/generation/chunks/assets/chunk_0007_grass_lite.png.import index abe6b52..03e4dc5 100644 --- a/world/generation/chunks/assets/chunk_0007_grass_lite.png.import +++ b/world/generation/chunks/assets/chunk_0007_grass_lite.png.import @@ -28,7 +28,7 @@ compress/rdo_quality_loss=0.0 compress/hdr_compression=1 compress/normal_map=0 compress/channel_pack=0 -mipmaps/generate=true +mipmaps/generate=false mipmaps/limit=-1 roughness/mode=0 roughness/src_normal="" diff --git a/world/generation/chunks/assets/chunk_0008.glb b/world/generation/chunks/assets/chunk_0008.glb new file mode 100644 index 0000000..d785e65 Binary files /dev/null and b/world/generation/chunks/assets/chunk_0008.glb differ diff --git a/world/generation/chunks/assets/chunk_0008.glb.import b/world/generation/chunks/assets/chunk_0008.glb.import new file mode 100644 index 0000000..a090711 --- /dev/null +++ b/world/generation/chunks/assets/chunk_0008.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://bm1teitx5ijxl" +path="res://.godot/imported/chunk_0008.glb-0b0a6712e5384c3f72c65e6ef1fc12c4.scn" + +[deps] + +source_file="res://world/generation/chunks/assets/chunk_0008.glb" +dest_files=["res://.godot/imported/chunk_0008.glb-0b0a6712e5384c3f72c65e6ef1fc12c4.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=1 diff --git a/world/generation/chunks/assets/chunk_0008_sand.png b/world/generation/chunks/assets/chunk_0008_sand.png new file mode 100644 index 0000000..06f807a Binary files /dev/null and b/world/generation/chunks/assets/chunk_0008_sand.png differ diff --git a/world/generation/chunks/assets/chunk_0008_sand.png.import b/world/generation/chunks/assets/chunk_0008_sand.png.import new file mode 100644 index 0000000..9c135d8 --- /dev/null +++ b/world/generation/chunks/assets/chunk_0008_sand.png.import @@ -0,0 +1,45 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dj24j2d1u8712" +path.s3tc="res://.godot/imported/chunk_0008_sand.png-55d7cf4f540d9cfb7d4ba59e9f9f38da.s3tc.ctex" +path.etc2="res://.godot/imported/chunk_0008_sand.png-55d7cf4f540d9cfb7d4ba59e9f9f38da.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} +generator_parameters={ +"md5": "adfa66b8fe9da7f3450d3ddfd432b86a" +} + +[deps] + +source_file="res://world/generation/chunks/assets/chunk_0008_sand.png" +dest_files=["res://.godot/imported/chunk_0008_sand.png-55d7cf4f540d9cfb7d4ba59e9f9f38da.s3tc.ctex", "res://.godot/imported/chunk_0008_sand.png-55d7cf4f540d9cfb7d4ba59e9f9f38da.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/world/generation/chunks/assets/chunk_0009.glb b/world/generation/chunks/assets/chunk_0009.glb new file mode 100644 index 0000000..8c09d3a Binary files /dev/null and b/world/generation/chunks/assets/chunk_0009.glb differ diff --git a/world/generation/chunks/assets/chunk_0009.glb.import b/world/generation/chunks/assets/chunk_0009.glb.import new file mode 100644 index 0000000..72c9778 --- /dev/null +++ b/world/generation/chunks/assets/chunk_0009.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://hasempwso5gi" +path="res://.godot/imported/chunk_0009.glb-397f642bee3da14146c56fddf8e21369.scn" + +[deps] + +source_file="res://world/generation/chunks/assets/chunk_0009.glb" +dest_files=["res://.godot/imported/chunk_0009.glb-397f642bee3da14146c56fddf8e21369.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=1 diff --git a/world/generation/chunks/assets/chunk_0009_grass_lite.png b/world/generation/chunks/assets/chunk_0009_grass_lite.png new file mode 100644 index 0000000..52c7ccb Binary files /dev/null and b/world/generation/chunks/assets/chunk_0009_grass_lite.png differ diff --git a/world/generation/chunks/assets/chunk_0009_grass_lite.png.import b/world/generation/chunks/assets/chunk_0009_grass_lite.png.import new file mode 100644 index 0000000..be93d5e --- /dev/null +++ b/world/generation/chunks/assets/chunk_0009_grass_lite.png.import @@ -0,0 +1,45 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://djjyaoih5vcnd" +path.s3tc="res://.godot/imported/chunk_0009_grass_lite.png-5d49eb465b8a8f8739796ce13c646f7d.s3tc.ctex" +path.etc2="res://.godot/imported/chunk_0009_grass_lite.png-5d49eb465b8a8f8739796ce13c646f7d.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} +generator_parameters={ +"md5": "4dc1ac400d85d9b3e4f43a5af0a38ed4" +} + +[deps] + +source_file="res://world/generation/chunks/assets/chunk_0009_grass_lite.png" +dest_files=["res://.godot/imported/chunk_0009_grass_lite.png-5d49eb465b8a8f8739796ce13c646f7d.s3tc.ctex", "res://.godot/imported/chunk_0009_grass_lite.png-5d49eb465b8a8f8739796ce13c646f7d.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/world/generation/chunks/assets/chunk_0010.glb b/world/generation/chunks/assets/chunk_0010.glb new file mode 100644 index 0000000..302fb11 Binary files /dev/null and b/world/generation/chunks/assets/chunk_0010.glb differ diff --git a/world/generation/chunks/assets/chunk_0010.glb.import b/world/generation/chunks/assets/chunk_0010.glb.import new file mode 100644 index 0000000..a8039a0 --- /dev/null +++ b/world/generation/chunks/assets/chunk_0010.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://b5tf5e1g5auu7" +path="res://.godot/imported/chunk_0010.glb-6ffffa0064f5930acc433afe5e4334e4.scn" + +[deps] + +source_file="res://world/generation/chunks/assets/chunk_0010.glb" +dest_files=["res://.godot/imported/chunk_0010.glb-6ffffa0064f5930acc433afe5e4334e4.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=1 diff --git a/world/generation/chunks/assets/chunk_0010_cliff_wall.png b/world/generation/chunks/assets/chunk_0010_cliff_wall.png new file mode 100644 index 0000000..3fe157f Binary files /dev/null and b/world/generation/chunks/assets/chunk_0010_cliff_wall.png differ diff --git a/world/generation/chunks/assets/chunk_0010_cliff_wall.png.import b/world/generation/chunks/assets/chunk_0010_cliff_wall.png.import new file mode 100644 index 0000000..5899d3a --- /dev/null +++ b/world/generation/chunks/assets/chunk_0010_cliff_wall.png.import @@ -0,0 +1,45 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cwwraho3pj5aa" +path.s3tc="res://.godot/imported/chunk_0010_cliff_wall.png-78830d69b22e3c32cc295e574d596c3a.s3tc.ctex" +path.etc2="res://.godot/imported/chunk_0010_cliff_wall.png-78830d69b22e3c32cc295e574d596c3a.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} +generator_parameters={ +"md5": "fc97184f537cc23f83a731327e10c836" +} + +[deps] + +source_file="res://world/generation/chunks/assets/chunk_0010_cliff_wall.png" +dest_files=["res://.godot/imported/chunk_0010_cliff_wall.png-78830d69b22e3c32cc295e574d596c3a.s3tc.ctex", "res://.godot/imported/chunk_0010_cliff_wall.png-78830d69b22e3c32cc295e574d596c3a.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/world/generation/chunks/assets/chunk_0010_grass_lite.png b/world/generation/chunks/assets/chunk_0010_grass_lite.png new file mode 100644 index 0000000..52c7ccb Binary files /dev/null and b/world/generation/chunks/assets/chunk_0010_grass_lite.png differ diff --git a/world/generation/chunks/assets/chunk_0010_grass_lite.png.import b/world/generation/chunks/assets/chunk_0010_grass_lite.png.import new file mode 100644 index 0000000..7cd6b3d --- /dev/null +++ b/world/generation/chunks/assets/chunk_0010_grass_lite.png.import @@ -0,0 +1,45 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://q7k2r4mb3bsr" +path.s3tc="res://.godot/imported/chunk_0010_grass_lite.png-694b30e6e36cc16c1cf98b2f3823a0c2.s3tc.ctex" +path.etc2="res://.godot/imported/chunk_0010_grass_lite.png-694b30e6e36cc16c1cf98b2f3823a0c2.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} +generator_parameters={ +"md5": "4dc1ac400d85d9b3e4f43a5af0a38ed4" +} + +[deps] + +source_file="res://world/generation/chunks/assets/chunk_0010_grass_lite.png" +dest_files=["res://.godot/imported/chunk_0010_grass_lite.png-694b30e6e36cc16c1cf98b2f3823a0c2.s3tc.ctex", "res://.godot/imported/chunk_0010_grass_lite.png-694b30e6e36cc16c1cf98b2f3823a0c2.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/world/generation/chunks/assets/chunk_0011.glb b/world/generation/chunks/assets/chunk_0011.glb new file mode 100644 index 0000000..85f73ed Binary files /dev/null and b/world/generation/chunks/assets/chunk_0011.glb differ diff --git a/world/generation/chunks/assets/chunk_0011.glb.import b/world/generation/chunks/assets/chunk_0011.glb.import new file mode 100644 index 0000000..86ee993 --- /dev/null +++ b/world/generation/chunks/assets/chunk_0011.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://cym6ilrdikjcj" +path="res://.godot/imported/chunk_0011.glb-cda3ab701cf5e469b53437c043314d05.scn" + +[deps] + +source_file="res://world/generation/chunks/assets/chunk_0011.glb" +dest_files=["res://.godot/imported/chunk_0011.glb-cda3ab701cf5e469b53437c043314d05.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=1 diff --git a/world/generation/chunks/assets/chunk_0011_cliff_wall.png b/world/generation/chunks/assets/chunk_0011_cliff_wall.png new file mode 100644 index 0000000..3fe157f Binary files /dev/null and b/world/generation/chunks/assets/chunk_0011_cliff_wall.png differ diff --git a/world/generation/chunks/assets/chunk_0011_cliff_wall.png.import b/world/generation/chunks/assets/chunk_0011_cliff_wall.png.import new file mode 100644 index 0000000..423ad9c --- /dev/null +++ b/world/generation/chunks/assets/chunk_0011_cliff_wall.png.import @@ -0,0 +1,45 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dggc1jbgenl11" +path.s3tc="res://.godot/imported/chunk_0011_cliff_wall.png-9a4d64a03d75f23c2ef3614386484a4e.s3tc.ctex" +path.etc2="res://.godot/imported/chunk_0011_cliff_wall.png-9a4d64a03d75f23c2ef3614386484a4e.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} +generator_parameters={ +"md5": "fc97184f537cc23f83a731327e10c836" +} + +[deps] + +source_file="res://world/generation/chunks/assets/chunk_0011_cliff_wall.png" +dest_files=["res://.godot/imported/chunk_0011_cliff_wall.png-9a4d64a03d75f23c2ef3614386484a4e.s3tc.ctex", "res://.godot/imported/chunk_0011_cliff_wall.png-9a4d64a03d75f23c2ef3614386484a4e.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/world/generation/chunks/assets/chunk_0011_grass_lite.png b/world/generation/chunks/assets/chunk_0011_grass_lite.png new file mode 100644 index 0000000..52c7ccb Binary files /dev/null and b/world/generation/chunks/assets/chunk_0011_grass_lite.png differ diff --git a/world/generation/chunks/assets/chunk_0011_grass_lite.png.import b/world/generation/chunks/assets/chunk_0011_grass_lite.png.import new file mode 100644 index 0000000..7a3b48d --- /dev/null +++ b/world/generation/chunks/assets/chunk_0011_grass_lite.png.import @@ -0,0 +1,45 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bhuhcaowvcyrk" +path.s3tc="res://.godot/imported/chunk_0011_grass_lite.png-3a0a1a7ad2d55bd853b238730af91a7c.s3tc.ctex" +path.etc2="res://.godot/imported/chunk_0011_grass_lite.png-3a0a1a7ad2d55bd853b238730af91a7c.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} +generator_parameters={ +"md5": "4dc1ac400d85d9b3e4f43a5af0a38ed4" +} + +[deps] + +source_file="res://world/generation/chunks/assets/chunk_0011_grass_lite.png" +dest_files=["res://.godot/imported/chunk_0011_grass_lite.png-3a0a1a7ad2d55bd853b238730af91a7c.s3tc.ctex", "res://.godot/imported/chunk_0011_grass_lite.png-3a0a1a7ad2d55bd853b238730af91a7c.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/world/generation/chunks/assets/chunk_0012.glb b/world/generation/chunks/assets/chunk_0012.glb new file mode 100644 index 0000000..bbde6a3 Binary files /dev/null and b/world/generation/chunks/assets/chunk_0012.glb differ diff --git a/world/generation/chunks/assets/chunk_0012.glb.import b/world/generation/chunks/assets/chunk_0012.glb.import new file mode 100644 index 0000000..65fad11 --- /dev/null +++ b/world/generation/chunks/assets/chunk_0012.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://c4i32u0bdtix5" +path="res://.godot/imported/chunk_0012.glb-1c818ae539bef19d5ca4fbe3ace96887.scn" + +[deps] + +source_file="res://world/generation/chunks/assets/chunk_0012.glb" +dest_files=["res://.godot/imported/chunk_0012.glb-1c818ae539bef19d5ca4fbe3ace96887.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=1 diff --git a/world/generation/chunks/assets/chunk_0012_cliff_wall.png b/world/generation/chunks/assets/chunk_0012_cliff_wall.png new file mode 100644 index 0000000..3fe157f Binary files /dev/null and b/world/generation/chunks/assets/chunk_0012_cliff_wall.png differ diff --git a/world/generation/chunks/assets/chunk_0012_cliff_wall.png.import b/world/generation/chunks/assets/chunk_0012_cliff_wall.png.import new file mode 100644 index 0000000..69a239c --- /dev/null +++ b/world/generation/chunks/assets/chunk_0012_cliff_wall.png.import @@ -0,0 +1,45 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cf27tcolkmfog" +path.s3tc="res://.godot/imported/chunk_0012_cliff_wall.png-3d27d4041790b1c3dbc838d6a0c94d40.s3tc.ctex" +path.etc2="res://.godot/imported/chunk_0012_cliff_wall.png-3d27d4041790b1c3dbc838d6a0c94d40.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} +generator_parameters={ +"md5": "fc97184f537cc23f83a731327e10c836" +} + +[deps] + +source_file="res://world/generation/chunks/assets/chunk_0012_cliff_wall.png" +dest_files=["res://.godot/imported/chunk_0012_cliff_wall.png-3d27d4041790b1c3dbc838d6a0c94d40.s3tc.ctex", "res://.godot/imported/chunk_0012_cliff_wall.png-3d27d4041790b1c3dbc838d6a0c94d40.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/world/generation/chunks/assets/chunk_0012_dirt.png b/world/generation/chunks/assets/chunk_0012_dirt.png new file mode 100644 index 0000000..588c8c6 Binary files /dev/null and b/world/generation/chunks/assets/chunk_0012_dirt.png differ diff --git a/world/generation/chunks/assets/chunk_0012_dirt.png.import b/world/generation/chunks/assets/chunk_0012_dirt.png.import new file mode 100644 index 0000000..c81e741 --- /dev/null +++ b/world/generation/chunks/assets/chunk_0012_dirt.png.import @@ -0,0 +1,45 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c3f1w276lmg7i" +path.s3tc="res://.godot/imported/chunk_0012_dirt.png-5c3a2a10642f9d078ad1d1c6333d8264.s3tc.ctex" +path.etc2="res://.godot/imported/chunk_0012_dirt.png-5c3a2a10642f9d078ad1d1c6333d8264.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} +generator_parameters={ +"md5": "07d90b3c46a4c3d0c172bed143dc9436" +} + +[deps] + +source_file="res://world/generation/chunks/assets/chunk_0012_dirt.png" +dest_files=["res://.godot/imported/chunk_0012_dirt.png-5c3a2a10642f9d078ad1d1c6333d8264.s3tc.ctex", "res://.godot/imported/chunk_0012_dirt.png-5c3a2a10642f9d078ad1d1c6333d8264.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/world/generation/chunks/assets/chunk_0012_grass_lite.png b/world/generation/chunks/assets/chunk_0012_grass_lite.png new file mode 100644 index 0000000..52c7ccb Binary files /dev/null and b/world/generation/chunks/assets/chunk_0012_grass_lite.png differ diff --git a/world/generation/chunks/assets/chunk_0012_grass_lite.png.import b/world/generation/chunks/assets/chunk_0012_grass_lite.png.import new file mode 100644 index 0000000..971d350 --- /dev/null +++ b/world/generation/chunks/assets/chunk_0012_grass_lite.png.import @@ -0,0 +1,45 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://lv8xn2moas2s" +path.s3tc="res://.godot/imported/chunk_0012_grass_lite.png-ccfdc53acb9a967eb5d00e669258fc3c.s3tc.ctex" +path.etc2="res://.godot/imported/chunk_0012_grass_lite.png-ccfdc53acb9a967eb5d00e669258fc3c.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} +generator_parameters={ +"md5": "4dc1ac400d85d9b3e4f43a5af0a38ed4" +} + +[deps] + +source_file="res://world/generation/chunks/assets/chunk_0012_grass_lite.png" +dest_files=["res://.godot/imported/chunk_0012_grass_lite.png-ccfdc53acb9a967eb5d00e669258fc3c.s3tc.ctex", "res://.godot/imported/chunk_0012_grass_lite.png-ccfdc53acb9a967eb5d00e669258fc3c.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/world/generation/chunks/assets/chunk_0013.glb b/world/generation/chunks/assets/chunk_0013.glb new file mode 100644 index 0000000..b5aa63c Binary files /dev/null and b/world/generation/chunks/assets/chunk_0013.glb differ diff --git a/world/generation/chunks/assets/chunk_0013.glb.import b/world/generation/chunks/assets/chunk_0013.glb.import new file mode 100644 index 0000000..b63b3e4 --- /dev/null +++ b/world/generation/chunks/assets/chunk_0013.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://dom2fvsn0s1wa" +path="res://.godot/imported/chunk_0013.glb-64cf2d2bb9c9020fa3d06004b1963f09.scn" + +[deps] + +source_file="res://world/generation/chunks/assets/chunk_0013.glb" +dest_files=["res://.godot/imported/chunk_0013.glb-64cf2d2bb9c9020fa3d06004b1963f09.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=1 diff --git a/world/generation/chunks/assets/chunk_0013_grass_lite.png b/world/generation/chunks/assets/chunk_0013_grass_lite.png new file mode 100644 index 0000000..52c7ccb Binary files /dev/null and b/world/generation/chunks/assets/chunk_0013_grass_lite.png differ diff --git a/world/generation/chunks/assets/chunk_0013_grass_lite.png.import b/world/generation/chunks/assets/chunk_0013_grass_lite.png.import new file mode 100644 index 0000000..95218ff --- /dev/null +++ b/world/generation/chunks/assets/chunk_0013_grass_lite.png.import @@ -0,0 +1,45 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dct65l02vr54q" +path.s3tc="res://.godot/imported/chunk_0013_grass_lite.png-0cf332d69bdd6f5da588e0ddda3bd437.s3tc.ctex" +path.etc2="res://.godot/imported/chunk_0013_grass_lite.png-0cf332d69bdd6f5da588e0ddda3bd437.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} +generator_parameters={ +"md5": "4dc1ac400d85d9b3e4f43a5af0a38ed4" +} + +[deps] + +source_file="res://world/generation/chunks/assets/chunk_0013_grass_lite.png" +dest_files=["res://.godot/imported/chunk_0013_grass_lite.png-0cf332d69bdd6f5da588e0ddda3bd437.s3tc.ctex", "res://.godot/imported/chunk_0013_grass_lite.png-0cf332d69bdd6f5da588e0ddda3bd437.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/world/generation/chunks/assets/chunk_0013_sand.png b/world/generation/chunks/assets/chunk_0013_sand.png new file mode 100644 index 0000000..06f807a Binary files /dev/null and b/world/generation/chunks/assets/chunk_0013_sand.png differ diff --git a/world/generation/chunks/assets/chunk_0013_sand.png.import b/world/generation/chunks/assets/chunk_0013_sand.png.import new file mode 100644 index 0000000..cd4178a --- /dev/null +++ b/world/generation/chunks/assets/chunk_0013_sand.png.import @@ -0,0 +1,45 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://btjhnb6owbjj2" +path.s3tc="res://.godot/imported/chunk_0013_sand.png-5fd756543742405c3864f1fdfb36632c.s3tc.ctex" +path.etc2="res://.godot/imported/chunk_0013_sand.png-5fd756543742405c3864f1fdfb36632c.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} +generator_parameters={ +"md5": "adfa66b8fe9da7f3450d3ddfd432b86a" +} + +[deps] + +source_file="res://world/generation/chunks/assets/chunk_0013_sand.png" +dest_files=["res://.godot/imported/chunk_0013_sand.png-5fd756543742405c3864f1fdfb36632c.s3tc.ctex", "res://.godot/imported/chunk_0013_sand.png-5fd756543742405c3864f1fdfb36632c.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/world/generation/chunks/assets/chunk_0014.glb b/world/generation/chunks/assets/chunk_0014.glb new file mode 100644 index 0000000..13eef5a Binary files /dev/null and b/world/generation/chunks/assets/chunk_0014.glb differ diff --git a/world/generation/chunks/assets/chunk_0014.glb.import b/world/generation/chunks/assets/chunk_0014.glb.import new file mode 100644 index 0000000..3d4bcb8 --- /dev/null +++ b/world/generation/chunks/assets/chunk_0014.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://50aolv5pg8d2" +path="res://.godot/imported/chunk_0014.glb-636ccc776ff9575e58a9db68c3f273af.scn" + +[deps] + +source_file="res://world/generation/chunks/assets/chunk_0014.glb" +dest_files=["res://.godot/imported/chunk_0014.glb-636ccc776ff9575e58a9db68c3f273af.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=1 diff --git a/world/generation/chunks/assets/chunk_0014_cliff_wall.png b/world/generation/chunks/assets/chunk_0014_cliff_wall.png new file mode 100644 index 0000000..3fe157f Binary files /dev/null and b/world/generation/chunks/assets/chunk_0014_cliff_wall.png differ diff --git a/world/generation/chunks/assets/chunk_0014_cliff_wall.png.import b/world/generation/chunks/assets/chunk_0014_cliff_wall.png.import new file mode 100644 index 0000000..45e466a --- /dev/null +++ b/world/generation/chunks/assets/chunk_0014_cliff_wall.png.import @@ -0,0 +1,45 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://6sd1uj06ti8f" +path.s3tc="res://.godot/imported/chunk_0014_cliff_wall.png-9ea694f83659f89e6c17ae1a39cf97cb.s3tc.ctex" +path.etc2="res://.godot/imported/chunk_0014_cliff_wall.png-9ea694f83659f89e6c17ae1a39cf97cb.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} +generator_parameters={ +"md5": "fc97184f537cc23f83a731327e10c836" +} + +[deps] + +source_file="res://world/generation/chunks/assets/chunk_0014_cliff_wall.png" +dest_files=["res://.godot/imported/chunk_0014_cliff_wall.png-9ea694f83659f89e6c17ae1a39cf97cb.s3tc.ctex", "res://.godot/imported/chunk_0014_cliff_wall.png-9ea694f83659f89e6c17ae1a39cf97cb.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/world/generation/chunks/assets/chunk_0014_grass_lite.png b/world/generation/chunks/assets/chunk_0014_grass_lite.png new file mode 100644 index 0000000..52c7ccb Binary files /dev/null and b/world/generation/chunks/assets/chunk_0014_grass_lite.png differ diff --git a/world/generation/chunks/assets/chunk_0014_grass_lite.png.import b/world/generation/chunks/assets/chunk_0014_grass_lite.png.import new file mode 100644 index 0000000..d4c09fe --- /dev/null +++ b/world/generation/chunks/assets/chunk_0014_grass_lite.png.import @@ -0,0 +1,45 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b6fftn8x2o1dd" +path.s3tc="res://.godot/imported/chunk_0014_grass_lite.png-90b3bea1cb09cf8dbae98afc39c1f5cd.s3tc.ctex" +path.etc2="res://.godot/imported/chunk_0014_grass_lite.png-90b3bea1cb09cf8dbae98afc39c1f5cd.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} +generator_parameters={ +"md5": "4dc1ac400d85d9b3e4f43a5af0a38ed4" +} + +[deps] + +source_file="res://world/generation/chunks/assets/chunk_0014_grass_lite.png" +dest_files=["res://.godot/imported/chunk_0014_grass_lite.png-90b3bea1cb09cf8dbae98afc39c1f5cd.s3tc.ctex", "res://.godot/imported/chunk_0014_grass_lite.png-90b3bea1cb09cf8dbae98afc39c1f5cd.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/world/generation/chunks/assets/chunk_0015.glb b/world/generation/chunks/assets/chunk_0015.glb new file mode 100644 index 0000000..2c42918 Binary files /dev/null and b/world/generation/chunks/assets/chunk_0015.glb differ diff --git a/world/generation/chunks/assets/chunk_0015.glb.import b/world/generation/chunks/assets/chunk_0015.glb.import new file mode 100644 index 0000000..7078d3b --- /dev/null +++ b/world/generation/chunks/assets/chunk_0015.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://d26fd6sjsh50x" +path="res://.godot/imported/chunk_0015.glb-0a28f27956d19c7965f7bc9c838f1b29.scn" + +[deps] + +source_file="res://world/generation/chunks/assets/chunk_0015.glb" +dest_files=["res://.godot/imported/chunk_0015.glb-0a28f27956d19c7965f7bc9c838f1b29.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=1 diff --git a/world/generation/chunks/assets/chunk_0015_cliff_wall.png b/world/generation/chunks/assets/chunk_0015_cliff_wall.png new file mode 100644 index 0000000..3fe157f Binary files /dev/null and b/world/generation/chunks/assets/chunk_0015_cliff_wall.png differ diff --git a/world/generation/chunks/assets/chunk_0015_cliff_wall.png.import b/world/generation/chunks/assets/chunk_0015_cliff_wall.png.import new file mode 100644 index 0000000..560f5b4 --- /dev/null +++ b/world/generation/chunks/assets/chunk_0015_cliff_wall.png.import @@ -0,0 +1,45 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cpce75ti5cwnw" +path.s3tc="res://.godot/imported/chunk_0015_cliff_wall.png-08e30f267a5816ed94aa39dc21ccf05c.s3tc.ctex" +path.etc2="res://.godot/imported/chunk_0015_cliff_wall.png-08e30f267a5816ed94aa39dc21ccf05c.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} +generator_parameters={ +"md5": "fc97184f537cc23f83a731327e10c836" +} + +[deps] + +source_file="res://world/generation/chunks/assets/chunk_0015_cliff_wall.png" +dest_files=["res://.godot/imported/chunk_0015_cliff_wall.png-08e30f267a5816ed94aa39dc21ccf05c.s3tc.ctex", "res://.godot/imported/chunk_0015_cliff_wall.png-08e30f267a5816ed94aa39dc21ccf05c.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/world/generation/chunks/assets/chunk_0015_grass_lite.png b/world/generation/chunks/assets/chunk_0015_grass_lite.png new file mode 100644 index 0000000..52c7ccb Binary files /dev/null and b/world/generation/chunks/assets/chunk_0015_grass_lite.png differ diff --git a/world/generation/chunks/assets/chunk_0015_grass_lite.png.import b/world/generation/chunks/assets/chunk_0015_grass_lite.png.import new file mode 100644 index 0000000..1ad5aa3 --- /dev/null +++ b/world/generation/chunks/assets/chunk_0015_grass_lite.png.import @@ -0,0 +1,45 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://pmaf2oetdqln" +path.s3tc="res://.godot/imported/chunk_0015_grass_lite.png-0ae072e9d3b6d68d7fdb493a6acf3bad.s3tc.ctex" +path.etc2="res://.godot/imported/chunk_0015_grass_lite.png-0ae072e9d3b6d68d7fdb493a6acf3bad.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} +generator_parameters={ +"md5": "4dc1ac400d85d9b3e4f43a5af0a38ed4" +} + +[deps] + +source_file="res://world/generation/chunks/assets/chunk_0015_grass_lite.png" +dest_files=["res://.godot/imported/chunk_0015_grass_lite.png-0ae072e9d3b6d68d7fdb493a6acf3bad.s3tc.ctex", "res://.godot/imported/chunk_0015_grass_lite.png-0ae072e9d3b6d68d7fdb493a6acf3bad.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/world/generation/chunks/assets/chunk_0016.glb b/world/generation/chunks/assets/chunk_0016.glb new file mode 100644 index 0000000..f4428f1 Binary files /dev/null and b/world/generation/chunks/assets/chunk_0016.glb differ diff --git a/world/generation/chunks/assets/chunk_0016.glb.import b/world/generation/chunks/assets/chunk_0016.glb.import new file mode 100644 index 0000000..bc947f3 --- /dev/null +++ b/world/generation/chunks/assets/chunk_0016.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://te1yrm2uhin1" +path="res://.godot/imported/chunk_0016.glb-6904d8b3583dd301bda1835c501225f3.scn" + +[deps] + +source_file="res://world/generation/chunks/assets/chunk_0016.glb" +dest_files=["res://.godot/imported/chunk_0016.glb-6904d8b3583dd301bda1835c501225f3.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=1 diff --git a/world/generation/chunks/assets/chunk_0016_dirt.png b/world/generation/chunks/assets/chunk_0016_dirt.png new file mode 100644 index 0000000..588c8c6 Binary files /dev/null and b/world/generation/chunks/assets/chunk_0016_dirt.png differ diff --git a/world/generation/chunks/assets/chunk_0016_dirt.png.import b/world/generation/chunks/assets/chunk_0016_dirt.png.import new file mode 100644 index 0000000..00b774f --- /dev/null +++ b/world/generation/chunks/assets/chunk_0016_dirt.png.import @@ -0,0 +1,45 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dxy8u3vyyu2on" +path.s3tc="res://.godot/imported/chunk_0016_dirt.png-5f2ccbadabdb1e395518abce5c09d66f.s3tc.ctex" +path.etc2="res://.godot/imported/chunk_0016_dirt.png-5f2ccbadabdb1e395518abce5c09d66f.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} +generator_parameters={ +"md5": "07d90b3c46a4c3d0c172bed143dc9436" +} + +[deps] + +source_file="res://world/generation/chunks/assets/chunk_0016_dirt.png" +dest_files=["res://.godot/imported/chunk_0016_dirt.png-5f2ccbadabdb1e395518abce5c09d66f.s3tc.ctex", "res://.godot/imported/chunk_0016_dirt.png-5f2ccbadabdb1e395518abce5c09d66f.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/world/generation/chunks/assets/chunk_0016_dirt_wall.png b/world/generation/chunks/assets/chunk_0016_dirt_wall.png new file mode 100644 index 0000000..dcc2364 Binary files /dev/null and b/world/generation/chunks/assets/chunk_0016_dirt_wall.png differ diff --git a/world/generation/chunks/assets/chunk_0016_dirt_wall.png.import b/world/generation/chunks/assets/chunk_0016_dirt_wall.png.import new file mode 100644 index 0000000..373e8cf --- /dev/null +++ b/world/generation/chunks/assets/chunk_0016_dirt_wall.png.import @@ -0,0 +1,45 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cn8ljtpbsg253" +path.s3tc="res://.godot/imported/chunk_0016_dirt_wall.png-536115f36dddb3063c7b5162468a4876.s3tc.ctex" +path.etc2="res://.godot/imported/chunk_0016_dirt_wall.png-536115f36dddb3063c7b5162468a4876.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} +generator_parameters={ +"md5": "b5430dba0b52db87d30bae85ae640b09" +} + +[deps] + +source_file="res://world/generation/chunks/assets/chunk_0016_dirt_wall.png" +dest_files=["res://.godot/imported/chunk_0016_dirt_wall.png-536115f36dddb3063c7b5162468a4876.s3tc.ctex", "res://.godot/imported/chunk_0016_dirt_wall.png-536115f36dddb3063c7b5162468a4876.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/world/generation/chunks/assets/chunk_0016_grass_lite.png b/world/generation/chunks/assets/chunk_0016_grass_lite.png new file mode 100644 index 0000000..52c7ccb Binary files /dev/null and b/world/generation/chunks/assets/chunk_0016_grass_lite.png differ diff --git a/world/generation/chunks/assets/chunk_0016_grass_lite.png.import b/world/generation/chunks/assets/chunk_0016_grass_lite.png.import new file mode 100644 index 0000000..6311cde --- /dev/null +++ b/world/generation/chunks/assets/chunk_0016_grass_lite.png.import @@ -0,0 +1,45 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dn84haedw08rx" +path.s3tc="res://.godot/imported/chunk_0016_grass_lite.png-df65a0c79eb0a4e70a36096ead0f76f3.s3tc.ctex" +path.etc2="res://.godot/imported/chunk_0016_grass_lite.png-df65a0c79eb0a4e70a36096ead0f76f3.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} +generator_parameters={ +"md5": "4dc1ac400d85d9b3e4f43a5af0a38ed4" +} + +[deps] + +source_file="res://world/generation/chunks/assets/chunk_0016_grass_lite.png" +dest_files=["res://.godot/imported/chunk_0016_grass_lite.png-df65a0c79eb0a4e70a36096ead0f76f3.s3tc.ctex", "res://.godot/imported/chunk_0016_grass_lite.png-df65a0c79eb0a4e70a36096ead0f76f3.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/world/generation/chunks/definitions/chunk_0002.tres b/world/generation/chunks/definitions/chunk_0002.tres index 1e51d12..77545a7 100644 --- a/world/generation/chunks/definitions/chunk_0002.tres +++ b/world/generation/chunks/definitions/chunk_0002.tres @@ -11,7 +11,7 @@ packed_scene = ExtResource("2_scene") primary_mesh_name = &"chunk_0002" selection_weight = 0.75 maximum_placements = 20 -tags = PackedStringArray("land", "walkable", "sand", "coast", "slope") +tags = PackedStringArray("land", "walkable", "sand", "coast", "slope", "beach_ocean_edge") allowed_neighbor_tags = PackedStringArray("sand", "grass") required_neighbor_tags = PackedStringArray("sand") minimum_required_neighbors = 1 diff --git a/world/generation/chunks/definitions/chunk_0003.tres b/world/generation/chunks/definitions/chunk_0003.tres index 1a1b3dc..4218357 100644 --- a/world/generation/chunks/definitions/chunk_0003.tres +++ b/world/generation/chunks/definitions/chunk_0003.tres @@ -9,7 +9,7 @@ stable_id = &"chunk_0003" label = "stream" packed_scene = ExtResource("2_scene") primary_mesh_name = &"chunk_0003" -selection_weight = 0.6 +selection_weight = 0.3 tags = PackedStringArray("land", "walkable", "fresh_water", "river", "flowing") allowed_neighbor_tags = PackedStringArray("grass") preferred_neighbor_tags = PackedStringArray("grass") diff --git a/world/generation/chunks/definitions/chunk_0008.tres b/world/generation/chunks/definitions/chunk_0008.tres new file mode 100644 index 0000000..29854f7 --- /dev/null +++ b/world/generation/chunks/definitions/chunk_0008.tres @@ -0,0 +1,22 @@ +[gd_resource type="Resource" script_class="TerrainChunkDefinition" load_steps=3 format=3] + +[ext_resource type="Script" path="res://world/generation/terrain_chunk_definition.gd" id="1_definition"] +[ext_resource type="PackedScene" path="res://world/generation/chunks/assets/chunk_0008.glb" id="2_scene"] + +[resource] +script = ExtResource("1_definition") +stable_id = &"chunk_0008" +label = "beach corner" +packed_scene = ExtResource("2_scene") +primary_mesh_name = &"chunk_0008" +selection_weight = 0.2 +maximum_placements = 4 +tags = PackedStringArray("land", "walkable", "sand", "coast", "slope", "corner") +allowed_neighbor_tags = PackedStringArray("sand", "coast") +north_allowed_neighbor_tags = PackedStringArray("beach_ocean_edge") +west_allowed_neighbor_tags = PackedStringArray("beach_ocean_edge") +required_neighbor_tags = PackedStringArray("beach_ocean_edge") +minimum_required_neighbors = 2 +preferred_neighbor_tags = PackedStringArray("coast") +prefers_map_boundary = true +ocean_facing_edges = 6 diff --git a/world/generation/chunks/definitions/chunk_0009.tres b/world/generation/chunks/definitions/chunk_0009.tres new file mode 100644 index 0000000..4be6d07 --- /dev/null +++ b/world/generation/chunks/definitions/chunk_0009.tres @@ -0,0 +1,23 @@ +[gd_resource type="Resource" script_class="TerrainChunkDefinition" load_steps=4 format=3] + +[ext_resource type="Script" path="res://world/generation/terrain_chunk_definition.gd" id="1_definition"] +[ext_resource type="PackedScene" path="res://world/generation/chunks/assets/chunk_0009.glb" id="2_scene"] +[ext_resource type="PackedScene" path="res://world/generation/chunks/assets/chunk_0000.glb" id="3_base_grass"] + +[resource] +script = ExtResource("1_definition") +stable_id = &"chunk_0009" +label = "second-tier grass top" +packed_scene = ExtResource("2_scene") +primary_mesh_name = &"chunk_0009" +base_layer_scene = ExtResource("3_base_grass") +base_layer_mesh_name = &"chunk_0000" +overlay_only = true +selection_weight = 0.22 +maximum_placements = 8 +tags = PackedStringArray("land", "walkable", "grass", "elevation_2", "elevation_2_top") +allowed_neighbor_tags = PackedStringArray("elevation_2") +required_neighbor_tags = PackedStringArray("elevation_2") +minimum_required_neighbors = 4 +preferred_neighbor_tags = PackedStringArray("elevation_2_top") +must_be_interior = true diff --git a/world/generation/chunks/definitions/chunk_0010.tres b/world/generation/chunks/definitions/chunk_0010.tres new file mode 100644 index 0000000..d9120b1 --- /dev/null +++ b/world/generation/chunks/definitions/chunk_0010.tres @@ -0,0 +1,27 @@ +[gd_resource type="Resource" script_class="TerrainChunkDefinition" load_steps=4 format=3] + +[ext_resource type="Script" path="res://world/generation/terrain_chunk_definition.gd" id="1_definition"] +[ext_resource type="PackedScene" path="res://world/generation/chunks/assets/chunk_0010.glb" id="2_scene"] +[ext_resource type="PackedScene" path="res://world/generation/chunks/assets/chunk_0000.glb" id="3_base_grass"] + +[resource] +script = ExtResource("1_definition") +stable_id = &"chunk_0010" +label = "second-tier grass corner" +packed_scene = ExtResource("2_scene") +primary_mesh_name = &"chunk_0010" +base_layer_scene = ExtResource("3_base_grass") +base_layer_mesh_name = &"chunk_0000" +overlay_only = true +selection_weight = 0.12 +maximum_placements = 16 +tags = PackedStringArray("land", "walkable", "grass", "cliff", "corner", "elevation_2", "elevation_2_border") +allowed_neighbor_tags = PackedStringArray("grass", "elevation_2") +north_allowed_neighbor_tags = PackedStringArray("elevation_2_border") +east_allowed_neighbor_tags = PackedStringArray("grass") +south_allowed_neighbor_tags = PackedStringArray("grass") +west_allowed_neighbor_tags = PackedStringArray("elevation_2_border") +required_neighbor_tags = PackedStringArray("elevation_2_border") +minimum_required_neighbors = 2 +preferred_neighbor_tags = PackedStringArray("elevation_2") +must_be_interior = true diff --git a/world/generation/chunks/definitions/chunk_0011.tres b/world/generation/chunks/definitions/chunk_0011.tres new file mode 100644 index 0000000..e7ce943 --- /dev/null +++ b/world/generation/chunks/definitions/chunk_0011.tres @@ -0,0 +1,27 @@ +[gd_resource type="Resource" script_class="TerrainChunkDefinition" load_steps=4 format=3] + +[ext_resource type="Script" path="res://world/generation/terrain_chunk_definition.gd" id="1_definition"] +[ext_resource type="PackedScene" path="res://world/generation/chunks/assets/chunk_0011.glb" id="2_scene"] +[ext_resource type="PackedScene" path="res://world/generation/chunks/assets/chunk_0000.glb" id="3_base_grass"] + +[resource] +script = ExtResource("1_definition") +stable_id = &"chunk_0011" +label = "second-tier grass edge" +packed_scene = ExtResource("2_scene") +primary_mesh_name = &"chunk_0011" +base_layer_scene = ExtResource("3_base_grass") +base_layer_mesh_name = &"chunk_0000" +overlay_only = true +selection_weight = 0.28 +maximum_placements = 32 +tags = PackedStringArray("land", "walkable", "grass", "cliff", "edge", "elevation_2", "elevation_2_border") +allowed_neighbor_tags = PackedStringArray("grass", "elevation_2") +north_allowed_neighbor_tags = PackedStringArray("elevation_2_border") +east_allowed_neighbor_tags = PackedStringArray("grass") +south_allowed_neighbor_tags = PackedStringArray("elevation_2_border") +west_allowed_neighbor_tags = PackedStringArray("elevation_2_top") +required_neighbor_tags = PackedStringArray("elevation_2_top") +minimum_required_neighbors = 1 +preferred_neighbor_tags = PackedStringArray("elevation_2") +must_be_interior = true diff --git a/world/generation/chunks/definitions/chunk_0012.tres b/world/generation/chunks/definitions/chunk_0012.tres new file mode 100644 index 0000000..06caccb --- /dev/null +++ b/world/generation/chunks/definitions/chunk_0012.tres @@ -0,0 +1,27 @@ +[gd_resource type="Resource" script_class="TerrainChunkDefinition" load_steps=4 format=3] + +[ext_resource type="Script" path="res://world/generation/terrain_chunk_definition.gd" id="1_definition"] +[ext_resource type="PackedScene" path="res://world/generation/chunks/assets/chunk_0012.glb" id="2_scene"] +[ext_resource type="PackedScene" path="res://world/generation/chunks/assets/chunk_0000.glb" id="3_base_grass"] + +[resource] +script = ExtResource("1_definition") +stable_id = &"chunk_0012" +label = "second-tier grass ramp" +packed_scene = ExtResource("2_scene") +primary_mesh_name = &"chunk_0012" +base_layer_scene = ExtResource("3_base_grass") +base_layer_mesh_name = &"chunk_0000" +overlay_only = true +selection_weight = 0.12 +maximum_placements = 8 +tags = PackedStringArray("land", "walkable", "grass", "cliff", "edge", "ramp", "elevation_2", "elevation_2_border") +allowed_neighbor_tags = PackedStringArray("grass", "elevation_2") +north_allowed_neighbor_tags = PackedStringArray("elevation_2_border") +east_allowed_neighbor_tags = PackedStringArray("grass") +south_allowed_neighbor_tags = PackedStringArray("elevation_2_border") +west_allowed_neighbor_tags = PackedStringArray("elevation_2_top") +required_neighbor_tags = PackedStringArray("elevation_2_top") +minimum_required_neighbors = 1 +preferred_neighbor_tags = PackedStringArray("elevation_2") +must_be_interior = true diff --git a/world/generation/chunks/definitions/chunk_0013.tres b/world/generation/chunks/definitions/chunk_0013.tres new file mode 100644 index 0000000..22b6061 --- /dev/null +++ b/world/generation/chunks/definitions/chunk_0013.tres @@ -0,0 +1,28 @@ +[gd_resource type="Resource" script_class="TerrainChunkDefinition" load_steps=3 format=3] + +[ext_resource type="Script" path="res://world/generation/terrain_chunk_definition.gd" id="1_definition"] +[ext_resource type="PackedScene" path="res://world/generation/chunks/assets/chunk_0013.glb" id="2_scene"] + +[resource] +script = ExtResource("1_definition") +stable_id = &"chunk_0013" +label = "grass-sand diagonal" +packed_scene = ExtResource("2_scene") +primary_mesh_name = &"chunk_0013" +participates_in_base_solver = false +allowed_rotation_mask = 15 +selection_weight = 0.8 +maximum_placements = 16 +tags = PackedStringArray("land", "walkable", "grass", "sand", "flat", "transition", "mixed_surface") +allowed_neighbor_tags = PackedStringArray("grass", "sand") +forbidden_neighbor_tags = PackedStringArray("fresh_water", "spawn") +north_allowed_neighbor_tags = PackedStringArray("sand") +east_allowed_neighbor_tags = PackedStringArray("grass") +south_allowed_neighbor_tags = PackedStringArray("grass") +west_allowed_neighbor_tags = PackedStringArray("sand") +north_surface_tags = PackedStringArray("sand") +east_surface_tags = PackedStringArray("grass") +south_surface_tags = PackedStringArray("grass") +west_surface_tags = PackedStringArray("sand") +preferred_neighbor_tags = PackedStringArray("grass", "sand", "transition") +must_be_interior = true diff --git a/world/generation/chunks/definitions/chunk_0014.tres b/world/generation/chunks/definitions/chunk_0014.tres new file mode 100644 index 0000000..c3a0d87 --- /dev/null +++ b/world/generation/chunks/definitions/chunk_0014.tres @@ -0,0 +1,24 @@ +[gd_resource type="Resource" script_class="TerrainChunkDefinition" load_steps=3 format=3] + +[ext_resource type="Script" path="res://world/generation/terrain_chunk_definition.gd" id="1_definition"] +[ext_resource type="PackedScene" path="res://world/generation/chunks/assets/chunk_0014.glb" id="2_scene"] + +[resource] +script = ExtResource("1_definition") +stable_id = &"chunk_0014" +label = "second-tier grass sea edge" +packed_scene = ExtResource("2_scene") +primary_mesh_name = &"chunk_0014" +overlay_only = true +selection_weight = 0.28 +maximum_placements = 32 +tags = PackedStringArray("land", "walkable", "grass", "coast", "cliff", "edge", "elevation_2", "elevation_2_border", "elevation_2_sea_edge") +allowed_neighbor_tags = PackedStringArray("elevation_2") +north_allowed_neighbor_tags = PackedStringArray("elevation_2_sea_edge") +south_allowed_neighbor_tags = PackedStringArray("elevation_2_sea_edge") +west_allowed_neighbor_tags = PackedStringArray("elevation_2_top") +required_neighbor_tags = PackedStringArray("elevation_2_top") +minimum_required_neighbors = 1 +preferred_neighbor_tags = PackedStringArray("elevation_2", "coast") +prefers_map_boundary = true +ocean_facing_edges = 2 diff --git a/world/generation/chunks/definitions/chunk_0015.tres b/world/generation/chunks/definitions/chunk_0015.tres new file mode 100644 index 0000000..d7f0a5a --- /dev/null +++ b/world/generation/chunks/definitions/chunk_0015.tres @@ -0,0 +1,23 @@ +[gd_resource type="Resource" script_class="TerrainChunkDefinition" load_steps=3 format=3] + +[ext_resource type="Script" path="res://world/generation/terrain_chunk_definition.gd" id="1_definition"] +[ext_resource type="PackedScene" path="res://world/generation/chunks/assets/chunk_0015.glb" id="2_scene"] + +[resource] +script = ExtResource("1_definition") +stable_id = &"chunk_0015" +label = "second-tier grass sea corner" +packed_scene = ExtResource("2_scene") +primary_mesh_name = &"chunk_0015" +overlay_only = true +selection_weight = 0.12 +maximum_placements = 16 +tags = PackedStringArray("land", "walkable", "grass", "coast", "cliff", "corner", "elevation_2", "elevation_2_border", "elevation_2_sea_edge") +allowed_neighbor_tags = PackedStringArray("elevation_2") +north_allowed_neighbor_tags = PackedStringArray("elevation_2_sea_edge") +west_allowed_neighbor_tags = PackedStringArray("elevation_2_sea_edge") +required_neighbor_tags = PackedStringArray("elevation_2_sea_edge") +minimum_required_neighbors = 2 +preferred_neighbor_tags = PackedStringArray("elevation_2", "coast") +prefers_map_boundary = true +ocean_facing_edges = 6 diff --git a/world/generation/chunks/definitions/chunk_0016.tres b/world/generation/chunks/definitions/chunk_0016.tres new file mode 100644 index 0000000..804bb34 --- /dev/null +++ b/world/generation/chunks/definitions/chunk_0016.tres @@ -0,0 +1,19 @@ +[gd_resource type="Resource" script_class="TerrainChunkDefinition" format=3] + +[ext_resource type="Script" path="res://world/generation/terrain_chunk_definition.gd" id="1_definition"] +[ext_resource type="PackedScene" path="res://world/generation/chunks/assets/chunk_0016.glb" id="2_scene"] + +[resource] +script = ExtResource("1_definition") +stable_id = &"chunk_0016" +label = "stream variant" +packed_scene = ExtResource("2_scene") +primary_mesh_name = &"chunk_0016" +selection_weight = 0.3 +tags = PackedStringArray("land", "walkable", "fresh_water", "river", "flowing") +allowed_neighbor_tags = PackedStringArray("grass") +preferred_neighbor_tags = PackedStringArray("grass") +water_inlet_edges = 1 +water_outlet_edges = 4 +water_surface_size = Vector2(2.1, 10) +water_surface_offset = Vector2(-0.315, 0) diff --git a/world/generation/chunks/terrain_chunk_catalog.tres b/world/generation/chunks/terrain_chunk_catalog.tres index 6240098..7d577a3 100644 --- a/world/generation/chunks/terrain_chunk_catalog.tres +++ b/world/generation/chunks/terrain_chunk_catalog.tres @@ -1,4 +1,4 @@ -[gd_resource type="Resource" script_class="TerrainChunkCatalog" load_steps=12 format=3] +[gd_resource type="Resource" script_class="TerrainChunkCatalog" load_steps=21 format=3] [ext_resource type="Script" path="res://world/generation/terrain_chunk_catalog.gd" id="1_catalog"] [ext_resource type="Resource" path="res://world/generation/chunks/definitions/chunk_0000.tres" id="2_grass"] @@ -11,8 +11,17 @@ [ext_resource type="Resource" path="res://world/generation/chunks/definitions/chunk_0005.tres" id="9_grass_ocean_edge"] [ext_resource type="Resource" path="res://world/generation/chunks/definitions/chunk_0006.tres" id="10_grass_beach_transition"] [ext_resource type="Resource" path="res://world/generation/chunks/definitions/chunk_0007.tres" id="11_grass_ocean_corner"] +[ext_resource type="Resource" path="res://world/generation/chunks/definitions/chunk_0008.tres" id="12_beach_ocean_corner"] +[ext_resource type="Resource" path="res://world/generation/chunks/definitions/chunk_0009.tres" id="13_cliff_top"] +[ext_resource type="Resource" path="res://world/generation/chunks/definitions/chunk_0010.tres" id="14_cliff_corner"] +[ext_resource type="Resource" path="res://world/generation/chunks/definitions/chunk_0011.tres" id="15_cliff_edge"] +[ext_resource type="Resource" path="res://world/generation/chunks/definitions/chunk_0012.tres" id="16_cliff_ramp"] +[ext_resource type="Resource" path="res://world/generation/chunks/definitions/chunk_0013.tres" id="17_grass_sand_diagonal"] +[ext_resource type="Resource" path="res://world/generation/chunks/definitions/chunk_0014.tres" id="18_cliff_sea_edge"] +[ext_resource type="Resource" path="res://world/generation/chunks/definitions/chunk_0015.tres" id="19_cliff_sea_corner"] +[ext_resource type="Resource" path="res://world/generation/chunks/definitions/chunk_0016.tres" id="20_stream_variant"] [resource] script = ExtResource("1_catalog") chunk_size = 10.0 -definitions = Array[ExtResource("7_definition_script")]([ExtResource("2_grass"), ExtResource("3_sand"), ExtResource("4_beach"), ExtResource("5_stream"), ExtResource("6_pond"), ExtResource("9_grass_ocean_edge"), ExtResource("10_grass_beach_transition"), ExtResource("11_grass_ocean_corner"), ExtResource("8_spawn")]) +definitions = Array[ExtResource("7_definition_script")]([ExtResource("2_grass"), ExtResource("3_sand"), ExtResource("4_beach"), ExtResource("5_stream"), ExtResource("20_stream_variant"), ExtResource("6_pond"), ExtResource("9_grass_ocean_edge"), ExtResource("10_grass_beach_transition"), ExtResource("11_grass_ocean_corner"), ExtResource("12_beach_ocean_corner"), ExtResource("17_grass_sand_diagonal"), ExtResource("13_cliff_top"), ExtResource("14_cliff_corner"), ExtResource("15_cliff_edge"), ExtResource("16_cliff_ramp"), ExtResource("18_cliff_sea_edge"), ExtResource("19_cliff_sea_corner"), ExtResource("8_spawn")]) diff --git a/world/generation/generated_world_region.gd b/world/generation/generated_world_region.gd index f313b51..59876b3 100644 --- a/world/generation/generated_world_region.gd +++ b/world/generation/generated_world_region.gd @@ -78,6 +78,11 @@ func _ready() -> void: func generate_world(seed: int) -> bool: if seed <= 0 or seed > PlayerSaveManager.MAX_WORLD_SEED: return false + if ( + seed == _current_seed + and _generator.get_generated_chunks_root() != null + ): + return true _current_seed = seed _generator.generation_seed = seed return _generator.generate() @@ -237,29 +242,34 @@ func _on_generation_completed(summary: Dictionary) -> void: _prop_definitions_for(rule, tags), coordinate, ) - if ( - definitions.is_empty() - or not _prop_group_roll_succeeds( - rule, - group_key, + for _placement_attempt: int in ( + rule.placement_attempts_per_chunk + ): + if group_counts.get(group_key, 0) >= maximum: + break + if ( + definitions.is_empty() + or not _prop_group_roll_succeeds( + rule, + group_key, + coordinate, + random, + ) + ): + continue + if _maybe_add_prop( + definitions, + record.get("position", Vector3.ZERO), coordinate, + biome.stable_id, random, - ) - ): - continue - if _maybe_add_prop( - definitions, - record.get("position", Vector3.ZERO), - coordinate, - biome.stable_id, - random, - terrain_triangles, - ): - group_counts[group_key] += 1 - _record_prop_group_coordinate( - group_key, - coordinate, - ) + terrain_triangles, + ): + group_counts[group_key] += 1 + _record_prop_group_coordinate( + group_key, + coordinate, + ) _ensure_minimum_props( eligible_records, group_counts, @@ -322,7 +332,7 @@ func _configure_static_water() -> void: _ocean.fish_pool = OCEAN_POOL _ocean.location_tags = [&"coast", &"ocean", &"generated_ocean"] _ocean.surface_size = Vector2(10000.0, 10000.0) - _ocean.position.y = WATER_HEIGHT - 0.025 + _ocean.position.y = WATER_HEIGHT _apply_water_materials() @@ -451,6 +461,11 @@ func _add_prop( ) -> bool: if definition == null or definition.packed_scene == null: return false + var visual_scale := random.randf_range( + definition.minimum_visual_scale, + definition.maximum_visual_scale, + ) + var scaled_clearance := definition.clearance_radius * visual_scale var placement := Vector3.ZERO var found_surface := false for attempt: int in PROP_PLACEMENT_ATTEMPTS: @@ -470,7 +485,7 @@ func _add_prop( > WATER_HEIGHT + PROP_MINIMUM_GROUND_CLEARANCE and _has_prop_clearance( placement, - definition.clearance_radius, + scaled_clearance, ) ): placement.y = surface_height @@ -492,22 +507,26 @@ func _add_prop( prop.set_meta(&"terrain_prop_group", definition.procedural_group) prop.set_meta(&"terrain_chunk_coordinate", chunk_coordinate) prop.set_meta(&"terrain_biome_id", biome_id) + prop.set_meta(&"terrain_prop_visual_scale", visual_scale) prop.position = placement prop.rotation.y = random.randf_range(-PI, PI) _decorations.add_child(prop) visual_root.name = "Visual" prop.add_child(visual_root) + visual_root.position = definition.visual_offset + visual_root.scale = Vector3.ONE * visual_scale _configure_prop_visuals(visual_root) - _add_prop_collision(prop, definition) + _apply_prop_material_variant(visual_root, definition, random) + _add_prop_collision(prop, definition, visual_scale) _placed_prop_positions.append(placement) - _placed_prop_clearance_radii.append(definition.clearance_radius) + _placed_prop_clearance_radii.append(scaled_clearance) _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() anchor.position = prop.position + Vector3( 0.0, - definition.gatherable_anchor_height, + definition.gatherable_anchor_height * visual_scale, 0.0, ) _tree_anchors.add_child(anchor) @@ -624,7 +643,11 @@ func _prop_group_maximum( if eligible_count <= 0: return 0 var maximum := ceili( - float(eligible_count * rule.maximum_density) + float( + eligible_count + * rule.placement_attempts_per_chunk + * rule.maximum_density + ) / float(PROP_CHANCE_SCALE) ) return maxi(maximum, rule.minimum_placements) @@ -777,6 +800,51 @@ func _configure_prop_visuals(root_node: Node) -> void: _configure_prop_visuals(child) +func _apply_prop_material_variant( + root_node: Node, + definition: TerrainPropDefinition, + random: RandomNumberGenerator, +) -> void: + if ( + definition.material_variants.is_empty() + or definition.variant_material_slot_names.is_empty() + ): + return + var variant := definition.material_variants[ + random.randi_range(0, definition.material_variants.size() - 1) + ] + _apply_material_variant_to_meshes( + root_node, + definition.variant_material_slot_names, + variant, + ) + + +func _apply_material_variant_to_meshes( + root_node: Node, + target_material_names: PackedStringArray, + variant: Material, +) -> void: + var mesh_instance := root_node as MeshInstance3D + if mesh_instance != null and mesh_instance.mesh != null: + for surface_index: int in mesh_instance.mesh.get_surface_count(): + var active_material := mesh_instance.get_active_material(surface_index) + if ( + active_material != null + and active_material.resource_name in target_material_names + ): + mesh_instance.set_surface_override_material( + surface_index, + variant, + ) + for child: Node in root_node.get_children(): + _apply_material_variant_to_meshes( + child, + target_material_names, + variant, + ) + + func _terrain_surface_triangles() -> Array[PackedVector3Array]: var triangles: Array[PackedVector3Array] = [] for mesh_instance: MeshInstance3D in _generator.get_primary_terrain_meshes(): @@ -818,6 +886,7 @@ func _surface_height_at( func _add_prop_collision( prop: Node3D, definition: TerrainPropDefinition, + visual_scale: float, ) -> void: if not definition.has_collision(): return @@ -830,16 +899,16 @@ func _add_prop_collision( collision.name = "CollisionShape" if definition.has_box_collision(): var box := BoxShape3D.new() - box.size = definition.collision_box_size + box.size = definition.collision_box_size * visual_scale collision.shape = box - collision.position = definition.collision_offset + collision.position = definition.collision_offset * visual_scale else: var cylinder := CylinderShape3D.new() - cylinder.radius = definition.collision_radius - cylinder.height = definition.collision_height + cylinder.radius = definition.collision_radius * visual_scale + cylinder.height = definition.collision_height * visual_scale collision.shape = cylinder collision.position = ( - definition.collision_offset + definition.collision_offset * visual_scale + Vector3.UP * cylinder.height * 0.5 ) body.add_child(collision) diff --git a/world/generation/generated_world_region.tscn b/world/generation/generated_world_region.tscn index e46796f..7987172 100644 --- a/world/generation/generated_world_region.tscn +++ b/world/generation/generated_world_region.tscn @@ -31,12 +31,14 @@ gatherable_anchor_root = NodePath("GatherableAnchors") unique_name_in_owner = true script = ExtResource("2_generator") catalog = ExtResource("3_catalog") -grid_size = Vector2i(7, 7) +grid_size = Vector2i(15, 13) generation_seed = 13001 generate_on_ready = false build_collision = true force_center_chunk_id = &"chunk_spawn" -required_chunk_ids = PackedStringArray("chunk_spawn", "chunk_0001", "chunk_0002", "chunk_0003", "chunk_0004", "chunk_0005", "chunk_0006", "chunk_0007") +required_chunk_ids = PackedStringArray("chunk_spawn", "chunk_0001", "chunk_0002", "chunk_0003", "chunk_0004", "chunk_0005", "chunk_0006", "chunk_0007", "chunk_0008") +grass_sand_smoothing_enabled = true +elevated_cliff_feature_enabled = true required_chunk_weight_multiplier = 64.0 maximum_backtracks = 100000 @@ -60,7 +62,7 @@ script = ExtResource("8_diggable") area_id = &"starter_beach" terrain_source = NodePath("../../Terrain/TerrainChunkGenerator") surface_materials = Array[StringName]([&"sand"]) -generation_bounds = Rect2(-35, -35, 70, 70) +generation_bounds = Rect2(-55, -45, 110, 90) minimum_global_y = -0.24 maximum_global_y = 0.5 minimum_up_dot = 0.72 diff --git a/world/generation/props/definitions/prop_mushroom.tres b/world/generation/props/definitions/prop_mushroom.tres index cf63eff..e4bff54 100644 --- a/world/generation/props/definitions/prop_mushroom.tres +++ b/world/generation/props/definitions/prop_mushroom.tres @@ -16,3 +16,7 @@ preferred_nearby_prop_groups = PackedStringArray("grass_tree") preferred_nearby_radius = 12.0 nearby_preference_weight_multiplier = 3.0 clearance_radius = 0.8 +visual_offset = Vector3(0, -0.08, 0) +collision_radius = 0.65 +collision_height = 1.0 +collision_offset = Vector3(0, -0.08, 0) diff --git a/world/generation/props/definitions/prop_pine.tres b/world/generation/props/definitions/prop_pine.tres index 19361c8..0a569cf 100644 --- a/world/generation/props/definitions/prop_pine.tres +++ b/world/generation/props/definitions/prop_pine.tres @@ -13,6 +13,8 @@ required_chunk_tags = PackedStringArray("grass") selection_weight = 0.7 minimum_spawn_chunk_distance = 2 clearance_radius = 1.55 +minimum_visual_scale = 0.75 +maximum_visual_scale = 1.22 collision_radius = 0.5 collision_height = 4.0 gatherable_anchor_height = 2.15 diff --git a/world/generation/props/definitions/prop_tree_1.tres b/world/generation/props/definitions/prop_tree_1.tres index 4b942db..41ab406 100644 --- a/world/generation/props/definitions/prop_tree_1.tres +++ b/world/generation/props/definitions/prop_tree_1.tres @@ -1,7 +1,10 @@ -[gd_resource type="Resource" script_class="TerrainPropDefinition" load_steps=3 format=3] +[gd_resource type="Resource" script_class="TerrainPropDefinition" load_steps=6 format=3] [ext_resource type="Script" path="res://world/generation/terrain_prop_definition.gd" id="1_definition"] [ext_resource type="PackedScene" path="res://world/generation/props/assets/prop_tree_1.glb" id="2_scene"] +[ext_resource type="Material" path="res://world/generation/props/materials/leaf_light.tres" id="3_leaf_light"] +[ext_resource type="Material" path="res://world/generation/props/materials/leaf_mid.tres" id="4_leaf_mid"] +[ext_resource type="Material" path="res://world/generation/props/materials/leaf_dark.tres" id="5_leaf_dark"] [resource] script = ExtResource("1_definition") @@ -12,6 +15,10 @@ procedural_group = &"grass_tree" required_chunk_tags = PackedStringArray("grass") minimum_spawn_chunk_distance = 2 clearance_radius = 1.3 +minimum_visual_scale = 0.75 +maximum_visual_scale = 1.15 +variant_material_slot_names = PackedStringArray("leaf_light", "leaf", "leaf_dark") +material_variants = Array[Material]([ExtResource("3_leaf_light"), ExtResource("4_leaf_mid"), ExtResource("5_leaf_dark")]) collision_radius = 0.4 collision_height = 3.2 gatherable_anchor_height = 2.15 diff --git a/world/generation/props/definitions/prop_tree_2.tres b/world/generation/props/definitions/prop_tree_2.tres index 3d1ab62..c4d09fa 100644 --- a/world/generation/props/definitions/prop_tree_2.tres +++ b/world/generation/props/definitions/prop_tree_2.tres @@ -1,7 +1,10 @@ -[gd_resource type="Resource" script_class="TerrainPropDefinition" load_steps=3 format=3] +[gd_resource type="Resource" script_class="TerrainPropDefinition" load_steps=6 format=3] [ext_resource type="Script" path="res://world/generation/terrain_prop_definition.gd" id="1_definition"] [ext_resource type="PackedScene" path="res://world/generation/props/assets/prop_tree_2.glb" id="2_scene"] +[ext_resource type="Material" path="res://world/generation/props/materials/leaf_light.tres" id="3_leaf_light"] +[ext_resource type="Material" path="res://world/generation/props/materials/leaf_mid.tres" id="4_leaf_mid"] +[ext_resource type="Material" path="res://world/generation/props/materials/leaf_dark.tres" id="5_leaf_dark"] [resource] script = ExtResource("1_definition") @@ -12,6 +15,10 @@ procedural_group = &"grass_tree" required_chunk_tags = PackedStringArray("grass") minimum_spawn_chunk_distance = 2 clearance_radius = 1.5 +minimum_visual_scale = 0.8 +maximum_visual_scale = 1.18 +variant_material_slot_names = PackedStringArray("leaf_light", "leaf", "leaf_dark") +material_variants = Array[Material]([ExtResource("3_leaf_light"), ExtResource("4_leaf_mid"), ExtResource("5_leaf_dark")]) collision_radius = 0.5 collision_height = 3.8 gatherable_anchor_height = 2.15 diff --git a/world/generation/props/definitions/prop_tree_3.tres b/world/generation/props/definitions/prop_tree_3.tres index 637a281..642a8de 100644 --- a/world/generation/props/definitions/prop_tree_3.tres +++ b/world/generation/props/definitions/prop_tree_3.tres @@ -1,7 +1,10 @@ -[gd_resource type="Resource" script_class="TerrainPropDefinition" load_steps=3 format=3] +[gd_resource type="Resource" script_class="TerrainPropDefinition" load_steps=6 format=3] [ext_resource type="Script" path="res://world/generation/terrain_prop_definition.gd" id="1_definition"] [ext_resource type="PackedScene" path="res://world/generation/props/assets/prop_tree_3.glb" id="2_scene"] +[ext_resource type="Material" path="res://world/generation/props/materials/leaf_light.tres" id="3_leaf_light"] +[ext_resource type="Material" path="res://world/generation/props/materials/leaf_mid.tres" id="4_leaf_mid"] +[ext_resource type="Material" path="res://world/generation/props/materials/leaf_dark.tres" id="5_leaf_dark"] [resource] script = ExtResource("1_definition") @@ -12,6 +15,10 @@ procedural_group = &"grass_tree" required_chunk_tags = PackedStringArray("grass") minimum_spawn_chunk_distance = 2 clearance_radius = 1.8 +minimum_visual_scale = 0.85 +maximum_visual_scale = 1.15 +variant_material_slot_names = PackedStringArray("leaf_light", "leaf", "leaf_dark") +material_variants = Array[Material]([ExtResource("3_leaf_light"), ExtResource("4_leaf_mid"), ExtResource("5_leaf_dark")]) collision_radius = 0.55 collision_height = 4.2 gatherable_anchor_height = 2.15 diff --git a/world/generation/props/materials/leaf_dark.tres b/world/generation/props/materials/leaf_dark.tres new file mode 100644 index 0000000..d75f991 --- /dev/null +++ b/world/generation/props/materials/leaf_dark.tres @@ -0,0 +1,9 @@ +[gd_resource type="StandardMaterial3D" format=3] + +[resource] +resource_name = "leaf_variant_dark" +albedo_color = Color(0.1712, 0.252, 0, 1) +metallic = 0.0 +roughness = 1.0 +shading_mode = 1 +texture_filter = 0 diff --git a/world/generation/props/materials/leaf_light.tres b/world/generation/props/materials/leaf_light.tres new file mode 100644 index 0000000..d49f786 --- /dev/null +++ b/world/generation/props/materials/leaf_light.tres @@ -0,0 +1,9 @@ +[gd_resource type="StandardMaterial3D" format=3] + +[resource] +resource_name = "leaf_variant_light" +albedo_color = Color(0.3388, 0.7187, 0, 1) +metallic = 0.0 +roughness = 1.0 +shading_mode = 1 +texture_filter = 0 diff --git a/world/generation/props/materials/leaf_mid.tres b/world/generation/props/materials/leaf_mid.tres new file mode 100644 index 0000000..d0ee646 --- /dev/null +++ b/world/generation/props/materials/leaf_mid.tres @@ -0,0 +1,9 @@ +[gd_resource type="StandardMaterial3D" format=3] + +[resource] +resource_name = "leaf_variant_mid" +albedo_color = Color(0.3147, 0.4631, 0, 1) +metallic = 0.0 +roughness = 1.0 +shading_mode = 1 +texture_filter = 0 diff --git a/world/generation/terrain_biome_prop_rule.gd b/world/generation/terrain_biome_prop_rule.gd index 96da0db..7b02e98 100644 --- a/world/generation/terrain_biome_prop_rule.gd +++ b/world/generation/terrain_biome_prop_rule.gd @@ -6,6 +6,9 @@ extends Resource @export_range(0, 10000, 1) var adjacency_bonus := 0 @export_range(0, 10000, 1) var maximum_density := 10000 @export_range(0, 64, 1) var minimum_placements := 0 +## More than one attempt permits intentionally dense biomes without changing +## the density of every biome that uses the same procedural prop group. +@export_range(1, 8, 1) var placement_attempts_per_chunk := 1 ## Empty permits every compatible prop in the procedural group. @export var allowed_prop_ids := PackedStringArray() diff --git a/world/generation/terrain_chunk_analyzer.gd b/world/generation/terrain_chunk_analyzer.gd index 9f1557c..84895b1 100644 --- a/world/generation/terrain_chunk_analyzer.gd +++ b/world/generation/terrain_chunk_analyzer.gd @@ -34,6 +34,29 @@ static func create_variants( chunk_size, edge_epsilon, ) + if definition.base_layer_scene != null: + var base_layer_root := definition.base_layer_scene.instantiate() + var base_layer_mesh := find_primary_mesh( + base_layer_root, + definition.base_layer_mesh_name, + ) + if base_layer_mesh == null or base_layer_mesh.mesh == null: + push_error( + "Terrain chunk %s has no base-layer MeshInstance3D named %s." + % [definition.stable_id, definition.base_layer_mesh_name] + ) + base_layer_root.free() + chunk_root.free() + return variants + boundary_points.append_array( + _collect_boundary_points( + base_layer_mesh.mesh, + _transform_relative_to(base_layer_mesh, base_layer_root), + chunk_size, + edge_epsilon, + ) + ) + base_layer_root.free() for quarter_turns: int in 4: if not definition.allows_quarter_turn(quarter_turns): continue diff --git a/world/generation/terrain_chunk_catalog.gd b/world/generation/terrain_chunk_catalog.gd index 3c73822..6271e04 100644 --- a/world/generation/terrain_chunk_catalog.gd +++ b/world/generation/terrain_chunk_catalog.gd @@ -30,6 +30,25 @@ func validation_errors() -> PackedStringArray: errors.append("%s has no PackedScene." % definition.stable_id) if definition.primary_mesh_name == &"": errors.append("%s has no primary mesh name." % definition.stable_id) + if ( + (definition.base_layer_scene == null) + != (definition.base_layer_mesh_name == &"") + ): + errors.append( + "%s must define both base-layer scene and mesh name." + % definition.stable_id + ) + if ( + definition.overlay_only + and definition.base_layer_scene == null + and definition.ocean_facing_edges == 0 + ): + errors.append( + ( + "%s is an inland overlay but has no base-layer scene." + % definition.stable_id + ) + ) if definition.allowed_rotation_mask == 0: errors.append("%s allows no rotations." % definition.stable_id) var has_coast := "coast" in definition.tags @@ -44,6 +63,11 @@ func validation_errors() -> PackedStringArray: "%s has an ocean-facing edge without the coast tag." % definition.stable_id ) + if definition.must_be_interior and definition.prefers_map_boundary: + errors.append( + "%s cannot require the interior and prefer the boundary." + % definition.stable_id + ) var has_fresh_water := "fresh_water" in definition.tags var has_water_footprint := ( definition.water_surface_size.x > 0.0 diff --git a/world/generation/terrain_chunk_definition.gd b/world/generation/terrain_chunk_definition.gd index 54f27cb..ca38dc3 100644 --- a/world/generation/terrain_chunk_definition.gd +++ b/world/generation/terrain_chunk_definition.gd @@ -5,6 +5,22 @@ extends Resource @export var label := "" @export var packed_scene: PackedScene @export var primary_mesh_name: StringName +## Optional existing level-one chunk placed beneath an authored elevated +## overlay. This lets cliff edges leave their downhill side open while the +## ordinary base terrain remains present in the same generated cell. +@export_group("Layered Terrain") +@export var base_layer_scene: PackedScene +@export var base_layer_mesh_name: StringName +## Overlay-only chunks replace a reserved base-terrain placement after the +## ordinary terrain solve. Inland overlays normally provide base_layer_scene; +## complete coastal overlays can instead carry their own descent to sea level. +## They remain in placement manifests and runtime output, but do not inflate +## every cell's global solver domain. +@export var overlay_only := false +## Post-process pieces remain available as authored variants and in placement +## manifests, but do not participate in the ordinary base-terrain solve. +@export var participates_in_base_solver := true +@export_group("") @export_flags("0 degrees", "90 degrees", "180 degrees", "270 degrees") var allowed_rotation_mask := 15 @export_range(0.01, 100.0, 0.01) var selection_weight := 1.0 @@ -27,6 +43,16 @@ var allowed_rotation_mask := 15 @export var south_allowed_neighbor_tags := PackedStringArray() @export var west_allowed_neighbor_tags := PackedStringArray() @export_group("") +## Optional canonical descriptions of the visible terrain surface at each +## edge. Empty edges inherit the definition's ordinary tags. Mixed-surface +## chunks use these to prevent a visually grass edge from joining sand (or the +## reverse) even when the chunk itself carries both biome tags. +@export_group("Directional Surface Rules") +@export var north_surface_tags := PackedStringArray() +@export var east_surface_tags := PackedStringArray() +@export var south_surface_tags := PackedStringArray() +@export var west_surface_tags := PackedStringArray() +@export_group("") ## When non-empty, at least this many in-grid cardinal neighbors must carry one ## of the listed tags. This supports statements such as "three sides around the ## spawn must be safe grass" without hard-coding a particular chunk ID. @@ -36,6 +62,9 @@ var allowed_rotation_mask := 15 ## weight. This shapes regions without turning a visual preference into a hard ## generation constraint. @export var preferred_neighbor_tags := PackedStringArray() +## Interior-only pieces may not occupy any outer grid cell. Coastal elevated +## pieces use separate authored definitions rather than weakening this rule. +@export var must_be_interior := false ## Coastal transition pieces should normally migrate toward the generated ## region's perimeter while remaining legal in the interior. @export var prefers_map_boundary := false @@ -92,6 +121,28 @@ func directional_allowed_neighbor_tags( return PackedStringArray() +func surface_tags_for_edge( + edge: TerrainChunkTopology.Edge, +) -> PackedStringArray: + var edge_tags := directional_surface_tags(edge) + return tags if edge_tags.is_empty() else edge_tags + + +func directional_surface_tags( + edge: TerrainChunkTopology.Edge, +) -> PackedStringArray: + match edge: + TerrainChunkTopology.Edge.NORTH: + return north_surface_tags + TerrainChunkTopology.Edge.EAST: + return east_surface_tags + TerrainChunkTopology.Edge.SOUTH: + return south_surface_tags + TerrainChunkTopology.Edge.WEST: + return west_surface_tags + return PackedStringArray() + + func _allows_neighbor_with_tags( neighbor: TerrainChunkDefinition, allowed_tags: PackedStringArray, diff --git a/world/generation/terrain_chunk_generator.gd b/world/generation/terrain_chunk_generator.gd index df3afc6..8f6235d 100644 --- a/world/generation/terrain_chunk_generator.gd +++ b/world/generation/terrain_chunk_generator.gd @@ -5,6 +5,11 @@ signal generation_completed(summary: Dictionary) const CONSTRAINT_PROFILE_QUANTIZATION := 0.001 const CANDIDATE_WEIGHT_SCALE := 10000.0 +const LARGE_GRID_LIGHTWEIGHT_THRESHOLD := 128 +const LARGE_GRID_PROPAGATION_INTERVAL := 8 +# Keep packed domain bits below the signed 64-bit sign bit. Catalogs larger +# than this remain correct through the unpacked solver path. +const MAX_PACKED_SOLVER_VARIANTS := 62 @export var catalog: TerrainChunkCatalog @export var grid_size := Vector2i(7, 7) @@ -14,6 +19,22 @@ const CANDIDATE_WEIGHT_SCALE := 10000.0 @export var show_chunk_labels := false @export var force_center_chunk_id: StringName = &"chunk_0000" @export var required_chunk_ids := PackedStringArray() +@export_group("Grass-Sand Smoothing") +@export var grass_sand_smoothing_enabled := false +@export var smoothing_grass_chunk_id: StringName = &"chunk_0000" +@export var smoothing_sand_chunk_id: StringName = &"chunk_0001" +@export var smoothing_diagonal_chunk_id: StringName = &"chunk_0013" +@export_range(1, 64, 1) var maximum_smoothing_placements := 16 +@export_group("") +@export_group("Elevated Inland Feature") +@export var elevated_cliff_feature_enabled := false +@export var elevated_cliff_base_chunk_id: StringName = &"chunk_0000" +@export var elevated_cliff_top_chunk_id: StringName = &"chunk_0009" +@export var elevated_cliff_corner_chunk_id: StringName = &"chunk_0010" +@export var elevated_cliff_edge_chunk_id: StringName = &"chunk_0011" +@export var elevated_cliff_ramp_chunk_id: StringName = &"chunk_0012" +@export_range(0.0, 1.0, 0.05) var elevated_cliff_ramp_chance := 0.75 +@export_group("") @export_range(1.0, 100.0, 1.0) var required_chunk_weight_multiplier := 64.0 @export_range(1.0, 4.0, 0.05) var preferred_neighbor_weight_multiplier := 1.6 @export_range(0.01, 1.0, 0.01) var long_repeat_weight_multiplier := 0.3 @@ -26,13 +47,27 @@ var _variants: Array[TerrainChunkVariant] = [] # Rotations with identical terrain edges and connector directions share one # solver candidate, then resolve back to an authored rotation after solving. var _solver_variants: Array[TerrainChunkVariant] = [] +var _solver_definitions: Array[TerrainChunkDefinition] = [] +var _solver_variants_by_definition: Dictionary[StringName, Array] = {} +var _definition_variant_masks: Dictionary[StringName, int] = {} +var _required_neighbor_variant_masks: Dictionary[StringName, int] = {} var _equivalent_rotations: Dictionary[String, PackedInt32Array] = {} var _solver_variant_indices: Dictionary = {} var _edge_compatibility := PackedByteArray() +var _edge_compatibility_masks := PackedInt64Array() +var _static_cell_candidate_masks := PackedInt64Array() +var _neighbor_indices: Array[PackedInt32Array] = [] var _placements: Array[TerrainChunkVariant] = [] +var _placement_counts: Dictionary[StringName, int] = {} +var _unfilled_cells := 0 +var _required_stable_ids: Array[StringName] = [] +var _selection_missing_required_count := 0 +var _selection_missing_required_mask := 0 var _random := RandomNumberGenerator.new() var _generated_chunks: Node3D var _backtrack_count := 0 +var _elevated_feature_center := Vector2i(-1, -1) +var _elevated_feature_reserved_grass_indices: Dictionary[int, bool] = {} func _ready() -> void: @@ -47,19 +82,27 @@ func generate() -> bool: previous_placements.assign(_placements) _random.seed = generation_seed _backtrack_count = 0 - _placements.clear() - _placements.resize(grid_size.x * grid_size.y) + _reset_placements(grid_size.x * grid_size.y) + if not _prepare_elevated_feature_region(): + _assign_placements(previous_placements) + return false if not _solve_cell(0): - _placements.assign(previous_placements) + _assign_placements(previous_placements) push_error( "Terrain generation could not solve a %dx%d grid with seed %d." % [grid_size.x, grid_size.y, generation_seed] ) return false _resolve_equivalent_rotations() + if not _apply_grass_sand_smoothing(): + _assign_placements(previous_placements) + return false + if not _apply_elevated_feature(): + _assign_placements(previous_placements) + return false var solution_root := _build_solution_root() if solution_root == null: - _placements.assign(previous_placements) + _assign_placements(previous_placements) return false _replace_generated_chunks(solution_root) var summary := _build_summary() @@ -93,11 +136,11 @@ func generate_from_placement_keys(keys: PackedStringArray) -> bool: var previous_placements: Array[TerrainChunkVariant] = [] previous_placements.assign(_placements) - _placements.assign(resolved) + _assign_placements(resolved) _backtrack_count = 0 var solution_root := _build_solution_root() if solution_root == null: - _placements.assign(previous_placements) + _assign_placements(previous_placements) return false _replace_generated_chunks(solution_root) var summary := _build_summary() @@ -180,6 +223,10 @@ func _prepare_catalog() -> bool: _variants.clear() _solver_variants.clear() + _solver_definitions.clear() + _solver_variants_by_definition.clear() + _definition_variant_masks.clear() + _required_neighbor_variant_masks.clear() _equivalent_rotations.clear() _solver_variant_indices.clear() for definition: TerrainChunkDefinition in catalog.definitions: @@ -195,6 +242,11 @@ func _prepare_catalog() -> bool: return false for variant: TerrainChunkVariant in analyzed: _variants.append(variant) + if ( + definition.overlay_only + or not definition.participates_in_base_solver + ): + continue var constraint_key := _constraint_key(variant) var rotations: PackedInt32Array = _equivalent_rotations.get( constraint_key, @@ -207,12 +259,81 @@ func _prepare_catalog() -> bool: if _variants.is_empty(): push_error("Terrain chunk catalog produced no usable variants.") return false + if _solver_variants.is_empty(): + push_error("Terrain chunk catalog produced no base-terrain solver variants.") + return false for index: int in _solver_variants.size(): - _solver_variant_indices[_solver_variants[index]] = index + var variant := _solver_variants[index] + _solver_variant_indices[variant] = index + var definition_variants: Array = _solver_variants_by_definition.get( + variant.definition.stable_id, + [], + ) + definition_variants.append(variant) + if definition_variants.size() == 1: + _solver_definitions.append(variant.definition) + _solver_variants_by_definition[ + variant.definition.stable_id + ] = definition_variants + if index < MAX_PACKED_SOLVER_VARIANTS: + _definition_variant_masks[variant.definition.stable_id] = ( + int( + _definition_variant_masks.get( + variant.definition.stable_id, + 0, + ) + ) + | (1 << index) + ) _build_edge_compatibility_cache() return true +func _reset_placements(cell_count: int) -> void: + _placements.clear() + _placements.resize(cell_count) + _placement_counts.clear() + _unfilled_cells = cell_count + + +func _assign_placements(layout: Array[TerrainChunkVariant]) -> void: + _placements.assign(layout) + _rebuild_placement_tracking() + + +func _rebuild_placement_tracking() -> void: + _placement_counts.clear() + _unfilled_cells = 0 + for placement: TerrainChunkVariant in _placements: + if placement == null: + _unfilled_cells += 1 + continue + _adjust_placement_count(placement.definition.stable_id, 1) + + +func _set_placement(index: int, placement: TerrainChunkVariant) -> void: + var previous := _placements[index] + if previous == placement: + return + if previous == null: + _unfilled_cells -= 1 + else: + _adjust_placement_count(previous.definition.stable_id, -1) + _placements[index] = placement + if placement == null: + _unfilled_cells += 1 + else: + _adjust_placement_count(placement.definition.stable_id, 1) + + +func _adjust_placement_count(stable_id: StringName, difference: int) -> void: + var updated := int(_placement_counts.get(stable_id, 0)) + difference + if updated <= 0: + _placement_counts.erase(stable_id) + return + _placement_counts[stable_id] = updated + + func _constraint_key(variant: TerrainChunkVariant) -> String: return "%s|%s" % [ variant.definition.stable_id, @@ -224,6 +345,10 @@ func _build_edge_compatibility_cache() -> void: var variant_count := _solver_variants.size() _edge_compatibility.resize(variant_count * 4 * variant_count) _edge_compatibility.fill(0) + _edge_compatibility_masks.resize( + variant_count * 4 if _packed_solver_domains_are_available() else 0 + ) + _edge_compatibility_masks.fill(0) for first_index: int in variant_count: var first := _solver_variants[first_index] for first_edge_value: int in TerrainChunkTopology.Edge.values(): @@ -236,14 +361,19 @@ func _build_edge_compatibility_cache() -> void: first_edge, second_index, ) - _edge_compatibility[cache_index] = int( - _calculate_edge_compatibility( + var compatible := _calculate_edge_compatibility( first, first_edge, second, second_edge, - ) ) + _edge_compatibility[cache_index] = int(compatible) + if compatible and _packed_solver_domains_are_available(): + var mask_index := first_index * 4 + int(first_edge) + _edge_compatibility_masks[mask_index] = ( + _edge_compatibility_masks[mask_index] + | (1 << second_index) + ) func _edge_compatibility_index( @@ -260,11 +390,13 @@ func _edge_compatibility_index( func _validate_generation_requirements() -> bool: var seen_required: Dictionary[StringName, bool] = {} + _required_stable_ids.clear() for required_id_value: String in required_chunk_ids: var required_id := StringName(required_id_value) if seen_required.has(required_id): continue seen_required[required_id] = true + _required_stable_ids.append(required_id) var definition := catalog.definition_for_id(required_id) if definition == null: push_error("Required terrain chunk %s is not in the catalog." % required_id) @@ -272,6 +404,15 @@ func _validate_generation_requirements() -> bool: if definition.maximum_placements == 0: push_error("Required terrain chunk %s permits no placements." % required_id) return false + if ( + definition.overlay_only + or not definition.participates_in_base_solver + ): + push_error( + "Required terrain chunk %s does not participate in the base solver." + % required_id + ) + return false if seen_required.size() > grid_size.x * grid_size.y: push_error("The terrain grid has fewer cells than required chunk IDs.") return false @@ -301,31 +442,52 @@ func _solve_cell(placed_count: int) -> bool: ) if _backtrack_count >= maximum_backtracks: return false + _refresh_selection_required_cache() if not _requirements_can_still_be_satisfied(): return false - var next_cell := _select_next_cell() + var next_cell := _select_next_cell(placed_count) var index := int(next_cell.get("index", -1)) if index < 0: return false - var candidates := next_cell.get( - "candidates", - [], - ) as Array[TerrainChunkVariant] + var candidates: Array[TerrainChunkVariant] = [] + candidates.assign(next_cell.get("candidates", [])) for candidate: TerrainChunkVariant in _weighted_candidate_order( candidates, index, ): - _placements[index] = candidate + _set_placement(index, candidate) if _solve_cell(placed_count + 1): return true - _placements[index] = null + _set_placement(index, null) _backtrack_count += 1 if _backtrack_count >= maximum_backtracks: break return false -func _select_next_cell() -> Dictionary: +func _refresh_selection_required_cache() -> void: + _selection_missing_required_count = 0 + _selection_missing_required_mask = 0 + for stable_id: StringName in _required_stable_ids: + if int(_placement_counts.get(stable_id, 0)) > 0: + continue + _selection_missing_required_count += 1 + _selection_missing_required_mask |= int( + _definition_variant_masks.get(stable_id, 0) + ) + + +func _select_next_cell(placed_count: int) -> Dictionary: + if _packed_solver_domains_are_available(): + if ( + _placements.size() >= LARGE_GRID_LIGHTWEIGHT_THRESHOLD + and placed_count % LARGE_GRID_PROPAGATION_INTERVAL != 0 + ): + return _select_next_large_grid_cell() + return _select_next_large_grid_propagated_cell() + if _placements.size() >= LARGE_GRID_LIGHTWEIGHT_THRESHOLD: + if placed_count % LARGE_GRID_PROPAGATION_INTERVAL != 0: + return _select_next_large_grid_cell_unpacked() # Rebuild and propagate the small domain table on every branch. This catches # unsupported connector chains before they turn into a deep recursive dead # end, while keeping placement state simple and deterministic. @@ -356,6 +518,285 @@ func _select_next_cell() -> Dictionary: return {"index": best_index, "candidates": best_candidates} +func _select_next_large_grid_propagated_cell() -> Dictionary: + var domains: Dictionary[int, int] = {} + for index: int in _placements.size(): + if _placements[index] != null: + continue + var candidate_mask := _compatible_candidate_mask(index) + if candidate_mask == 0: + return {"index": index, "candidates": []} + domains[index] = candidate_mask + if not _propagate_domain_masks(domains): + return {"index": -1, "candidates": []} + + var best_index := -1 + var best_mask := 0 + var best_size := _solver_variants.size() + 1 + for index: int in domains: + var candidate_mask := domains[index] + var candidate_count := _mask_bit_count(candidate_mask) + if best_index < 0 or candidate_count < best_size: + best_index = index + best_mask = candidate_mask + best_size = candidate_count + if candidate_count == 1: + break + return { + "index": best_index, + "candidates": _candidates_from_mask(best_mask), + } + + +func _candidates_from_mask(candidate_mask: int) -> Array[TerrainChunkVariant]: + var result: Array[TerrainChunkVariant] = [] + for candidate_index: int in _solver_variants.size(): + if (candidate_mask & (1 << candidate_index)) != 0: + result.append(_solver_variants[candidate_index]) + return result + + +func _mask_bit_count(candidate_mask: int) -> int: + var count := 0 + var remaining := candidate_mask + while remaining != 0: + remaining &= remaining - 1 + count += 1 + return count + + +func _propagate_domain_masks(domains: Dictionary[int, int]) -> bool: + var changed := true + while changed: + changed = false + for index: int in domains.keys(): + var candidates := domains[index] + var supported := 0 + for candidate_index: int in _solver_variants.size(): + var candidate_bit := 1 << candidate_index + if ( + (candidates & candidate_bit) != 0 + and _domain_candidate_has_mask_support( + candidate_index, + index, + domains, + ) + ): + supported |= candidate_bit + if supported == 0: + return false + if supported != candidates: + domains[index] = supported + changed = true + var required_result := _constrain_required_domain_masks(domains) + if required_result < 0: + return false + if required_result > 0: + changed = true + if not _placed_neighbor_requirements_have_domain_mask_support(domains): + return false + return true + + +func _domain_candidate_has_mask_support( + candidate_index: int, + index: int, + domains: Dictionary[int, int], +) -> bool: + var candidate := _solver_variants[candidate_index] + var possible_required_neighbors := 0 + var required_neighbor_mask := _required_neighbor_variant_mask( + candidate.definition + ) + for edge_value: int in TerrainChunkTopology.Edge.values(): + var edge := edge_value as TerrainChunkTopology.Edge + var neighbor_index := _neighbor_indices[index][edge_value] + if neighbor_index < 0: + continue + var placed_neighbor := _placements[neighbor_index] + if placed_neighbor != null: + if _definition_matches_required_neighbor_tags( + candidate.definition, + placed_neighbor.definition, + ): + possible_required_neighbors += 1 + continue + var support_mask := ( + int(domains.get(neighbor_index, 0)) + & _edge_compatibility_masks[candidate_index * 4 + int(edge)] + ) + if ( + candidate.definition.maximum_placements >= 0 + and _definition_placement_count(candidate.definition) + 2 + > candidate.definition.maximum_placements + ): + support_mask &= ~int( + _definition_variant_masks.get( + candidate.definition.stable_id, + 0, + ) + ) + if support_mask == 0: + return false + if (support_mask & required_neighbor_mask) != 0: + possible_required_neighbors += 1 + return ( + possible_required_neighbors + >= candidate.definition.minimum_required_neighbors + ) + + +func _required_neighbor_variant_mask( + definition: TerrainChunkDefinition, +) -> int: + if _required_neighbor_variant_masks.has(definition.stable_id): + return _required_neighbor_variant_masks[definition.stable_id] + var result := 0 + for candidate_index: int in _solver_variants.size(): + if _definition_matches_required_neighbor_tags( + definition, + _solver_variants[candidate_index].definition, + ): + result |= 1 << candidate_index + _required_neighbor_variant_masks[definition.stable_id] = result + return result + + +func _constrain_required_domain_masks( + domains: Dictionary[int, int], +) -> int: + var changed := false + for stable_id: StringName in _missing_required_ids(): + var definition_mask := int(_definition_variant_masks.get(stable_id, 0)) + var supporting_cell := -1 + var supporting_cell_count := 0 + for index: int in domains: + if (domains[index] & definition_mask) == 0: + continue + supporting_cell = index + supporting_cell_count += 1 + if supporting_cell_count > 1: + break + if supporting_cell_count == 0: + return -1 + if supporting_cell_count != 1: + continue + var forced_mask := domains[supporting_cell] & definition_mask + if forced_mask == 0: + return -1 + if forced_mask != domains[supporting_cell]: + domains[supporting_cell] = forced_mask + changed = true + return 1 if changed else 0 + + +func _placed_neighbor_requirements_have_domain_mask_support( + domains: Dictionary[int, int], +) -> bool: + for index: int in _placements.size(): + var placement := _placements[index] + if placement == null or placement.definition.minimum_required_neighbors <= 0: + continue + var placement_variant_index := int( + _solver_variant_indices.get(placement, -1) + ) + if placement_variant_index < 0: + return false + var required_neighbor_mask := _required_neighbor_variant_mask( + placement.definition + ) + var possible_neighbors := 0 + for edge_value: int in TerrainChunkTopology.Edge.values(): + var edge := edge_value as TerrainChunkTopology.Edge + var neighbor_index := _neighbor_indices[index][edge_value] + if neighbor_index < 0: + continue + var neighbor := _placements[neighbor_index] + if neighbor != null: + if _definition_matches_required_neighbor_tags( + placement.definition, + neighbor.definition, + ): + possible_neighbors += 1 + continue + var compatible_required_mask := ( + int(domains.get(neighbor_index, 0)) + & required_neighbor_mask + & _edge_compatibility_masks[ + placement_variant_index * 4 + int(edge) + ] + ) + if compatible_required_mask != 0: + possible_neighbors += 1 + if possible_neighbors < placement.definition.minimum_required_neighbors: + return false + return true + + +func _select_next_large_grid_cell() -> Dictionary: + # Full all-cell arc propagation scales cubically as the map grows. Large + # worlds instead use the same compatibility checks with a frontier-aware MRV + # pass. Exact placement validation and backtracking remain unchanged. + var best_index := -1 + var best_candidates: Array[TerrainChunkVariant] = [] + var best_placed_neighbors := -1 + for index: int in _placements.size(): + if _placements[index] != null: + continue + var candidate_mask := _compatible_candidate_mask(index) + if candidate_mask == 0: + return {"index": index, "candidates": []} + var candidates := _candidates_from_mask(candidate_mask) + var placed_neighbors := _placed_neighbor_count(index) + if ( + best_index < 0 + or candidates.size() < best_candidates.size() + or ( + candidates.size() == best_candidates.size() + and placed_neighbors > best_placed_neighbors + ) + ): + best_index = index + best_candidates = candidates + best_placed_neighbors = placed_neighbors + return {"index": best_index, "candidates": best_candidates} + + +func _select_next_large_grid_cell_unpacked() -> Dictionary: + var best_index := -1 + var best_candidates: Array[TerrainChunkVariant] = [] + var best_placed_neighbors := -1 + for index: int in _placements.size(): + if _placements[index] != null: + continue + var candidates := _compatible_candidates_unpacked(index) + if candidates.is_empty(): + return {"index": index, "candidates": candidates} + var placed_neighbors := _placed_neighbor_count(index) + if ( + best_index < 0 + or candidates.size() < best_candidates.size() + or ( + candidates.size() == best_candidates.size() + and placed_neighbors > best_placed_neighbors + ) + ): + best_index = index + best_candidates = candidates + best_placed_neighbors = placed_neighbors + return {"index": best_index, "candidates": best_candidates} + + +func _placed_neighbor_count(index: int) -> int: + var count := 0 + for neighbor_index: int in _neighbor_indices[index]: + if neighbor_index < 0: + continue + if _placements[neighbor_index] != null: + count += 1 + return count + + func _propagate_domains(domains: Dictionary) -> bool: var changed := true while changed: @@ -545,37 +986,99 @@ func _constrain_required_domains(domains: Dictionary) -> int: func _compatible_candidates(index: int) -> Array[TerrainChunkVariant]: + if not _packed_solver_domains_are_available(): + return _compatible_candidates_unpacked(index) + return _candidates_from_mask(_compatible_candidate_mask(index)) + + +func _compatible_candidates_unpacked( + index: int, +) -> Array[TerrainChunkVariant]: var result: Array[TerrainChunkVariant] = [] var missing_required := _missing_required_ids() - var remaining_cells := _unfilled_cell_count() - var must_place_missing_required := missing_required.size() >= remaining_cells + var must_place_missing_required := ( + missing_required.size() >= _unfilled_cells + ) for candidate: TerrainChunkVariant in _solver_variants: if ( must_place_missing_required and not missing_required.has(candidate.definition.stable_id) ): continue - if not _candidate_can_occupy_cell(candidate, index): - continue - result.append(candidate) + if _candidate_can_occupy_cell(candidate, index): + result.append(candidate) return result -func _candidate_can_occupy_cell( +func _packed_solver_domains_are_available() -> bool: + return _solver_variants.size() <= MAX_PACKED_SOLVER_VARIANTS + + +func _compatible_candidate_mask(index: int) -> int: + if index < 0 or index >= _static_cell_candidate_masks.size(): + return 0 + var result := int(_static_cell_candidate_masks[index]) + if _selection_missing_required_count >= _unfilled_cells: + result &= _selection_missing_required_mask + if result == 0: + return 0 + result = _mask_matching_placed_neighbors(result, index) + if result == 0: + return 0 + + for definition: TerrainChunkDefinition in _solver_definitions: + var definition_mask := int( + _definition_variant_masks.get(definition.stable_id, 0) + ) + if (result & definition_mask) == 0: + continue + var definition_variants: Array = _solver_variants_by_definition.get( + definition.stable_id, + [], + ) + if ( + definition_variants.is_empty() + or not _candidate_dynamic_cell_rules_are_satisfied( + definition_variants[0] as TerrainChunkVariant, + index, + ) + ): + result &= ~definition_mask + return result + + +func _mask_matching_placed_neighbors(candidate_mask: int, index: int) -> int: + var result := candidate_mask + for edge_value: int in TerrainChunkTopology.Edge.values(): + var neighbor_index := _neighbor_indices[index][edge_value] + if neighbor_index < 0: + continue + var neighbor := _placements[neighbor_index] + if neighbor == null: + continue + var neighbor_variant_index := int( + _solver_variant_indices.get(neighbor, -1) + ) + if neighbor_variant_index < 0: + return 0 + var neighbor_edge := TerrainChunkTopology.opposite_edge( + edge_value as TerrainChunkTopology.Edge + ) + result &= _edge_compatibility_masks[ + neighbor_variant_index * 4 + int(neighbor_edge) + ] + if result == 0: + return 0 + return result + + +func _candidate_dynamic_cell_rules_are_satisfied( candidate: TerrainChunkVariant, index: int, ) -> bool: if not _definition_has_capacity(candidate.definition): return false - var coordinate := Vector2i(index % grid_size.x, index / grid_size.x) - var center := Vector2i(grid_size.x / 2, grid_size.y / 2) - var center_index := center.y * grid_size.x + center.x - if ( - coordinate == center - and force_center_chunk_id != &"" - and candidate.definition.stable_id != force_center_chunk_id - ): - return false + var center_index := (grid_size.y / 2) * grid_size.x + grid_size.x / 2 if ( index != center_index and _placements[center_index] == null @@ -586,23 +1089,365 @@ func _candidate_can_occupy_cell( ): return false return ( - _variant_respects_ocean_boundary(candidate, coordinate) - and _candidate_matches_placed_neighbors(candidate, coordinate) - and _candidate_neighbor_requirement_can_still_be_met( + _candidate_neighbor_requirement_can_still_be_met_at_index( candidate.definition, - coordinate, + index, ) - and _candidate_preserves_placed_neighbor_requirements( + and _candidate_preserves_placed_neighbor_requirements_at_index( candidate.definition, - coordinate, + index, ) ) +func _candidate_neighbor_requirement_can_still_be_met_at_index( + definition: TerrainChunkDefinition, + index: int, +) -> bool: + if definition.minimum_required_neighbors <= 0: + return true + var possible_neighbors := 0 + for neighbor_index: int in _neighbor_indices[index]: + if neighbor_index < 0: + continue + var neighbor := _placements[neighbor_index] + if ( + neighbor == null + or _definition_matches_required_neighbor_tags( + definition, + neighbor.definition, + ) + ): + possible_neighbors += 1 + return possible_neighbors >= definition.minimum_required_neighbors + + +func _candidate_preserves_placed_neighbor_requirements_at_index( + candidate_definition: TerrainChunkDefinition, + candidate_index: int, +) -> bool: + for neighbor_index: int in _neighbor_indices[candidate_index]: + if neighbor_index < 0: + continue + var neighbor := _placements[neighbor_index] + if neighbor == null or neighbor.definition.minimum_required_neighbors <= 0: + continue + var possible_neighbors := 0 + for requirement_neighbor_index: int in _neighbor_indices[neighbor_index]: + if requirement_neighbor_index < 0: + continue + if requirement_neighbor_index == candidate_index: + if _definition_matches_required_neighbor_tags( + neighbor.definition, + candidate_definition, + ): + possible_neighbors += 1 + continue + var requirement_neighbor := _placements[requirement_neighbor_index] + if ( + requirement_neighbor == null + or _definition_matches_required_neighbor_tags( + neighbor.definition, + requirement_neighbor.definition, + ) + ): + possible_neighbors += 1 + if possible_neighbors < neighbor.definition.minimum_required_neighbors: + return false + return true + + +func _candidate_can_occupy_cell( + candidate: TerrainChunkVariant, + index: int, +) -> bool: + var coordinate := Vector2i(index % grid_size.x, index / grid_size.x) + if ( + _elevated_feature_reserved_grass_indices.has(index) + and candidate.definition.stable_id != elevated_cliff_base_chunk_id + ): + return false + var center := Vector2i(grid_size.x / 2, grid_size.y / 2) + if ( + coordinate == center + and force_center_chunk_id != &"" + and candidate.definition.stable_id != force_center_chunk_id + ): + return false + return ( + _variant_respects_ocean_boundary(candidate, coordinate) + and _candidate_matches_placed_neighbors(candidate, coordinate) + and _candidate_dynamic_cell_rules_are_satisfied(candidate, index) + ) + + +func _prepare_elevated_feature_region() -> bool: + _elevated_feature_center = Vector2i(-1, -1) + _elevated_feature_reserved_grass_indices.clear() + if not elevated_cliff_feature_enabled: + _build_grid_solver_caches() + return true + for stable_id: StringName in [ + elevated_cliff_base_chunk_id, + elevated_cliff_top_chunk_id, + elevated_cliff_corner_chunk_id, + elevated_cliff_edge_chunk_id, + elevated_cliff_ramp_chunk_id, + ]: + if catalog.definition_for_id(stable_id) == null: + push_error("Elevated cliff feature references missing chunk %s." % stable_id) + return false + if grid_size.x < 11 or grid_size.y < 9: + push_error( + "Elevated cliff feature requires at least an 11x9 terrain grid." + ) + return false + var forced_center := Vector2i(grid_size.x / 2, grid_size.y / 2) + var candidates: Array[Vector2i] = [] + for row: int in range(2, grid_size.y - 2): + for column: int in range(2, grid_size.x - 2): + var candidate := Vector2i(column, row) + if ( + absi(candidate.x - forced_center.x) <= 2 + and absi(candidate.y - forced_center.y) <= 2 + ): + continue + candidates.append(candidate) + if candidates.is_empty(): + push_error("Terrain grid has no inland 3x3 cliff feature location.") + return false + var feature_random := RandomNumberGenerator.new() + feature_random.seed = generation_seed ^ 0x2E1E7A7ED + _elevated_feature_center = candidates[ + feature_random.randi_range(0, candidates.size() - 1) + ] + # Solve the authored 3x3 cliff assembly into a one-cell ring of ordinary + # level-one flat grass. Every raised piece still occupies one of the inner + # nine grass cells; the outer ring prevents a stream, pond, or coast profile + # from being exposed directly against its downhill edge. + for row_offset: int in range(-2, 3): + for column_offset: int in range(-2, 3): + var coordinate := ( + _elevated_feature_center + + Vector2i(column_offset, row_offset) + ) + _elevated_feature_reserved_grass_indices[ + coordinate.y * grid_size.x + coordinate.x + ] = true + _build_grid_solver_caches() + return true + + +func _build_grid_solver_caches() -> void: + _neighbor_indices.clear() + _neighbor_indices.resize(_placements.size()) + _static_cell_candidate_masks.resize(_placements.size()) + _static_cell_candidate_masks.fill(0) + var center := Vector2i(grid_size.x / 2, grid_size.y / 2) + for index: int in _placements.size(): + var coordinate := Vector2i(index % grid_size.x, index / grid_size.x) + var neighbors := PackedInt32Array([-1, -1, -1, -1]) + for edge_value: int in TerrainChunkTopology.Edge.values(): + var neighbor_coordinate := ( + coordinate + + TerrainChunkTopology.grid_offset( + edge_value as TerrainChunkTopology.Edge + ) + ) + if _coordinate_is_inside_grid(neighbor_coordinate): + neighbors[edge_value] = ( + neighbor_coordinate.y * grid_size.x + + neighbor_coordinate.x + ) + _neighbor_indices[index] = neighbors + + if not _packed_solver_domains_are_available(): + continue + var candidate_mask := 0 + for candidate_index: int in _solver_variants.size(): + var candidate := _solver_variants[candidate_index] + if ( + _elevated_feature_reserved_grass_indices.has(index) + and candidate.definition.stable_id + != elevated_cliff_base_chunk_id + ): + continue + if ( + coordinate == center + and force_center_chunk_id != &"" + and candidate.definition.stable_id != force_center_chunk_id + ): + continue + if not _variant_respects_ocean_boundary(candidate, coordinate): + continue + candidate_mask |= 1 << candidate_index + _static_cell_candidate_masks[index] = candidate_mask + + +func _apply_grass_sand_smoothing() -> bool: + if not grass_sand_smoothing_enabled: + return true + var diagonal_definition := catalog.definition_for_id( + smoothing_diagonal_chunk_id + ) + if diagonal_definition == null: + push_error( + "Grass-sand smoothing references missing chunk %s." + % smoothing_diagonal_chunk_id + ) + return false + if diagonal_definition.participates_in_base_solver: + push_error( + "Grass-sand smoothing chunk %s must be excluded from the base solver." + % smoothing_diagonal_chunk_id + ) + return false + + var candidates: Array[Dictionary] = [] + var center := Vector2i(grid_size.x / 2, grid_size.y / 2) + for index: int in _placements.size(): + var placement := _placements[index] + if placement == null or placement.definition.stable_id not in [ + smoothing_grass_chunk_id, + smoothing_sand_chunk_id, + ]: + continue + var coordinate := Vector2i(index % grid_size.x, index / grid_size.x) + if ( + _coordinate_is_on_boundary(coordinate) + or _elevated_feature_reserved_grass_indices.has(index) + or ( + absi(coordinate.x - center.x) + + absi(coordinate.y - center.y) + <= 1 + ) + ): + continue + for quarter_turns: int in 4: + var diagonal := _authored_variant( + diagonal_definition, + quarter_turns, + ) + if ( + diagonal != null + and _candidate_matches_placed_neighbors(diagonal, coordinate) + ): + candidates.append({"index": index, "variant": diagonal}) + break + + var smoothing_random := RandomNumberGenerator.new() + smoothing_random.seed = generation_seed ^ 0xD1A60A1 + for candidate_index: int in range(candidates.size() - 1, 0, -1): + var swap_index := smoothing_random.randi_range(0, candidate_index) + var temporary := candidates[candidate_index] + candidates[candidate_index] = candidates[swap_index] + candidates[swap_index] = temporary + + var previous_placements: Array[TerrainChunkVariant] = [] + previous_placements.assign(_placements) + var replacement_count := 0 + for candidate: Dictionary in candidates: + if replacement_count >= maximum_smoothing_placements: + break + var index := int(candidate["index"]) + var coordinate := Vector2i(index % grid_size.x, index / grid_size.x) + var diagonal := candidate["variant"] as TerrainChunkVariant + if ( + diagonal == null + or not _candidate_matches_placed_neighbors(diagonal, coordinate) + or not _replacement_preserves_required_chunk(_placements[index]) + ): + continue + _set_placement(index, diagonal) + replacement_count += 1 + + var validation_error := _resolved_layout_validation_error(_placements) + if not validation_error.is_empty(): + _assign_placements(previous_placements) + push_error( + "Grass-sand smoothing produced an invalid layout: " + + validation_error + ) + return false + return true + + +func _replacement_preserves_required_chunk( + original: TerrainChunkVariant, +) -> bool: + if original == null: + return false + var stable_id := original.definition.stable_id + if String(stable_id) not in required_chunk_ids: + return true + return _definition_placement_count(original.definition) > 1 + + +func _apply_elevated_feature() -> bool: + if not elevated_cliff_feature_enabled: + return true + if _elevated_feature_center.x < 0 or _elevated_feature_center.y < 0: + push_error("Elevated cliff feature has no reserved base region.") + return false + var edge_specs: Array[Dictionary] = [ + {"offset": Vector2i(0, -1), "turns": 1}, + {"offset": Vector2i(1, 0), "turns": 0}, + {"offset": Vector2i(0, 1), "turns": 3}, + {"offset": Vector2i(-1, 0), "turns": 2}, + ] + var feature_random := RandomNumberGenerator.new() + feature_random.seed = generation_seed ^ 0x51A7C11FF + var ramp_edge := -1 + if feature_random.randf() < elevated_cliff_ramp_chance: + ramp_edge = feature_random.randi_range(0, edge_specs.size() - 1) + var placements: Array[Dictionary] = [ + {"offset": Vector2i(-1, -1), "id": elevated_cliff_corner_chunk_id, "turns": 2}, + {"offset": Vector2i(1, -1), "id": elevated_cliff_corner_chunk_id, "turns": 1}, + {"offset": Vector2i(0, 0), "id": elevated_cliff_top_chunk_id, "turns": 0}, + {"offset": Vector2i(-1, 1), "id": elevated_cliff_corner_chunk_id, "turns": 3}, + {"offset": Vector2i(1, 1), "id": elevated_cliff_corner_chunk_id, "turns": 0}, + ] + for edge_index: int in edge_specs.size(): + var edge_spec := edge_specs[edge_index] + placements.append({ + "offset": edge_spec["offset"], + "id": ( + elevated_cliff_ramp_chunk_id + if edge_index == ramp_edge + else elevated_cliff_edge_chunk_id + ), + "turns": edge_spec["turns"], + }) + for spec: Dictionary in placements: + var definition := catalog.definition_for_id(spec["id"] as StringName) + var variant := _authored_variant(definition, int(spec["turns"])) + if variant == null: + push_error( + "Elevated cliff feature cannot resolve %s rotation %d." + % [spec["id"], spec["turns"]] + ) + return false + var coordinate := _elevated_feature_center + (spec["offset"] as Vector2i) + _set_placement( + coordinate.y * grid_size.x + coordinate.x, + variant, + ) + var validation_error := _resolved_layout_validation_error(_placements) + if not validation_error.is_empty(): + push_error("Elevated cliff feature is invalid: " + validation_error) + return false + return true + + func _variant_respects_ocean_boundary( variant: TerrainChunkVariant, coordinate: Vector2i, ) -> bool: + if ( + variant.definition.must_be_interior + and _coordinate_is_on_boundary(coordinate) + ): + return false var ocean_edges := variant.rotated_edge_mask( variant.definition.ocean_facing_edges ) @@ -656,101 +1501,6 @@ func _coordinate_is_inside_grid(coordinate: Vector2i) -> bool: ) -func _candidate_neighbor_requirement_can_still_be_met( - definition: TerrainChunkDefinition, - coordinate: Vector2i, -) -> bool: - if definition.minimum_required_neighbors <= 0: - return true - var possible_neighbors := 0 - for edge_value: int in TerrainChunkTopology.Edge.values(): - var neighbor_coordinate := ( - coordinate - + TerrainChunkTopology.grid_offset( - edge_value as TerrainChunkTopology.Edge - ) - ) - if not _coordinate_is_inside_grid(neighbor_coordinate): - continue - var neighbor := _placements[ - neighbor_coordinate.y * grid_size.x + neighbor_coordinate.x - ] - if ( - neighbor == null - or _definition_matches_required_neighbor_tags( - definition, - neighbor.definition, - ) - ): - possible_neighbors += 1 - return possible_neighbors >= definition.minimum_required_neighbors - - -func _candidate_preserves_placed_neighbor_requirements( - definition: TerrainChunkDefinition, - coordinate: Vector2i, -) -> bool: - for edge_value: int in TerrainChunkTopology.Edge.values(): - var neighbor_coordinate := ( - coordinate - + TerrainChunkTopology.grid_offset( - edge_value as TerrainChunkTopology.Edge - ) - ) - if not _coordinate_is_inside_grid(neighbor_coordinate): - continue - var neighbor := _placements[ - neighbor_coordinate.y * grid_size.x + neighbor_coordinate.x - ] - if neighbor == null or neighbor.definition.minimum_required_neighbors <= 0: - continue - if not _placed_requirement_can_still_be_met_with_candidate( - neighbor.definition, - neighbor_coordinate, - definition, - coordinate, - ): - return false - return true - - -func _placed_requirement_can_still_be_met_with_candidate( - definition: TerrainChunkDefinition, - coordinate: Vector2i, - candidate_definition: TerrainChunkDefinition, - candidate_coordinate: Vector2i, -) -> bool: - var possible_neighbors := 0 - for edge_value: int in TerrainChunkTopology.Edge.values(): - var neighbor_coordinate := ( - coordinate - + TerrainChunkTopology.grid_offset( - edge_value as TerrainChunkTopology.Edge - ) - ) - if not _coordinate_is_inside_grid(neighbor_coordinate): - continue - if neighbor_coordinate == candidate_coordinate: - if _definition_matches_required_neighbor_tags( - definition, - candidate_definition, - ): - possible_neighbors += 1 - continue - var neighbor := _placements[ - neighbor_coordinate.y * grid_size.x + neighbor_coordinate.x - ] - if ( - neighbor == null - or _definition_matches_required_neighbor_tags( - definition, - neighbor.definition, - ) - ): - possible_neighbors += 1 - return possible_neighbors >= definition.minimum_required_neighbors - - func _definition_has_capacity(definition: TerrainChunkDefinition) -> bool: if definition.maximum_placements < 0: return true @@ -758,11 +1508,9 @@ func _definition_has_capacity(definition: TerrainChunkDefinition) -> bool: func _definition_placement_count(definition: TerrainChunkDefinition) -> int: - var count := 0 - for placement: TerrainChunkVariant in _placements: - if placement != null and placement.definition == definition: - count += 1 - return count + if definition == null: + return 0 + return int(_placement_counts.get(definition.stable_id, 0)) func _edges_are_compatible( @@ -829,11 +1577,30 @@ func _calculate_edge_compatibility( or (first_inlet and second_outlet) ) return ( - first.allows_non_water_neighbor_on_edge(second, first_edge) + _edge_surfaces_are_compatible( + first, + first_edge, + second, + second_edge, + ) + and first.allows_non_water_neighbor_on_edge(second, first_edge) and second.allows_non_water_neighbor_on_edge(first, second_edge) ) +func _edge_surfaces_are_compatible( + first: TerrainChunkVariant, + first_edge: TerrainChunkTopology.Edge, + second: TerrainChunkVariant, + second_edge: TerrainChunkTopology.Edge, +) -> bool: + var first_tags := first.surface_tags(first_edge) + var second_tags := second.surface_tags(second_edge) + if first_tags.is_empty() or second_tags.is_empty(): + return true + return TerrainChunkDefinition.has_any_tag(first_tags, second_tags) + + func _variant_edge_has_connector( variant: TerrainChunkVariant, source_mask: int, @@ -862,35 +1629,57 @@ func _requirements_can_still_be_satisfied() -> bool: func _definition_has_available_cell( definition: TerrainChunkDefinition, ) -> bool: + var definition_variants: Array = _solver_variants_by_definition.get( + definition.stable_id, + [], + ) + if ( + _packed_solver_domains_are_available() + and not definition_variants.is_empty() + ): + var definition_mask := int( + _definition_variant_masks.get(definition.stable_id, 0) + ) + for index: int in _placements.size(): + if _placements[index] != null: + continue + var candidate_mask := ( + int(_static_cell_candidate_masks[index]) & definition_mask + ) + if candidate_mask == 0: + continue + candidate_mask = _mask_matching_placed_neighbors( + candidate_mask, + index, + ) + if ( + candidate_mask != 0 + and _candidate_dynamic_cell_rules_are_satisfied( + definition_variants[0] as TerrainChunkVariant, + index, + ) + ): + return true + return false for index: int in _placements.size(): if _placements[index] != null: continue - for candidate: TerrainChunkVariant in _solver_variants: - if ( - candidate.definition == definition - and _candidate_can_occupy_cell(candidate, index) - ): + for candidate: TerrainChunkVariant in definition_variants: + if _candidate_can_occupy_cell(candidate, index): return true return false func _unfilled_cell_count() -> int: - var count := 0 - for placement: TerrainChunkVariant in _placements: - if placement == null: - count += 1 - return count + return _unfilled_cells func _missing_required_ids() -> Array[StringName]: - var found: Dictionary[StringName, bool] = {} - for placement: TerrainChunkVariant in _placements: - if placement != null: - found[placement.definition.stable_id] = true var result: Array[StringName] = [] - for required_id_value: String in required_chunk_ids: - var required_id := StringName(required_id_value) - if not found.has(required_id) and not result.has(required_id): + for required_id: StringName in _required_stable_ids: + if ( + int(_placement_counts.get(required_id, 0)) <= 0 + ): result.append(required_id) return result @@ -1140,13 +1929,7 @@ func _walkable_connectivity_validation_error( func _required_chunk_is_missing(stable_id: StringName) -> bool: if not required_chunk_ids.has(String(stable_id)): return false - for placement: TerrainChunkVariant in _placements: - if ( - placement != null - and placement.definition.stable_id == stable_id - ): - return false - return true + return int(_placement_counts.get(stable_id, 0)) <= 0 func _resolve_equivalent_rotations() -> void: @@ -1231,7 +2014,7 @@ func _build_solution_root() -> Node3D: if variant == null: continue var coordinate := Vector2i(index % grid_size.x, index / grid_size.x) - var chunk_root := variant.definition.packed_scene.instantiate() as Node3D + var chunk_root := _instantiate_chunk_root(variant.definition) if chunk_root == null: push_error("%s does not instantiate as Node3D." % variant.stable_key()) solution_root.free() @@ -1260,6 +2043,27 @@ func _build_solution_root() -> Node3D: return solution_root +func _instantiate_chunk_root( + definition: TerrainChunkDefinition, +) -> Node3D: + var terrain_visual := definition.packed_scene.instantiate() as Node3D + if terrain_visual == null: + return null + if definition.base_layer_scene == null: + return terrain_visual + var base_layer_visual := definition.base_layer_scene.instantiate() as Node3D + if base_layer_visual == null: + terrain_visual.free() + return null + var layered_root := Node3D.new() + layered_root.name = "LayeredTerrainChunk" + base_layer_visual.name = "TerrainBaseLayer" + terrain_visual.name = "TerrainOverlay" + layered_root.add_child(base_layer_visual) + layered_root.add_child(terrain_visual) + return layered_root + + func _replace_generated_chunks(solution_root: Node3D) -> void: _clear_generated_chunks() _generated_chunks = solution_root @@ -1274,11 +2078,34 @@ func _add_collision( chunk_root, definition.primary_mesh_name, ) + _add_collision_to_mesh( + primary_mesh, + definition.stable_id, + "TerrainCollision", + ) + if definition.base_layer_scene == null: + return + var base_layer_mesh := TerrainChunkAnalyzer.find_primary_mesh( + chunk_root, + definition.base_layer_mesh_name, + ) + _add_collision_to_mesh( + base_layer_mesh, + definition.stable_id, + "TerrainBaseLayerCollision", + ) + + +func _add_collision_to_mesh( + primary_mesh: MeshInstance3D, + stable_id: StringName, + collision_name: String, +) -> void: if primary_mesh == null or primary_mesh.mesh == null: return var terrain_shape := primary_mesh.mesh.create_trimesh_shape() if terrain_shape == null or terrain_shape.get_faces().is_empty(): - push_warning("%s produced no terrain collision." % definition.stable_id) + push_warning("%s produced no terrain collision." % stable_id) return var concave_shape := terrain_shape as ConcavePolygonShape3D if concave_shape != null: @@ -1287,7 +2114,7 @@ func _add_collision( # can never become an invisible collision gap. concave_shape.backface_collision = true var collision_body := StaticBody3D.new() - collision_body.name = "TerrainCollision" + collision_body.name = collision_name collision_body.collision_layer = 1 collision_body.collision_mask = 0 primary_mesh.add_child(collision_body) diff --git a/world/generation/terrain_chunk_variant.gd b/world/generation/terrain_chunk_variant.gd index efb590a..ca45812 100644 --- a/world/generation/terrain_chunk_variant.gd +++ b/world/generation/terrain_chunk_variant.gd @@ -35,6 +35,10 @@ func allows_non_water_neighbor_on_edge( ) +func surface_tags(edge: TerrainChunkTopology.Edge) -> PackedStringArray: + return definition.surface_tags_for_edge(source_edge(edge)) + + func topology_signature(quantization: float) -> String: var tokens := PackedStringArray() for edge_value: int in TerrainChunkTopology.Edge.values(): @@ -44,9 +48,10 @@ func topology_signature(quantization: float) -> String: func constraint_signature(quantization: float) -> String: - return "%s|neighbors:%s|ocean:%d|in:%d|out:%d" % [ + return "%s|neighbors:%s|surfaces:%s|ocean:%d|in:%d|out:%d" % [ topology_signature(quantization), _neighbor_rule_signature(), + _surface_rule_signature(), rotated_edge_mask(definition.ocean_facing_edges), rotated_edge_mask(definition.water_inlet_edges), rotated_edge_mask(definition.water_outlet_edges), @@ -65,6 +70,16 @@ func _neighbor_rule_signature() -> String: return "/".join(edge_tokens) +func _surface_rule_signature() -> String: + var edge_tokens := PackedStringArray() + for edge_value: int in TerrainChunkTopology.Edge.values(): + var edge := edge_value as TerrainChunkTopology.Edge + var edge_tags: PackedStringArray = surface_tags(edge).duplicate() + edge_tags.sort() + edge_tokens.append(",".join(edge_tags)) + return "/".join(edge_tokens) + + func rotated_edge_mask(source_mask: int) -> int: var result := 0 for edge_value: int in TerrainChunkTopology.Edge.values(): diff --git a/world/generation/terrain_prop_catalog.gd b/world/generation/terrain_prop_catalog.gd index f3375b1..e73fbe0 100644 --- a/world/generation/terrain_prop_catalog.gd +++ b/world/generation/terrain_prop_catalog.gd @@ -82,4 +82,23 @@ func validation_errors() -> PackedStringArray: "%s has a negative box collision dimension." % definition.stable_id ) + if definition.minimum_visual_scale > definition.maximum_visual_scale: + errors.append( + "%s has an inverted visual scale range." + % definition.stable_id + ) + if ( + not definition.material_variants.is_empty() + and definition.variant_material_slot_names.is_empty() + ): + errors.append( + "%s has material variants but no target material slots." + % definition.stable_id + ) + for material: Material in definition.material_variants: + if material == null: + errors.append( + "%s contains an empty material variant." + % definition.stable_id + ) return errors diff --git a/world/generation/terrain_prop_definition.gd b/world/generation/terrain_prop_definition.gd index ca04b21..f6e73ae 100644 --- a/world/generation/terrain_prop_definition.gd +++ b/world/generation/terrain_prop_definition.gd @@ -18,6 +18,15 @@ extends Resource @export_range(0.0, 100.0, 0.5) var preferred_nearby_radius := 0.0 @export_range(1.0, 10.0, 0.1) var nearby_preference_weight_multiplier := 1.0 @export_range(0.0, 10.0, 0.05) var clearance_radius := 0.5 +## Procedural instances use a deterministic uniform scale in this range. +@export_range(0.1, 4.0, 0.05) var minimum_visual_scale := 1.0 +@export_range(0.1, 4.0, 0.05) var maximum_visual_scale := 1.0 +## Presentation-only offset. The prop root remains exactly terrain-aligned. +@export var visual_offset := Vector3.ZERO +## A single material is chosen per prop and applied only to matching imported +## material slots. This keeps trunks untouched while varying foliage. +@export var variant_material_slot_names := PackedStringArray() +@export var material_variants: Array[Material] = [] @export_range(0.0, 5.0, 0.05) var collision_radius := 0.0 @export_range(0.0, 20.0, 0.05) var collision_height := 0.0 @export var collision_box_size := Vector3.ZERO 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