From a62cce6404024b19c0ce77d934135b2fbff662e8 Mon Sep 17 00:00:00 2001 From: Voyager Date: Fri, 14 Aug 2026 22:11:49 -0400 Subject: [PATCH] Add networked crab gathering prototype --- economy/fishing_shop_stock.gd | 4 + fish/fish_data.gd | 31 +- fish/fish_selector.gd | 2 +- fish/pools/fish_catalog.tres | 3 +- fish/species/crab_blue/crab_blue.tres | 3 + fish/species/crab_brown/crab_brown.png | Bin 0 -> 32494 bytes fish/species/crab_brown/crab_brown.png.import | 40 + fish/species/crab_brown/crab_brown.tres | 27 + .../crab_dungeness/crab_dungeness.tres | 3 + fish/species/crab_ghost/crab_ghost.tres | 3 + fishing/fishing_spot.gd | 38 + gathering/catalog/crab_blue.tres | 13 + gathering/catalog/crab_brown.tres | 26 + gathering/catalog/crab_dungeness.tres | 13 + gathering/catalog/crab_ghost.tres | 13 + gathering/catalog/gatherable_catalog.tres | 11 + gathering/gatherable_catalog.gd | 23 + gathering/gatherable_catalog.gd.uid | 1 + gathering/gatherable_data.gd | 61 + gathering/gatherable_data.gd.uid | 1 + gathering/gathering_controller.gd | 247 ++++ gathering/gathering_controller.gd.uid | 1 + gathering/world_gatherable.gd | 113 ++ gathering/world_gatherable.gd.uid | 1 + items/catalog/crab_net.tres | 16 + items/catalog/item_catalog.tres | 5 +- jobs/job_catalog.gd | 2 +- jobs/player_job_service.gd | 6 +- main/main.gd | 53 + main/main.tscn | 14 + network/network_item_use_service.gd | 12 + network/network_protocol.gd | 5 +- network/network_session.gd | 2 + network/network_world_spawn_protocol.gd | 94 ++ network/network_world_spawn_protocol.gd.uid | 1 + network/network_world_spawn_service.gd | 1126 +++++++++++++++++ network/network_world_spawn_service.gd.uid | 1 + player/net_attachment.tscn | 55 + player/player.gd | 70 + scripts/run_validations.sh | 3 + tests/economy_regression_validation.gd | 4 +- tests/fish_catalog_content_validation.gd | 24 +- tests/fish_hotbar_showcase_validation.gd | 2 +- tests/fish_quality_validation.gd | 2 +- tests/gathering_showcase_validation.gd | 89 ++ tests/gathering_showcase_validation.gd.uid | 1 + tests/logbook_runtime_validation.gd | 6 +- tests/logbook_validation.gd | 31 +- tests/world_spawn_multiplayer_validation.gd | 168 +++ .../world_spawn_multiplayer_validation.gd.uid | 1 + tests/world_spawn_protocol_validation.gd | 102 ++ tests/world_spawn_protocol_validation.gd.uid | 1 + ui/logbook_catalog.gd | 49 +- ui/logbook_page.gd | 32 +- world/regions/starter_island_region.gd | 83 ++ world/test_world.gd | 10 + 56 files changed, 2692 insertions(+), 56 deletions(-) create mode 100644 fish/species/crab_brown/crab_brown.png create mode 100644 fish/species/crab_brown/crab_brown.png.import create mode 100644 fish/species/crab_brown/crab_brown.tres create mode 100644 gathering/catalog/crab_blue.tres create mode 100644 gathering/catalog/crab_brown.tres create mode 100644 gathering/catalog/crab_dungeness.tres create mode 100644 gathering/catalog/crab_ghost.tres create mode 100644 gathering/catalog/gatherable_catalog.tres create mode 100644 gathering/gatherable_catalog.gd create mode 100644 gathering/gatherable_catalog.gd.uid create mode 100644 gathering/gatherable_data.gd create mode 100644 gathering/gatherable_data.gd.uid create mode 100644 gathering/gathering_controller.gd create mode 100644 gathering/gathering_controller.gd.uid create mode 100644 gathering/world_gatherable.gd create mode 100644 gathering/world_gatherable.gd.uid create mode 100644 items/catalog/crab_net.tres create mode 100644 network/network_world_spawn_protocol.gd create mode 100644 network/network_world_spawn_protocol.gd.uid create mode 100644 network/network_world_spawn_service.gd create mode 100644 network/network_world_spawn_service.gd.uid create mode 100644 player/net_attachment.tscn create mode 100644 tests/gathering_showcase_validation.gd create mode 100644 tests/gathering_showcase_validation.gd.uid create mode 100644 tests/world_spawn_multiplayer_validation.gd create mode 100644 tests/world_spawn_multiplayer_validation.gd.uid create mode 100644 tests/world_spawn_protocol_validation.gd create mode 100644 tests/world_spawn_protocol_validation.gd.uid diff --git a/economy/fishing_shop_stock.gd b/economy/fishing_shop_stock.gd index 4f5fda4..e8104f4 100644 --- a/economy/fishing_shop_stock.gd +++ b/economy/fishing_shop_stock.gd @@ -10,6 +10,7 @@ const WORM_MAX_STACK: int = 10 const FISH_FINDER_ID: StringName = &"fish_finder" const MAGNET_ID: StringName = &"magnet" const BATTERIES_ID: StringName = &"batteries" +const CRAB_NET_ID: StringName = &"crab_net" const ITEM_PRICES: Dictionary[StringName, int] = { &"worms": 1, @@ -26,6 +27,7 @@ const ITEM_PRICES: Dictionary[StringName, int] = { FISH_FINDER_ID: 500, MAGNET_ID: 250, BATTERIES_ID: 15, + CRAB_NET_ID: 50, } const BAIT_UNLOCK_PRICES: Dictionary[StringName, int] = { &"snails": 400, @@ -46,6 +48,7 @@ const ITEM_ORDER: Array[StringName] = [ &"the_standby", FISH_FINDER_ID, MAGNET_ID, + CRAB_NET_ID, &"coffee", &"energy_drink", &"snack", @@ -108,6 +111,7 @@ static func is_permanent_unlock( item.is_lure() or item_id == FISH_FINDER_ID or item_id == MAGNET_ID + or item_id == CRAB_NET_ID or item is FishingRodDataType ) ) diff --git a/fish/fish_data.gd b/fish/fish_data.gd index 60ce03e..c34a953 100644 --- a/fish/fish_data.gd +++ b/fish/fish_data.gd @@ -24,6 +24,17 @@ enum Rarity { LEGENDARY, } +enum CollectionMethod { + FISHING, + NET, + DIGGING, +} + +enum LogbookSection { + AUTOMATIC, + SHELLFISH, +} + @export var id: StringName @export var display_name: String @export_category("Developer Catalog") @@ -32,6 +43,9 @@ enum Rarity { @export_range(1, 999999, 1) var catalog_number: int = 1 @export var collection_group: StringName @export_multiline var logbook_fact: String +@export var collection_method: CollectionMethod = CollectionMethod.FISHING +@export var logbook_section: LogbookSection = LogbookSection.AUTOMATIC +@export var habitat_label: String = "" @export_category("Fishing") @export_flags("Fresh Water", "Salt Water") var allowed_water_types: int = WaterType.ALL_FISHABLE_MASK @@ -53,9 +67,12 @@ var allowed_water_types: int = WaterType.ALL_FISHABLE_MASK func is_selectable() -> bool: + return active and is_valid_catalog_entry() + + +func is_valid_catalog_entry() -> bool: return ( - active - and not id.is_empty() + not id.is_empty() and base_catch_weight > 0.0 and catch_profile != null and weight_min_lb > 0.0 @@ -69,6 +86,16 @@ func is_selectable() -> bool: ) +func is_fishable() -> bool: + return is_selectable() and collection_method == CollectionMethod.FISHING + + +func get_habitat_label() -> String: + if not habitat_label.strip_edges().is_empty(): + return habitat_label.strip_edges().to_lower() + return WaterType.label(get_primary_water_type()).to_lower() + + func is_allowed_in_water(type: WaterType.Type) -> bool: return (allowed_water_types & WaterType.mask_for(type)) != 0 diff --git a/fish/fish_selector.gd b/fish/fish_selector.gd index 89bc3ca..de906f8 100644 --- a/fish/fish_selector.gd +++ b/fish/fish_selector.gd @@ -46,7 +46,7 @@ func select_fish( var weights: Array[float] = [] var total_weight: float = 0.0 for fish: FishDataType in pool.candidates: - if fish == null or not fish.is_selectable(): + if fish == null or not fish.is_fishable(): continue if not fish.is_allowed_in_water(context.water_type): continue diff --git a/fish/pools/fish_catalog.tres b/fish/pools/fish_catalog.tres index 666dce2..e2f7385 100644 --- a/fish/pools/fish_catalog.tres +++ b/fish/pools/fish_catalog.tres @@ -314,7 +314,8 @@ [ext_resource type="Resource" path="res://fish/species/whale_humpback/whale_humpback.tres" id="312_whale_humpback"] [ext_resource type="Resource" path="res://fish/species/whale_killer/whale_killer.tres" id="313_whale_killer"] [ext_resource type="Resource" path="res://fish/species/whale_sperm/whale_sperm.tres" id="314_whale_sperm"] +[ext_resource type="Resource" path="res://fish/species/crab_brown/crab_brown.tres" id="315_crab_brown"] [resource] script = ExtResource("1_pool") -candidates = [ExtResource("2_bluegill"), ExtResource("3_bass"), ExtResource("4_carp"), ExtResource("5_sunfish"), ExtResource("6_catfish_blue"), ExtResource("7_catfish_channel"), ExtResource("8_catfish_flathead"), ExtResource("9_catfish_white"), ExtResource("10_tuna_albacore"), ExtResource("11_tuna_bigeye"), ExtResource("12_tuna_bluefin"), ExtResource("13_tuna_skipjack"), ExtResource("14_tuna_yellowfin"), ExtResource("15_goby_round"), ExtResource("16_salmon_atlantic"), ExtResource("17_salmon_chum"), ExtResource("18_salmon_coho"), ExtResource("19_salmon_pink"), ExtResource("20_salmon_sockeye"), ExtResource("21_anchovy_european"), ExtResource("22_anchovy_northern"), ExtResource("23_chub_european"), ExtResource("24_chub_flame"), ExtResource("25_chub_lake"), ExtResource("26_goldfish"), ExtResource("27_goldfish_bubbleeye"), ExtResource("28_grouper_gulf"), ExtResource("29_grouper_red"), ExtResource("30_mackerel_atlantic"), ExtResource("31_mackerel_cero"), ExtResource("32_mackerel_chub"), ExtResource("33_mackerel_king"), ExtResource("34_mackerel_spanish"), ExtResource("35_marlin_black"), ExtResource("36_marlin_blue"), ExtResource("37_marlin_white"), ExtResource("38_pomfret_black"), ExtResource("39_pomfret_chinese"), ExtResource("40_pomfret_golden"), ExtResource("41_pomfret_white"), ExtResource("42_sailfish"), ExtResource("43_sauger"), ExtResource("44_saugeye"), ExtResource("45_snapper_lane"), ExtResource("46_snapper_mangrove"), ExtResource("47_snapper_mutton"), ExtResource("48_snapper_red"), ExtResource("49_swordfish"), ExtResource("50_trout_cutthroat"), ExtResource("51_trout_golden"), ExtResource("52_trout_rainbow"), ExtResource("53_trout_steelhead"), ExtResource("54_walleye"), ExtResource("55_bowfin"), ExtResource("56_sturgeon_lake"), ExtResource("57_gar_longnose"), ExtResource("58_paddlefish"), ExtResource("59_sturgeon_shovelnose"), ExtResource("60_gar_spotted"), ExtResource("61_lungfish_west_african"), ExtResource("62_mudskipper_atlantic"), ExtResource("63_foureyes_largescale"), ExtResource("64_mullet_red"), ExtResource("65_plaice_european"), ExtResource("66_flounder_summer"), ExtResource("67_flounder_winter"), ExtResource("68_dugong"), ExtResource("69_seal_harbor"), ExtResource("70_sea_otter"), ExtResource("71_walrus"), ExtResource("72_manatee_west_indian"), ExtResource("73_pipefish_bay"), ExtResource("74_seahorse_lined"), ExtResource("75_monkfish"), ExtResource("76_tripletail"), ExtResource("77_bluefish"), ExtResource("78_jack_crevalle"), ExtResource("79_queenfish_talang"), ExtResource("80_sand_lance_american"), ExtResource("81_menhaden_atlantic"), ExtResource("82_sardine_european"), ExtResource("83_sardine_pacific"), ExtResource("84_shark_blacktip"), ExtResource("85_shark_great_hammerhead"), ExtResource("86_shark_great_white"), ExtResource("87_shark_tiger"), ExtResource("88_shark_whale"), ExtResource("89_burbot"), ExtResource("90_cisco"), ExtResource("91_whitefish_lake"), ExtResource("92_char_arctic"), ExtResource("93_grayling_arctic"), ExtResource("94_trout_brown"), ExtResource("95_wolffish_atlantic"), ExtResource("96_cusk"), ExtResource("97_wolffish_spotted"), ExtResource("98_herring_atlantic"), ExtResource("99_capelin"), ExtResource("100_sprat_european"), ExtResource("101_herring_pacific"), ExtResource("102_pollock_alaska"), ExtResource("103_cod_atlantic"), ExtResource("104_pollock_atlantic"), ExtResource("105_sole_dover"), ExtResource("106_haddock"), ExtResource("107_cod_pacific"), ExtResource("108_hake_pacific"), ExtResource("109_hake_silver"), ExtResource("110_nautilus_chambered"), ExtResource("111_cuttlefish_common"), ExtResource("112_cuttlefish_flamboyant"), ExtResource("113_tilefish_blueline"), ExtResource("114_tilefish_golden"), ExtResource("115_ocean_perch_pacific"), ExtResource("116_sea_bass_chilean"), ExtResource("117_sablefish"), ExtResource("118_wreckfish"), ExtResource("119_oarfish_giant"), ExtResource("120_coelacanth_west_indian"), ExtResource("121_anglerfish_black_seadevil"), ExtResource("122_fangtooth_common"), ExtResource("123_gulper_eel"), ExtResource("124_barreleye_pacific"), ExtResource("125_blobfish_smooth_head"), ExtResource("126_lanternfish_spotted"), ExtResource("127_sea_star_crown_of_thorns"), ExtResource("128_sea_cucumber_giant_california"), ExtResource("129_urchin_purple"), ExtResource("130_sand_dollar"), ExtResource("131_sea_star_sunflower"), ExtResource("132_axolotl"), ExtResource("133_hellbender"), ExtResource("134_guppy"), ExtResource("135_tetra_neon"), ExtResource("136_danio_zebra"), ExtResource("137_largemouth_bass"), ExtResource("138_smallmouth_bass"), ExtResource("139_buffalo_bigmouth"), ExtResource("140_redhorse_golden"), ExtResource("141_quillback"), ExtResource("142_buffalo_smallmouth"), ExtResource("143_sucker_white"), ExtResource("144_bullhead_black"), ExtResource("145_bullhead_brown"), ExtResource("146_madtom_tadpole"), ExtResource("147_bullhead_yellow"), ExtResource("148_barbel_common"), ExtResource("149_bream_common"), ExtResource("150_roach_common"), ExtResource("151_fallfish"), ExtResource("152_ide"), ExtResource("153_rudd"), ExtResource("154_tench"), ExtResource("155_crayfish_red_swamp"), ExtResource("156_gar_alligator"), ExtResource("157_arapaima"), ExtResource("158_tigerfish_goliath"), ExtResource("159_muskellunge"), ExtResource("160_shiner_common"), ExtResource("161_chub_creek"), ExtResource("162_shiner_emerald"), ExtResource("163_minnow_fathead"), ExtResource("164_shiner_golden"), ExtResource("165_electric_eel"), ExtResource("166_freshwater_drum"), ExtResource("167_goldeye"), ExtResource("168_mooneye"), ExtResource("169_loach_weather"), ExtResource("170_koi"), ExtResource("171_crappie_black"), ExtResource("172_green_sunfish"), ExtResource("173_pumpkinseed"), ExtResource("174_redear_sunfish"), ExtResource("175_warmouth"), ExtResource("176_crappie_white"), ExtResource("177_yellow_perch"), ExtResource("178_pickerel_chain"), ExtResource("179_pickerel_grass"), ExtResource("180_pike_northern"), ExtResource("181_jelly_crystal"), ExtResource("182_jelly_lions_mane"), ExtResource("183_jelly_moon"), ExtResource("184_jelly_pacific_sea_nettle"), ExtResource("185_jelly_sea_wasp"), ExtResource("186_pompano_african"), ExtResource("187_cobia"), ExtResource("188_trevally_giant"), ExtResource("189_amberjack_greater"), ExtResource("190_amberjack_lesser"), ExtResource("191_halibut_atlantic"), ExtResource("192_halibut_pacific"), ExtResource("193_turbot"), ExtResource("194_lobster_american"), ExtResource("195_krill_antarctic"), ExtResource("196_lobster_caribbean_spiny"), ExtResource("197_shrimp_peacock_mantis"), ExtResource("198_shrimp_skunk_cleaner"), ExtResource("199_crab_ghost"), ExtResource("200_crab_blue"), ExtResource("201_crab_dungeness"), ExtResource("202_crab_japanese_spider"), ExtResource("203_crab_red_king"), ExtResource("204_eel_american"), ExtResource("205_eel_european"), ExtResource("206_smelt_rainbow"), ExtResource("207_smelt_eulachon"), ExtResource("208_sturgeon_white"), ExtResource("209_mahi_mahi"), ExtResource("210_opah"), ExtResource("211_wahoo"), ExtResource("212_octopus_common"), ExtResource("213_octopus_day"), ExtResource("214_octopus_giant_pacific"), ExtResource("215_octopus_greater_blue_ringed"), ExtResource("216_skate_barndoor"), ExtResource("217_manta_ray_giant"), ExtResource("218_sawfish_largetooth"), ExtResource("219_stingray_ocellate_river"), ExtResource("220_stingray_southern"), ExtResource("221_lionfish_red"), ExtResource("222_scorpionfish_red"), ExtResource("223_moray_green"), ExtResource("224_flounder_peacock"), ExtResource("225_surgeonfish_blue"), ExtResource("226_parrotfish_rainbow"), ExtResource("227_parrotfish_stoplight"), ExtResource("228_tang_yellow"), ExtResource("229_trumpetfish_atlantic"), ExtResource("230_triggerfish_gray"), ExtResource("231_pufferfish_guineafowl"), ExtResource("232_cowfish_longhorn"), ExtResource("233_triggerfish_queen"), ExtResource("234_cornetfish_red"), ExtResource("235_porcupinefish_spotted"), ExtResource("236_boxfish_yellow"), ExtResource("237_butterflyfish_copperband"), ExtResource("238_clownfish_ocellaris"), ExtResource("239_angelfish_queen"), ExtResource("240_grouper_atlantic_goliath"), ExtResource("241_trevally_bluefin"), ExtResource("242_grouper_giant"), ExtResource("243_barracuda_great"), ExtResource("244_grouper_nassau"), ExtResource("245_chromis_blue_green"), ExtResource("246_grunt_bluestriped"), ExtResource("247_grunt_french"), ExtResource("248_damselfish_sergeant_major"), ExtResource("249_wrasse_ballan"), ExtResource("250_wrasse_cleaner"), ExtResource("251_hogfish"), ExtResource("252_turtle_green"), ExtResource("253_turtle_hawksbill"), ExtResource("254_turtle_leatherback"), ExtResource("255_turtle_loggerhead"), ExtResource("256_seadragon_leafy"), ExtResource("257_seadragon_weedy"), ExtResource("258_oyster_black_lip_pearl"), ExtResource("259_clam_giant"), ExtResource("260_clam_geoduck"), ExtResource("261_conch_queen"), ExtResource("262_abalone_red"), ExtResource("263_squid_bigfin_reef"), ExtResource("264_squid_colossal"), ExtResource("265_squid_giant"), ExtResource("266_squid_humboldt"), ExtResource("267_halfbeak_ballyhoo"), ExtResource("268_needlefish_hound"), ExtResource("269_flyingfish_tropical"), ExtResource("270_croaker_atlantic"), ExtResource("271_black_drum"), ExtResource("272_red_drum"), ExtResource("273_mullet_striped"), ExtResource("274_weakfish"), ExtResource("275_white_perch"), ExtResource("276_croaker_yellowfin"), ExtResource("277_rockfish_black"), ExtResource("278_sea_bass_black"), ExtResource("279_rockfish_canary"), ExtResource("280_porgy_scup"), ExtResource("281_sheepshead"), ExtResource("282_tautog"), ExtResource("283_rockfish_yelloweye"), ExtResource("284_lingcod"), ExtResource("285_milkfish"), ExtResource("286_roosterfish"), ExtResource("287_tarpon_atlantic"), ExtResource("288_barramundi"), ExtResource("289_snook_common"), ExtResource("290_bonefish"), ExtResource("291_pompano_florida"), ExtResource("292_ladyfish"), ExtResource("293_permit"), ExtResource("294_knifefish_clown"), ExtResource("295_loach_clown"), ExtResource("296_discus"), ExtResource("297_angelfish_freshwater"), ExtResource("298_gourami_giant"), ExtResource("299_tilapia_nile"), ExtResource("300_oscar"), ExtResource("301_betta_siamese"), ExtResource("302_tambaqui"), ExtResource("303_catfish_walking"), ExtResource("304_snakehead_giant"), ExtResource("305_dorado_golden"), ExtResource("306_mahseer_golden"), ExtResource("307_peacock_bass"), ExtResource("308_piranha_red_bellied"), ExtResource("309_arowana_silver"), ExtResource("310_whale_blue"), ExtResource("311_dolphin_bottlenose"), ExtResource("312_whale_humpback"), ExtResource("313_whale_killer"), ExtResource("314_whale_sperm")] +candidates = [ExtResource("2_bluegill"), ExtResource("3_bass"), ExtResource("4_carp"), ExtResource("5_sunfish"), ExtResource("6_catfish_blue"), ExtResource("7_catfish_channel"), ExtResource("8_catfish_flathead"), ExtResource("9_catfish_white"), ExtResource("10_tuna_albacore"), ExtResource("11_tuna_bigeye"), ExtResource("12_tuna_bluefin"), ExtResource("13_tuna_skipjack"), ExtResource("14_tuna_yellowfin"), ExtResource("15_goby_round"), ExtResource("16_salmon_atlantic"), ExtResource("17_salmon_chum"), ExtResource("18_salmon_coho"), ExtResource("19_salmon_pink"), ExtResource("20_salmon_sockeye"), ExtResource("21_anchovy_european"), ExtResource("22_anchovy_northern"), ExtResource("23_chub_european"), ExtResource("24_chub_flame"), ExtResource("25_chub_lake"), ExtResource("26_goldfish"), ExtResource("27_goldfish_bubbleeye"), ExtResource("28_grouper_gulf"), ExtResource("29_grouper_red"), ExtResource("30_mackerel_atlantic"), ExtResource("31_mackerel_cero"), ExtResource("32_mackerel_chub"), ExtResource("33_mackerel_king"), ExtResource("34_mackerel_spanish"), ExtResource("35_marlin_black"), ExtResource("36_marlin_blue"), ExtResource("37_marlin_white"), ExtResource("38_pomfret_black"), ExtResource("39_pomfret_chinese"), ExtResource("40_pomfret_golden"), ExtResource("41_pomfret_white"), ExtResource("42_sailfish"), ExtResource("43_sauger"), ExtResource("44_saugeye"), ExtResource("45_snapper_lane"), ExtResource("46_snapper_mangrove"), ExtResource("47_snapper_mutton"), ExtResource("48_snapper_red"), ExtResource("49_swordfish"), ExtResource("50_trout_cutthroat"), ExtResource("51_trout_golden"), ExtResource("52_trout_rainbow"), ExtResource("53_trout_steelhead"), ExtResource("54_walleye"), ExtResource("55_bowfin"), ExtResource("56_sturgeon_lake"), ExtResource("57_gar_longnose"), ExtResource("58_paddlefish"), ExtResource("59_sturgeon_shovelnose"), ExtResource("60_gar_spotted"), ExtResource("61_lungfish_west_african"), ExtResource("62_mudskipper_atlantic"), ExtResource("63_foureyes_largescale"), ExtResource("64_mullet_red"), ExtResource("65_plaice_european"), ExtResource("66_flounder_summer"), ExtResource("67_flounder_winter"), ExtResource("68_dugong"), ExtResource("69_seal_harbor"), ExtResource("70_sea_otter"), ExtResource("71_walrus"), ExtResource("72_manatee_west_indian"), ExtResource("73_pipefish_bay"), ExtResource("74_seahorse_lined"), ExtResource("75_monkfish"), ExtResource("76_tripletail"), ExtResource("77_bluefish"), ExtResource("78_jack_crevalle"), ExtResource("79_queenfish_talang"), ExtResource("80_sand_lance_american"), ExtResource("81_menhaden_atlantic"), ExtResource("82_sardine_european"), ExtResource("83_sardine_pacific"), ExtResource("84_shark_blacktip"), ExtResource("85_shark_great_hammerhead"), ExtResource("86_shark_great_white"), ExtResource("87_shark_tiger"), ExtResource("88_shark_whale"), ExtResource("89_burbot"), ExtResource("90_cisco"), ExtResource("91_whitefish_lake"), ExtResource("92_char_arctic"), ExtResource("93_grayling_arctic"), ExtResource("94_trout_brown"), ExtResource("95_wolffish_atlantic"), ExtResource("96_cusk"), ExtResource("97_wolffish_spotted"), ExtResource("98_herring_atlantic"), ExtResource("99_capelin"), ExtResource("100_sprat_european"), ExtResource("101_herring_pacific"), ExtResource("102_pollock_alaska"), ExtResource("103_cod_atlantic"), ExtResource("104_pollock_atlantic"), ExtResource("105_sole_dover"), ExtResource("106_haddock"), ExtResource("107_cod_pacific"), ExtResource("108_hake_pacific"), ExtResource("109_hake_silver"), ExtResource("110_nautilus_chambered"), ExtResource("111_cuttlefish_common"), ExtResource("112_cuttlefish_flamboyant"), ExtResource("113_tilefish_blueline"), ExtResource("114_tilefish_golden"), ExtResource("115_ocean_perch_pacific"), ExtResource("116_sea_bass_chilean"), ExtResource("117_sablefish"), ExtResource("118_wreckfish"), ExtResource("119_oarfish_giant"), ExtResource("120_coelacanth_west_indian"), ExtResource("121_anglerfish_black_seadevil"), ExtResource("122_fangtooth_common"), ExtResource("123_gulper_eel"), ExtResource("124_barreleye_pacific"), ExtResource("125_blobfish_smooth_head"), ExtResource("126_lanternfish_spotted"), ExtResource("127_sea_star_crown_of_thorns"), ExtResource("128_sea_cucumber_giant_california"), ExtResource("129_urchin_purple"), ExtResource("130_sand_dollar"), ExtResource("131_sea_star_sunflower"), ExtResource("132_axolotl"), ExtResource("133_hellbender"), ExtResource("134_guppy"), ExtResource("135_tetra_neon"), ExtResource("136_danio_zebra"), ExtResource("137_largemouth_bass"), ExtResource("138_smallmouth_bass"), ExtResource("139_buffalo_bigmouth"), ExtResource("140_redhorse_golden"), ExtResource("141_quillback"), ExtResource("142_buffalo_smallmouth"), ExtResource("143_sucker_white"), ExtResource("144_bullhead_black"), ExtResource("145_bullhead_brown"), ExtResource("146_madtom_tadpole"), ExtResource("147_bullhead_yellow"), ExtResource("148_barbel_common"), ExtResource("149_bream_common"), ExtResource("150_roach_common"), ExtResource("151_fallfish"), ExtResource("152_ide"), ExtResource("153_rudd"), ExtResource("154_tench"), ExtResource("155_crayfish_red_swamp"), ExtResource("156_gar_alligator"), ExtResource("157_arapaima"), ExtResource("158_tigerfish_goliath"), ExtResource("159_muskellunge"), ExtResource("160_shiner_common"), ExtResource("161_chub_creek"), ExtResource("162_shiner_emerald"), ExtResource("163_minnow_fathead"), ExtResource("164_shiner_golden"), ExtResource("165_electric_eel"), ExtResource("166_freshwater_drum"), ExtResource("167_goldeye"), ExtResource("168_mooneye"), ExtResource("169_loach_weather"), ExtResource("170_koi"), ExtResource("171_crappie_black"), ExtResource("172_green_sunfish"), ExtResource("173_pumpkinseed"), ExtResource("174_redear_sunfish"), ExtResource("175_warmouth"), ExtResource("176_crappie_white"), ExtResource("177_yellow_perch"), ExtResource("178_pickerel_chain"), ExtResource("179_pickerel_grass"), ExtResource("180_pike_northern"), ExtResource("181_jelly_crystal"), ExtResource("182_jelly_lions_mane"), ExtResource("183_jelly_moon"), ExtResource("184_jelly_pacific_sea_nettle"), ExtResource("185_jelly_sea_wasp"), ExtResource("186_pompano_african"), ExtResource("187_cobia"), ExtResource("188_trevally_giant"), ExtResource("189_amberjack_greater"), ExtResource("190_amberjack_lesser"), ExtResource("191_halibut_atlantic"), ExtResource("192_halibut_pacific"), ExtResource("193_turbot"), ExtResource("194_lobster_american"), ExtResource("195_krill_antarctic"), ExtResource("196_lobster_caribbean_spiny"), ExtResource("197_shrimp_peacock_mantis"), ExtResource("198_shrimp_skunk_cleaner"), ExtResource("199_crab_ghost"), ExtResource("200_crab_blue"), ExtResource("201_crab_dungeness"), ExtResource("202_crab_japanese_spider"), ExtResource("203_crab_red_king"), ExtResource("204_eel_american"), ExtResource("205_eel_european"), ExtResource("206_smelt_rainbow"), ExtResource("207_smelt_eulachon"), ExtResource("208_sturgeon_white"), ExtResource("209_mahi_mahi"), ExtResource("210_opah"), ExtResource("211_wahoo"), ExtResource("212_octopus_common"), ExtResource("213_octopus_day"), ExtResource("214_octopus_giant_pacific"), ExtResource("215_octopus_greater_blue_ringed"), ExtResource("216_skate_barndoor"), ExtResource("217_manta_ray_giant"), ExtResource("218_sawfish_largetooth"), ExtResource("219_stingray_ocellate_river"), ExtResource("220_stingray_southern"), ExtResource("221_lionfish_red"), ExtResource("222_scorpionfish_red"), ExtResource("223_moray_green"), ExtResource("224_flounder_peacock"), ExtResource("225_surgeonfish_blue"), ExtResource("226_parrotfish_rainbow"), ExtResource("227_parrotfish_stoplight"), ExtResource("228_tang_yellow"), ExtResource("229_trumpetfish_atlantic"), ExtResource("230_triggerfish_gray"), ExtResource("231_pufferfish_guineafowl"), ExtResource("232_cowfish_longhorn"), ExtResource("233_triggerfish_queen"), ExtResource("234_cornetfish_red"), ExtResource("235_porcupinefish_spotted"), ExtResource("236_boxfish_yellow"), ExtResource("237_butterflyfish_copperband"), ExtResource("238_clownfish_ocellaris"), ExtResource("239_angelfish_queen"), ExtResource("240_grouper_atlantic_goliath"), ExtResource("241_trevally_bluefin"), ExtResource("242_grouper_giant"), ExtResource("243_barracuda_great"), ExtResource("244_grouper_nassau"), ExtResource("245_chromis_blue_green"), ExtResource("246_grunt_bluestriped"), ExtResource("247_grunt_french"), ExtResource("248_damselfish_sergeant_major"), ExtResource("249_wrasse_ballan"), ExtResource("250_wrasse_cleaner"), ExtResource("251_hogfish"), ExtResource("252_turtle_green"), ExtResource("253_turtle_hawksbill"), ExtResource("254_turtle_leatherback"), ExtResource("255_turtle_loggerhead"), ExtResource("256_seadragon_leafy"), ExtResource("257_seadragon_weedy"), ExtResource("258_oyster_black_lip_pearl"), ExtResource("259_clam_giant"), ExtResource("260_clam_geoduck"), ExtResource("261_conch_queen"), ExtResource("262_abalone_red"), ExtResource("263_squid_bigfin_reef"), ExtResource("264_squid_colossal"), ExtResource("265_squid_giant"), ExtResource("266_squid_humboldt"), ExtResource("267_halfbeak_ballyhoo"), ExtResource("268_needlefish_hound"), ExtResource("269_flyingfish_tropical"), ExtResource("270_croaker_atlantic"), ExtResource("271_black_drum"), ExtResource("272_red_drum"), ExtResource("273_mullet_striped"), ExtResource("274_weakfish"), ExtResource("275_white_perch"), ExtResource("276_croaker_yellowfin"), ExtResource("277_rockfish_black"), ExtResource("278_sea_bass_black"), ExtResource("279_rockfish_canary"), ExtResource("280_porgy_scup"), ExtResource("281_sheepshead"), ExtResource("282_tautog"), ExtResource("283_rockfish_yelloweye"), ExtResource("284_lingcod"), ExtResource("285_milkfish"), ExtResource("286_roosterfish"), ExtResource("287_tarpon_atlantic"), ExtResource("288_barramundi"), ExtResource("289_snook_common"), ExtResource("290_bonefish"), ExtResource("291_pompano_florida"), ExtResource("292_ladyfish"), ExtResource("293_permit"), ExtResource("294_knifefish_clown"), ExtResource("295_loach_clown"), ExtResource("296_discus"), ExtResource("297_angelfish_freshwater"), ExtResource("298_gourami_giant"), ExtResource("299_tilapia_nile"), ExtResource("300_oscar"), ExtResource("301_betta_siamese"), ExtResource("302_tambaqui"), ExtResource("303_catfish_walking"), ExtResource("304_snakehead_giant"), ExtResource("305_dorado_golden"), ExtResource("306_mahseer_golden"), ExtResource("307_peacock_bass"), ExtResource("308_piranha_red_bellied"), ExtResource("309_arowana_silver"), ExtResource("310_whale_blue"), ExtResource("311_dolphin_bottlenose"), ExtResource("312_whale_humpback"), ExtResource("313_whale_killer"), ExtResource("314_whale_sperm"), ExtResource("315_crab_brown")] diff --git a/fish/species/crab_blue/crab_blue.tres b/fish/species/crab_blue/crab_blue.tres index 2248f78..fd2ec5c 100644 --- a/fish/species/crab_blue/crab_blue.tres +++ b/fish/species/crab_blue/crab_blue.tres @@ -16,6 +16,9 @@ active = false catalog_number = 3902 collection_group = &"Marine crabs" logbook_fact = "blue crabs swim with flattened rear legs shaped like paddles. bright blue claws and an olive shell conceal a quick, defensive temperament." +collection_method = 1 +logbook_section = 1 +habitat_label = "shallow coastal water" allowed_water_types = 2 rarity = 0 base_catch_weight = 1.15 diff --git a/fish/species/crab_brown/crab_brown.png b/fish/species/crab_brown/crab_brown.png new file mode 100644 index 0000000000000000000000000000000000000000..a06f0392df129df89f0c73b28ddd5a148e4aad48 GIT binary patch literal 32494 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 void: return if not event.is_action("fish_primary"): return + var selected_item: ItemDataType = _get_active_item() + if ( + state == FishingState.READY + and selected_item != null + and selected_item.item_id == FishingShopStockType.CRAB_NET_ID + ): + return if event.is_pressed(): match state: FishingState.READY: @@ -737,6 +745,36 @@ func refresh_active_item_status() -> void: status_changed.emit("") +func report_external_status(message: String) -> void: + status_changed.emit(message) + + +func present_external_catch(fish_catch: FishCatchType) -> bool: + if ( + fish_catch == null + or not fish_catch.is_valid() + or state != FishingState.READY + or _local_player == null + ): + return false + _active_player = _local_player + _pending_catch = fish_catch + _active_player.set_movement_enabled(false) + state = FishingState.SHOWING_CATCH + _showcase_ready = true + _showcase_outcome_completed = true + _put_away_press_armed = false + _active_player.begin_catch_showcase(fish_catch) + showcase_changed.emit( + fish_catch.fish.display_name, + fish_catch.fish.get_rarity_name(), + fish_catch.weight_lb, + fish_catch.quality, + true, + ) + return true + + func _update_cast_charge(delta: float) -> void: if state != FishingState.AIMING_CAST: return diff --git a/gathering/catalog/crab_blue.tres b/gathering/catalog/crab_blue.tres new file mode 100644 index 0000000..9e02a04 --- /dev/null +++ b/gathering/catalog/crab_blue.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" script_class="GatherableData" load_steps=3 format=3] + +[ext_resource type="Script" path="res://gathering/gatherable_data.gd" id="1_data"] +[ext_resource type="Resource" path="res://fish/species/crab_blue/crab_blue.tres" id="2_catch"] + +[resource] +script = ExtResource("1_data") +type_id = &"crab_blue" +catch_data = ExtResource("2_catch") +required_tool_id = &"crab_net" +surface_materials = Array[StringName]([&"sand"]) +minimum_surface_y = -0.8 +population = 4 diff --git a/gathering/catalog/crab_brown.tres b/gathering/catalog/crab_brown.tres new file mode 100644 index 0000000..13ac341 --- /dev/null +++ b/gathering/catalog/crab_brown.tres @@ -0,0 +1,26 @@ +[gd_resource type="Resource" script_class="GatherableData" load_steps=3 format=3] + +[ext_resource type="Script" path="res://gathering/gatherable_data.gd" id="1_data"] +[ext_resource type="Resource" path="res://fish/species/crab_brown/crab_brown.tres" id="2_catch"] + +[resource] +script = ExtResource("1_data") +type_id = &"crab_brown" +catch_data = ExtResource("2_catch") +required_tool_id = &"crab_net" +surface_materials = Array[StringName]([&"sand"]) +minimum_surface_y = 0.08 +population = 2 +movement_speed = 0.35 +roam_radius = 3.5 +scare_radius = 2.8 +capture_radius = 0.7 +interaction_range = 2.6 +charge_duration = 2.0 +sprite_pixel_size = 0.001 +sprite_tilt_degrees = -45.0 +capture_respawn_min_seconds = 480.0 +capture_respawn_max_seconds = 720.0 +scare_respawn_min_seconds = 45.0 +scare_respawn_max_seconds = 90.0 +minimum_respawn_spacing_seconds = 180.0 diff --git a/gathering/catalog/crab_dungeness.tres b/gathering/catalog/crab_dungeness.tres new file mode 100644 index 0000000..e4af78f --- /dev/null +++ b/gathering/catalog/crab_dungeness.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" script_class="GatherableData" load_steps=3 format=3] + +[ext_resource type="Script" path="res://gathering/gatherable_data.gd" id="1_data"] +[ext_resource type="Resource" path="res://fish/species/crab_dungeness/crab_dungeness.tres" id="2_catch"] + +[resource] +script = ExtResource("1_data") +type_id = &"crab_dungeness" +catch_data = ExtResource("2_catch") +required_tool_id = &"crab_net" +surface_materials = Array[StringName]([&"sand"]) +minimum_surface_y = -0.5 +population = 4 diff --git a/gathering/catalog/crab_ghost.tres b/gathering/catalog/crab_ghost.tres new file mode 100644 index 0000000..557419e --- /dev/null +++ b/gathering/catalog/crab_ghost.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" script_class="GatherableData" load_steps=3 format=3] + +[ext_resource type="Script" path="res://gathering/gatherable_data.gd" id="1_data"] +[ext_resource type="Resource" path="res://fish/species/crab_ghost/crab_ghost.tres" id="2_catch"] + +[resource] +script = ExtResource("1_data") +type_id = &"crab_ghost" +catch_data = ExtResource("2_catch") +required_tool_id = &"crab_net" +surface_materials = Array[StringName]([&"sand"]) +minimum_surface_y = 0.08 +population = 4 diff --git a/gathering/catalog/gatherable_catalog.tres b/gathering/catalog/gatherable_catalog.tres new file mode 100644 index 0000000..90131fc --- /dev/null +++ b/gathering/catalog/gatherable_catalog.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="GatherableCatalog" load_steps=6 format=3] + +[ext_resource type="Script" path="res://gathering/gatherable_catalog.gd" id="1_catalog"] +[ext_resource type="Resource" path="res://gathering/catalog/crab_brown.tres" id="2_brown"] +[ext_resource type="Resource" path="res://gathering/catalog/crab_ghost.tres" id="3_ghost"] +[ext_resource type="Resource" path="res://gathering/catalog/crab_blue.tres" id="4_blue"] +[ext_resource type="Resource" path="res://gathering/catalog/crab_dungeness.tres" id="5_dungeness"] + +[resource] +script = ExtResource("1_catalog") +entries = [ExtResource("2_brown"), ExtResource("3_ghost"), ExtResource("4_blue"), ExtResource("5_dungeness")] diff --git a/gathering/gatherable_catalog.gd b/gathering/gatherable_catalog.gd new file mode 100644 index 0000000..d71b65f --- /dev/null +++ b/gathering/gatherable_catalog.gd @@ -0,0 +1,23 @@ +class_name GatherableCatalog +extends Resource + +const GatherableDataType = preload("res://gathering/gatherable_data.gd") + +@export var entries: Array[GatherableDataType] = [] + + +func get_entry(type_id: StringName) -> GatherableDataType: + if type_id.is_empty(): + return null + for entry: GatherableDataType in entries: + if entry != null and entry.type_id == type_id: + return entry + return null + + +func get_available_entries() -> Array[GatherableDataType]: + var available: Array[GatherableDataType] = [] + for entry: GatherableDataType in entries: + if entry != null and entry.is_available(): + available.append(entry) + return available diff --git a/gathering/gatherable_catalog.gd.uid b/gathering/gatherable_catalog.gd.uid new file mode 100644 index 0000000..7f74b07 --- /dev/null +++ b/gathering/gatherable_catalog.gd.uid @@ -0,0 +1 @@ +uid://bpsncwh7qlfk1 diff --git a/gathering/gatherable_data.gd b/gathering/gatherable_data.gd new file mode 100644 index 0000000..f1be67c --- /dev/null +++ b/gathering/gatherable_data.gd @@ -0,0 +1,61 @@ +class_name GatherableData +extends Resource + +const FishDataType = preload("res://fish/fish_data.gd") + +@export var type_id: StringName +@export var catch_data: FishDataType +@export var required_tool_id: StringName +@export var surface_materials: Array[StringName] = [] +@export_range(-100.0, 100.0, 0.01) var minimum_surface_y: float = 0.08 +@export_range(0, 64, 1) var population: int = 0 +@export_range(0.0, 10.0, 0.05) var movement_speed: float = 0.35 +@export_range(0.1, 20.0, 0.1) var roam_radius: float = 3.5 +@export_range(0.1, 20.0, 0.1) var scare_radius: float = 2.8 +@export_range(0.1, 5.0, 0.05) var capture_radius: float = 0.7 +@export_range(0.1, 10.0, 0.05) var interaction_range: float = 2.6 +@export_range(0.1, 10.0, 0.05) var charge_duration: float = 2.0 +@export_range(0.1, 5.0, 0.01) var sprite_pixel_size: float = 0.001 +@export_range(-90.0, 90.0, 1.0) var sprite_tilt_degrees: float = -45.0 +@export_category("Respawn Budget") +@export_range(0.0, 3600.0, 1.0) var capture_respawn_min_seconds: float = 480.0 +@export_range(0.0, 3600.0, 1.0) var capture_respawn_max_seconds: float = 720.0 +@export_range(0.0, 3600.0, 1.0) var scare_respawn_min_seconds: float = 45.0 +@export_range(0.0, 3600.0, 1.0) var scare_respawn_max_seconds: float = 90.0 +@export_range(0.0, 3600.0, 1.0) var minimum_respawn_spacing_seconds: float = 180.0 + + +func is_valid() -> bool: + return ( + not type_id.is_empty() + and catch_data != null + and catch_data.is_valid_catalog_entry() + and catch_data.collection_method != FishDataType.CollectionMethod.FISHING + and not required_tool_id.is_empty() + and not surface_materials.is_empty() + and population > 0 + and movement_speed >= 0.0 + and roam_radius > 0.0 + and scare_radius > 0.0 + and capture_radius > 0.0 + and interaction_range > 0.0 + and charge_duration > 0.0 + and sprite_pixel_size > 0.0 + and capture_respawn_max_seconds >= capture_respawn_min_seconds + and scare_respawn_max_seconds >= scare_respawn_min_seconds + ) + + +func get_respawn_delay(reason: StringName, rng: RandomNumberGenerator) -> float: + var minimum_seconds: float = capture_respawn_min_seconds + var maximum_seconds: float = capture_respawn_max_seconds + if reason == &"scared": + minimum_seconds = scare_respawn_min_seconds + maximum_seconds = scare_respawn_max_seconds + if rng == null or maximum_seconds <= minimum_seconds: + return minimum_seconds + return rng.randf_range(minimum_seconds, maximum_seconds) + + +func is_available() -> bool: + return catch_data != null and catch_data.active and is_valid() diff --git a/gathering/gatherable_data.gd.uid b/gathering/gatherable_data.gd.uid new file mode 100644 index 0000000..5875fff --- /dev/null +++ b/gathering/gatherable_data.gd.uid @@ -0,0 +1 @@ +uid://doheo0308illx diff --git a/gathering/gathering_controller.gd b/gathering/gathering_controller.gd new file mode 100644 index 0000000..a0b6602 --- /dev/null +++ b/gathering/gathering_controller.gd @@ -0,0 +1,247 @@ +class_name GatheringController +extends Node3D + +const FishingShopStockType = preload("res://economy/fishing_shop_stock.gd") +const FishingSpotType = preload("res://fishing/fishing_spot.gd") +const NetworkWorldSpawnServiceType = preload( + "res://network/network_world_spawn_service.gd" +) + +signal status_changed(message: String) + +@export_range(0.5, 5.0, 0.05) var marker_distance: float = 1.7 +@export_range(0.1, 2.0, 0.05) var marker_radius: float = 0.7 +@export_flags_3d_physics var terrain_collision_mask: int = 1 +@export_range(0.5, 20.0, 0.5) var ray_height: float = 5.0 +@export_range(0.5, 20.0, 0.5) var ray_depth: float = 8.0 + +var _player: Player +var _bag: PlayerBag +var _hotbar: PlayerHotbar +var _fishing_spot: FishingSpotType +var _service: NetworkWorldSpawnServiceType +var _marker: MeshInstance3D +var _marker_invalid_material: StandardMaterial3D +var _marker_valid_material: StandardMaterial3D +var _marker_position: Vector3 +var _marker_has_surface: bool = false +var _target_entity_id: String = "" +var _charging: bool = false +var _charge_elapsed: float = 0.0 +var _charge_duration: float = 2.0 +var _request_id: String = "" +var _gameplay_input_enabled: bool = false + + +func _ready() -> void: + _build_marker() + set_process_unhandled_input(true) + + +func setup( + player: Player, + bag: PlayerBag, + hotbar: PlayerHotbar, + fishing_spot: FishingSpotType, + service: NetworkWorldSpawnServiceType, +) -> void: + _player = player + _bag = bag + _hotbar = hotbar + _fishing_spot = fishing_spot + _service = service + _service.local_interaction_finished.connect( + _on_interaction_finished + ) + + +func set_gameplay_input_enabled(enabled: bool) -> void: + _gameplay_input_enabled = enabled + if not enabled: + _cancel_charge() + + +func is_net_selected() -> bool: + return ( + _bag != null + and _hotbar != null + and _hotbar.get_selected_item_id() + == FishingShopStockType.CRAB_NET_ID + and _bag.owns_item(FishingShopStockType.CRAB_NET_ID) + ) + + +func _process(delta: float) -> void: + var input_available: bool = ( + _gameplay_input_enabled + and is_net_selected() + and _player != null + and _player.is_local_control_enabled() + and _fishing_spot != null + and _fishing_spot.can_change_hotbar_selection() + ) + if not input_available: + if _charging: + _cancel_charge() + _set_marker_visible(false) + return + if not _charging: + _set_marker_visible(false) + return + _charge_elapsed += delta + _update_marker_target() + _update_target_entity() + var target_entry: GatherableData = _service.get_entry_for_entity( + _target_entity_id + ) + if target_entry != null: + _charge_duration = target_entry.charge_duration + var fully_charged: bool = _charge_elapsed >= _charge_duration + var valid_target: bool = ( + fully_charged + and not _target_entity_id.is_empty() + and _player.is_sneaking() + ) + _marker.material_override = ( + _marker_valid_material if valid_target else _marker_invalid_material + ) + _set_marker_visible(_marker_has_surface) + + +func _unhandled_input(event: InputEvent) -> void: + if not event.is_action("fish_primary"): + return + if event.is_pressed(): + if ( + _charging + or not _gameplay_input_enabled + or not is_net_selected() + or _fishing_spot == null + or not _fishing_spot.can_change_hotbar_selection() + ): + return + _request_id = _service.begin_local_interaction() + if _request_id.is_empty(): + return + _charging = true + _charge_elapsed = 0.0 + _charge_duration = maxf( + _service.get_charge_duration_for_tool( + FishingShopStockType.CRAB_NET_ID + ), + 0.1, + ) + _update_marker_target() + _update_target_entity() + get_viewport().set_input_as_handled() + return + if not _charging: + return + var fully_charged: bool = _charge_elapsed >= _charge_duration + if ( + fully_charged + and _marker_has_surface + and not _target_entity_id.is_empty() + and _player.is_sneaking() + ): + _service.finish_local_interaction( + _request_id, + _target_entity_id, + _marker_position, + ) + else: + _service.cancel_local_interaction(_request_id) + status_changed.emit( + "Sneak close, pull the net all the way back, and line up the marker." + ) + _reset_charge_state() + get_viewport().set_input_as_handled() + + +func _update_marker_target() -> void: + _marker_has_surface = false + if _player == null or get_world_3d() == null: + return + var facing: Vector3 = _player.get_facing_direction() + facing.y = 0.0 + if facing.is_zero_approx(): + facing = -_player.global_basis.z + facing.y = 0.0 + if facing.is_zero_approx(): + return + facing = facing.normalized() + var query_center: Vector3 = _player.global_position + facing * marker_distance + var query := PhysicsRayQueryParameters3D.create( + query_center + Vector3.UP * ray_height, + query_center - Vector3.UP * ray_depth, + terrain_collision_mask, + ) + query.collide_with_areas = false + query.collide_with_bodies = true + var hit: Dictionary = get_world_3d().direct_space_state.intersect_ray(query) + if hit.is_empty(): + return + var position: Variant = hit.get("position") + if typeof(position) != TYPE_VECTOR3: + return + _marker_position = (position as Vector3) + Vector3.UP * 0.045 + _marker.global_position = _marker_position + _marker_has_surface = true + + +func _update_target_entity() -> void: + _target_entity_id = ( + _service.find_capture_target( + _marker_position, + FishingShopStockType.CRAB_NET_ID, + ) + if _marker_has_surface and _service != null + else "" + ) + + +func _cancel_charge() -> void: + if _charging and not _request_id.is_empty() and _service != null: + _service.cancel_local_interaction(_request_id) + _reset_charge_state() + + +func _reset_charge_state() -> void: + _charging = false + _charge_elapsed = 0.0 + _request_id = "" + _target_entity_id = "" + _marker_has_surface = false + _set_marker_visible(false) + + +func _on_interaction_finished(accepted: bool, message: String) -> void: + _reset_charge_state() + if not accepted or not message.is_empty(): + status_changed.emit(message) + + +func _build_marker() -> void: + _marker_invalid_material = StandardMaterial3D.new() + _marker_invalid_material.albedo_color = Color(0.86, 0.24, 0.19, 0.9) + _marker_invalid_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + _marker_valid_material = StandardMaterial3D.new() + _marker_valid_material.albedo_color = Color(0.25, 0.9, 0.36, 0.95) + _marker_valid_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + var ring := TorusMesh.new() + ring.inner_radius = marker_radius * 0.78 + ring.outer_radius = marker_radius + ring.rings = 16 + ring.ring_segments = 24 + _marker = MeshInstance3D.new() + _marker.name = "GatheringTargetMarker" + _marker.mesh = ring + _marker.material_override = _marker_invalid_material + _marker.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + _marker.visible = false + add_child(_marker) + + +func _set_marker_visible(value: bool) -> void: + if _marker != null: + _marker.visible = value diff --git a/gathering/gathering_controller.gd.uid b/gathering/gathering_controller.gd.uid new file mode 100644 index 0000000..30e9ead --- /dev/null +++ b/gathering/gathering_controller.gd.uid @@ -0,0 +1 @@ +uid://cec7nwk815d8a diff --git a/gathering/world_gatherable.gd b/gathering/world_gatherable.gd new file mode 100644 index 0000000..1dccc7a --- /dev/null +++ b/gathering/world_gatherable.gd @@ -0,0 +1,113 @@ +class_name WorldGatherable +extends Node3D + +const GatherableDataType = preload("res://gathering/gatherable_data.gd") + +var entity_id: String = "" +var type_id: StringName +var data: GatherableDataType +var _sprite: Sprite3D +var _target_position: Vector3 +var _target_yaw: float = 0.0 +var _has_state: bool = false +var _despawning: bool = false + + +func configure( + configured_entity_id: String, + configured_data: GatherableDataType, + position: Vector3, + yaw: float, +) -> void: + entity_id = configured_entity_id + data = configured_data + type_id = data.type_id if data != null else StringName() + _ensure_visual() + if data != null and data.catch_data != null: + _sprite.texture = data.catch_data.display_texture + _sprite.pixel_size = data.sprite_pixel_size + _sprite.rotation_degrees.x = data.sprite_tilt_degrees + apply_network_state(position, yaw, true) + + +func apply_network_state( + position: Vector3, + yaw: float, + immediate: bool = false, +) -> void: + if _despawning or not position.is_finite() or not is_finite(yaw): + return + _target_position = position + _target_yaw = yaw + if immediate or not _has_state: + global_position = position + rotation.y = yaw + _has_state = true + + +func play_despawn(with_dust: bool) -> void: + if _despawning: + return + _despawning = true + if with_dust: + _emit_dust() + var tween: Tween = create_tween() + tween.set_parallel(true) + tween.set_trans(Tween.TRANS_QUAD) + tween.set_ease(Tween.EASE_IN) + if _sprite != null: + tween.tween_property(_sprite, "position:y", -0.3, 0.3) + tween.tween_property(_sprite, "scale", Vector3(0.75, 0.75, 0.75), 0.3) + tween.chain().tween_callback(queue_free) + + +func _process(delta: float) -> void: + if not _has_state or _despawning: + return + global_position = global_position.lerp( + _target_position, + 1.0 - exp(-10.0 * delta), + ) + rotation.y = lerp_angle( + rotation.y, + _target_yaw, + 1.0 - exp(-8.0 * delta), + ) + + +func _ensure_visual() -> void: + if _sprite != null: + return + _sprite = Sprite3D.new() + _sprite.name = "GatherableSprite" + _sprite.position.y = 0.22 + _sprite.shaded = false + _sprite.double_sided = true + _sprite.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST + add_child(_sprite) + + +func _emit_dust() -> void: + var particles := CPUParticles3D.new() + particles.name = "DustPoof" + particles.amount = 9 + particles.lifetime = 0.55 + particles.one_shot = true + particles.explosiveness = 1.0 + particles.direction = Vector3.UP + particles.spread = 58.0 + particles.gravity = Vector3(0.0, -2.4, 0.0) + particles.initial_velocity_min = 0.65 + particles.initial_velocity_max = 1.25 + particles.scale_amount_min = 0.7 + particles.scale_amount_max = 1.35 + var dust_material := StandardMaterial3D.new() + dust_material.albedo_color = Color(0.58, 0.46, 0.31, 0.85) + dust_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + dust_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + var dust_mesh := BoxMesh.new() + dust_mesh.size = Vector3(0.1, 0.1, 0.1) + dust_mesh.material = dust_material + particles.mesh = dust_mesh + add_child(particles) + particles.emitting = true diff --git a/gathering/world_gatherable.gd.uid b/gathering/world_gatherable.gd.uid new file mode 100644 index 0000000..f0de7f7 --- /dev/null +++ b/gathering/world_gatherable.gd.uid @@ -0,0 +1 @@ +uid://3t8ygawpkgwj diff --git a/items/catalog/crab_net.tres b/items/catalog/crab_net.tres new file mode 100644 index 0000000..104c9ff --- /dev/null +++ b/items/catalog/crab_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 = &"crab_net" +display_name = "crab net" +description = "a hand net for catching crabs and other small shore animals." +active = true +category = 1 +stackable = false +max_stack = 1 +usable = false +equippable = true +hotbar_allowed = true diff --git a/items/catalog/item_catalog.tres b/items/catalog/item_catalog.tres index 5f2ac3f..1715678 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=38 format=3] +[gd_resource type="Resource" script_class="ItemCatalog" load_steps=39 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"] @@ -37,7 +37,8 @@ [ext_resource type="Resource" path="res://items/catalog/rods/oddity_string_rod.tres" id="36_oddity"] [ext_resource type="Resource" path="res://items/catalog/rods/aurora_rod.tres" id="37_aurora"] [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"] [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")] +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")] diff --git a/jobs/job_catalog.gd b/jobs/job_catalog.gd index 009a152..0b9c717 100644 --- a/jobs/job_catalog.gd +++ b/jobs/job_catalog.gd @@ -90,7 +90,7 @@ static func generate_daily_jobs( )) var species: Array[FishDataType] = [] for fish: FishDataType in candidates: - if fish != null and fish.is_selectable(): + if fish != null and fish.is_fishable(): species.append(fish) if not species.is_empty(): species.sort_custom( diff --git a/jobs/player_job_service.gd b/jobs/player_job_service.gd index 7f3be92..684c5ea 100644 --- a/jobs/player_job_service.gd +++ b/jobs/player_job_service.gd @@ -454,7 +454,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_selectable(): + if fish != null and fish.is_fishable(): candidates.append(fish) var schedule_anchor_index: int = _current_weather_segment() var allow_weather_jobs: bool = ( @@ -688,7 +688,7 @@ func _registered_species_count() -> int: var count: int = 0 if _catalog != null: for fish: FishDataType in _catalog.candidates: - if fish != null and fish.is_selectable(): + if fish != null and fish.is_fishable(): count += 1 return count @@ -727,7 +727,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_selectable(): + if fish != null and fish.is_fishable(): 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 4e1f28b..b211dfb 100644 --- a/main/main.gd +++ b/main/main.gd @@ -25,6 +25,7 @@ const ItemCatalogType = preload("res://items/item_catalog.gd") const ArtShopStockType = preload("res://economy/art_shop_stock.gd") const ItemDataType = preload("res://items/item_data.gd") const FishingRodDataType = preload("res://items/fishing_rod_data.gd") +const FishingShopStockType = preload("res://economy/fishing_shop_stock.gd") const FishingShopType = preload("res://ui/fishing_shop.gd") const FishingShopInteractionType = preload( "res://world/fishing_shop_interaction.gd" @@ -100,6 +101,15 @@ const NetworkWorldWeatherServiceType = preload( const RainAmbienceType = preload("res://world/rain_ambience.gd") const PlayerJobServiceType = preload("res://jobs/player_job_service.gd") const NetworkJobServiceType = preload("res://network/network_job_service.gd") +const GatherableCatalogType = preload( + "res://gathering/gatherable_catalog.gd" +) +const GatheringControllerType = preload( + "res://gathering/gathering_controller.gd" +) +const NetworkWorldSpawnServiceType = preload( + "res://network/network_world_spawn_service.gd" +) const TITLE_MUSIC_SILENCE_DB: float = -80.0 const TIME_CROSSING_EPSILON_HOURS: float = 0.000001 @@ -111,6 +121,7 @@ const SHOP_PATTERN_SCALE: float = 1.75 @export var pelican_buyer_profile: FishBuyerProfileType @export var main_shop_buyer_profile: FishBuyerProfileType @export var item_catalog: ItemCatalogType +@export var gatherable_catalog: GatherableCatalogType @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 @@ -150,6 +161,12 @@ const SHOP_PATTERN_SCALE: float = 1.75 ) @onready var _player_jobs: PlayerJobServiceType = %PlayerJobService @onready var _network_jobs: NetworkJobServiceType = %NetworkJobService +@onready var _network_world_spawns: NetworkWorldSpawnServiceType = ( + %NetworkWorldSpawnService +) +@onready var _gathering_controller: GatheringControllerType = ( + %GatheringController +) @onready var _data_root: PlayerDataRoot = %PlayerDataRoot @onready var _identity_backups: IdentityBackupService = %IdentityBackupService @onready var _network_profile: NetworkProfilePreferencesType = ( @@ -191,6 +208,7 @@ const SHOP_PATTERN_SCALE: float = 1.75 ) @onready var _players_root: Node3D = $Players @onready var _surface_drawings_root: Node3D = $SurfaceDrawings +@onready var _world_gatherables_root: Node3D = $WorldGatherables @onready var _title_background: ColorRect = %TitleBackground @onready var _player_menu_backdrop: ColorRect = %PlayerMenuBackdrop @onready var _shop_backdrop: ColorRect = %ShopBackdrop @@ -542,6 +560,33 @@ func _initialize_application(dedicated: bool) -> void: _save_manager, _asset_reservations ) + _network_world_spawns.setup( + _network_session, + _player_spawn_service, + _test_world, + _world_gatherables_root, + gatherable_catalog, + fish_catalog, + _player.inventory, + _player.collection_log, + _player.cooler_capacity, + _player.experience, + _save_manager, + _network_item_use, + ) + _gathering_controller.setup( + _player, + _player.bag, + _player.hotbar, + _fishing_spot, + _network_world_spawns, + ) + _network_world_spawns.local_capture_received.connect( + _fishing_spot.present_external_catch + ) + _gathering_controller.status_changed.connect( + _fishing_spot.report_external_status + ) _network_fish_showcase.setup( _network_session, _player_spawn_service, @@ -1326,6 +1371,7 @@ func _set_gameplay_active(active: bool) -> void: _player.set_camera_input_enabled(active) _player.set_camera_active(active) _fishing_spot.set_gameplay_input_enabled(active) + _gathering_controller.set_gameplay_input_enabled(active) _water_recovery.set_recovery_enabled(active) _game_ui.set_gameplay_ui_enabled(active) _save_manager.set_autosave_enabled(active) @@ -1775,6 +1821,12 @@ func _on_active_hotbar_item_changed( and item.is_available() and _player.bag.owns_item(item_id) ) + var active_is_catching_net: bool = ( + item_id == FishingShopStockType.CRAB_NET_ID + and item != null + and item.is_available() + and _player.bag.owns_item(item_id) + ) _player.set_active_fishing_rod( item as FishingRodDataType if active_is_rod else null, true, @@ -1783,6 +1835,7 @@ func _on_active_hotbar_item_changed( item.icon if item != null else null, active_is_art_kit, ) + _player.set_active_catching_net(active_is_catching_net) _game_ui.set_surface_drawing_hotbar_selected(active_is_art_kit) _network_item_use.submit_local_equipped(item_id, active_is_rod or ( item != null and _player.bag.owns_item(item_id) diff --git a/main/main.tscn b/main/main.tscn index 6b9f496..bdc7c78 100644 --- a/main/main.tscn +++ b/main/main.tscn @@ -57,6 +57,9 @@ [ext_resource type="Script" uid="uid://c80x8jkdnx0xy" path="res://settings/controller_mapping_manager.gd" id="56_controller_mapping"] [ext_resource type="Script" uid="uid://d0jv8n2nfqia0" path="res://network/discovery_client.gd" id="57_discovery"] [ext_resource type="AudioStream" uid="uid://c4y8puv0n6xk8" path="res://audio/music/world/craft.mp3" id="dusk_music"] +[ext_resource type="Resource" path="res://gathering/catalog/gatherable_catalog.tres" id="58_gatherable_catalog"] +[ext_resource type="Script" path="res://network/network_world_spawn_service.gd" id="59_world_spawns"] +[ext_resource type="Script" path="res://gathering/gathering_controller.gd" id="60_gathering_controller"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_title_water_native"] shader = ExtResource("40_title_water") @@ -142,6 +145,7 @@ fish_catalog = ExtResource("6_pool") pelican_buyer_profile = ExtResource("7_pelicans") main_shop_buyer_profile = ExtResource("12_main_shop") item_catalog = ExtResource("11_items") +gatherable_catalog = ExtResource("58_gatherable_catalog") [node name="TitleBackgroundLayer" type="CanvasLayer" parent="." unique_id=1690754702] unique_name_in_owner = true @@ -283,6 +287,10 @@ script = ExtResource("24_network_item") unique_name_in_owner = true script = ExtResource("45_fish_showcase") +[node name="NetworkWorldSpawnService" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("59_world_spawns") + [node name="NetworkSurfaceDrawingService" type="Node" parent="." unique_id=80122665] unique_name_in_owner = true script = ExtResource("46_surface_drawing") @@ -378,6 +386,12 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 3.3999481, 12.07081) [node name="SurfaceDrawings" type="Node3D" parent="." unique_id=234671307] +[node name="WorldGatherables" type="Node3D" parent="."] + +[node name="GatheringController" type="Node3D" parent="."] +unique_name_in_owner = true +script = ExtResource("60_gathering_controller") + [node name="FishingSpot" parent="." unique_id=1461328864 instance=ExtResource("4_spot")] unique_name_in_owner = true transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.02, -18) diff --git a/network/network_item_use_service.gd b/network/network_item_use_service.gd index 9b47b41..1777c51 100644 --- a/network/network_item_use_service.gd +++ b/network/network_item_use_service.gd @@ -4,6 +4,7 @@ extends Node const FishingRodDataType = preload("res://items/fishing_rod_data.gd") const ArtShopStockType = preload("res://economy/art_shop_stock.gd") +const FishingShopStockType = preload("res://economy/fishing_shop_stock.gd") const MAX_LEDGER_ENTRIES: int = 64 @@ -292,6 +293,13 @@ func get_effects_for_peer(peer_id: int) -> PlayerItemEffects: return avatar.item_effects if avatar != null else null +func get_equipped_item_id(peer_id: int) -> StringName: + var state: Dictionary = _equipped_states.get(peer_id, {}) + if state.is_empty() or not bool(state.get("owns_item", false)): + return StringName() + return StringName(str(state.get("item_id", ""))) + + func submit_local_equipped(item_id: StringName, owns_item: bool) -> void: if _session == null or not _session.is_gameplay_session_active(): return @@ -377,6 +385,10 @@ func _apply_equipped(data: Dictionary) -> void: item.icon if item != null else null, item_id == ArtShopStockType.ART_KIT_ITEM_ID and bool(data["owns_item"]), ) + avatar.set_active_catching_net( + item_id == FishingShopStockType.CRAB_NET_ID + and bool(data["owns_item"]), + ) equipped_state_changed.emit( peer_id, StringName(str(data["item_id"])), int(data["category"]) ) diff --git a/network/network_protocol.gd b/network/network_protocol.gd index 7250fba..d1f8d2c 100644 --- a/network/network_protocol.gd +++ b/network/network_protocol.gd @@ -1,7 +1,7 @@ class_name NetworkProtocol extends RefCounted -const PROTOCOL_VERSION: int = 4 +const PROTOCOL_VERSION: int = 5 const GAME_BUILD: String = "prealpha" const MAX_GAME_VERSION_LENGTH: int = 64 const MAX_DISPLAY_NAME_LENGTH: int = 24 @@ -25,6 +25,7 @@ const WORLD_TIME_CAPABILITY: String = "world_time_v1" const WORLD_WEATHER_CAPABILITY: String = "world_weather_v1" const FISH_QUALITY_CAPABILITY: String = "fish_quality_v1" const JOBS_CAPABILITY: String = "jobs_v1" +const WORLD_SPAWN_CAPABILITY: String = "world_spawn_envelope_v1" enum RejectionCode { NONE, @@ -172,6 +173,7 @@ static func make_client_hello( WORLD_TIME_CAPABILITY, WORLD_WEATHER_CAPABILITY, JOBS_CAPABILITY, + WORLD_SPAWN_CAPABILITY, ]), "cosmetic_snapshot": cosmetic_snapshot, "identity_fingerprint": identity_fingerprint, @@ -313,6 +315,7 @@ static func make_server_hello( WORLD_TIME_CAPABILITY, WORLD_WEATHER_CAPABILITY, JOBS_CAPABILITY, + WORLD_SPAWN_CAPABILITY, "chat_v1", "mail_v1", "profile_v1", diff --git a/network/network_session.gd b/network/network_session.gd index 076acc5..cd08600 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -267,6 +267,7 @@ func _register_player_host() -> void: NetworkProtocol.WORLD_TIME_CAPABILITY, NetworkProtocol.WORLD_WEATHER_CAPABILITY, NetworkProtocol.JOBS_CAPABILITY, + NetworkProtocol.WORLD_SPAWN_CAPABILITY, ]), ) _registry.update_appearance(1, _local_appearance_snapshot) @@ -555,6 +556,7 @@ func supports_server_capability(capability: StringName) -> bool: NetworkProtocol.SURFACE_DRAWING_CAPABILITY, NetworkProtocol.WORLD_TIME_CAPABILITY, NetworkProtocol.WORLD_WEATHER_CAPABILITY, + NetworkProtocol.WORLD_SPAWN_CAPABILITY, "chat_v1", "mail_v1", "profile_v1", diff --git a/network/network_world_spawn_protocol.gd b/network/network_world_spawn_protocol.gd new file mode 100644 index 0000000..4d0a55d --- /dev/null +++ b/network/network_world_spawn_protocol.gd @@ -0,0 +1,94 @@ +class_name NetworkWorldSpawnProtocol +extends RefCounted + +const CAPABILITY: StringName = &"world_spawn_envelope_v1" +const ENVELOPE_VERSION: int = 1 +const RELIABLE_CHANNEL: int = NetworkProtocol.ITEM_RELIABLE_CHANNEL +const SNAPSHOT_CHANNEL: int = 4 +const MAX_SESSION_ID_LENGTH: int = 96 +const MAX_EVENT_ID_LENGTH: int = 48 +const MAX_ENTITY_ID_LENGTH: int = 128 +const MAX_TYPE_ID_LENGTH: int = 96 +const MAX_ENTITIES_PER_SNAPSHOT: int = 128 +## Keeps unreliable movement envelopes below ENet's practical MTU while +## allowing reliable population envelopes to carry a larger initial state. +const SNAPSHOT_ENTITIES_PER_ENVELOPE: int = 4 + + +static func make_envelope( + session_id: String, + sequence: int, + event_id: StringName, + payload: Dictionary, +) -> Dictionary: + return { + "envelope_version": ENVELOPE_VERSION, + "session_id": session_id, + "sequence": sequence, + "event_id": str(event_id), + "payload": payload.duplicate(true), + } + + +static func validate_envelope(value: Variant) -> bool: + if typeof(value) != TYPE_DICTIONARY: + return false + var data: Dictionary = value + return ( + typeof(data.get("envelope_version")) == TYPE_INT + and int(data["envelope_version"]) == ENVELOPE_VERSION + and typeof(data.get("session_id")) == TYPE_STRING + and not str(data["session_id"]).is_empty() + and str(data["session_id"]).length() <= MAX_SESSION_ID_LENGTH + and typeof(data.get("sequence")) == TYPE_INT + and int(data["sequence"]) >= 0 + and typeof(data.get("event_id")) == TYPE_STRING + and not str(data["event_id"]).is_empty() + and str(data["event_id"]).length() <= MAX_EVENT_ID_LENGTH + and typeof(data.get("payload")) == TYPE_DICTIONARY + ) + + +static func validate_entity_state(value: Variant) -> bool: + if typeof(value) != TYPE_DICTIONARY: + return false + var data: Dictionary = value + var position: Variant = data.get("position") + if typeof(position) != TYPE_ARRAY or position.size() != 3: + return false + for component: Variant in position: + if typeof(component) not in [TYPE_FLOAT, TYPE_INT]: + return false + if not is_finite(float(component)): + return false + return ( + typeof(data.get("entity_id")) == TYPE_STRING + and not str(data["entity_id"]).is_empty() + and str(data["entity_id"]).length() <= MAX_ENTITY_ID_LENGTH + and typeof(data.get("type_id")) == TYPE_STRING + and not str(data["type_id"]).is_empty() + and str(data["type_id"]).length() <= MAX_TYPE_ID_LENGTH + and typeof(data.get("yaw")) in [TYPE_FLOAT, TYPE_INT] + and is_finite(float(data["yaw"])) + and typeof(data.get("revision")) == TYPE_INT + and int(data["revision"]) >= 0 + ) + + +static func vector3_to_array(value: Vector3) -> Array[float]: + return [value.x, value.y, value.z] + + +static func array_to_vector3(value: Variant) -> Vector3: + if typeof(value) != TYPE_ARRAY or value.size() != 3: + return Vector3(INF, INF, INF) + for component: Variant in value: + if typeof(component) not in [TYPE_FLOAT, TYPE_INT]: + return Vector3(INF, INF, INF) + if not is_finite(float(component)): + return Vector3(INF, INF, INF) + return _array_to_vector3(value) + + +static func _array_to_vector3(value: Array) -> Vector3: + return Vector3(float(value[0]), float(value[1]), float(value[2])) diff --git a/network/network_world_spawn_protocol.gd.uid b/network/network_world_spawn_protocol.gd.uid new file mode 100644 index 0000000..d245393 --- /dev/null +++ b/network/network_world_spawn_protocol.gd.uid @@ -0,0 +1 @@ +uid://wstcwpwk41mf diff --git a/network/network_world_spawn_service.gd b/network/network_world_spawn_service.gd new file mode 100644 index 0000000..7c50731 --- /dev/null +++ b/network/network_world_spawn_service.gd @@ -0,0 +1,1126 @@ +class_name NetworkWorldSpawnService +extends Node + +const FishCatchType = preload("res://fish/fish_catch.gd") +const FishDataType = preload("res://fish/fish_data.gd") +const FishExperienceType = preload("res://fish/fish_experience.gd") +const FishPoolType = preload("res://fish/fish_pool.gd") +const FishSelectorType = preload("res://fish/fish_selector.gd") +const GatherableCatalogType = preload("res://gathering/gatherable_catalog.gd") +const GatherableDataType = preload("res://gathering/gatherable_data.gd") +const WorldGatherableType = preload("res://gathering/world_gatherable.gd") +const TestWorldType = preload("res://world/test_world.gd") + +signal local_capture_received(fish_catch: FishCatch) +signal local_interaction_finished(accepted: bool, message: String) + +const SNAPSHOT_INTERVAL_SECONDS: float = 0.2 +const CAPACITY_RESPONSE_TIMEOUT_SECONDS: float = 3.0 +const MAX_REQUEST_ID_LENGTH: int = 128 +const MAX_LEDGER_ENTRIES: int = 96 + +var _session: NetworkSession +var _spawn_service: PlayerSpawnService +var _world: TestWorldType +var _world_root: Node3D +var _catalog: GatherableCatalogType +var _fish_catalog: FishPoolType +var _local_inventory: FishInventory +var _local_collection: CollectionLog +var _local_capacity: PlayerCoolerCapacity +var _local_experience: PlayerExperience +var _save_manager: PlayerSaveManager +var _item_use: NetworkItemUseService + +var _entities: Dictionary = {} +var _presentations: Dictionary = {} +var _entity_revisions: Dictionary = {} +var _surface_triangles: Dictionary = {} +var _surface_areas: Dictionary = {} +var _surface_total_areas: Dictionary = {} +var _respawns: Array[Dictionary] = [] +var _next_respawn_by_type: Dictionary[StringName, float] = {} +var _charge_requests: Dictionary = {} +var _pending_captures: Dictionary = {} +var _received_results: Dictionary = {} +var _showcase_deadlines: Dictionary = {} +var _envelope_sequence: int = 0 +var _snapshot_elapsed: float = 0.0 +var _population_session_id: String = "" +var _rng := RandomNumberGenerator.new() + + +func setup( + session: NetworkSession, + spawn_service: PlayerSpawnService, + world: TestWorldType, + world_root: Node3D, + catalog: GatherableCatalogType, + fish_catalog: FishPoolType, + local_inventory: FishInventory, + local_collection: CollectionLog, + local_capacity: PlayerCoolerCapacity, + local_experience: PlayerExperience, + save_manager: PlayerSaveManager, + item_use: NetworkItemUseService, +) -> void: + _session = session + _spawn_service = spawn_service + _world = world + _world_root = world_root + _catalog = catalog + _fish_catalog = fish_catalog + _local_inventory = local_inventory + _local_collection = local_collection + _local_capacity = local_capacity + _local_experience = local_experience + _save_manager = save_manager + _item_use = item_use + _rng.randomize() + _session.peer_authenticated.connect(_on_peer_authenticated) + _session.peer_removed.connect(_on_peer_removed) + _session.state_changed.connect(_on_session_state_changed) + set_physics_process(true) + _begin_population_if_ready.call_deferred() + + +func begin_local_interaction() -> String: + if ( + _session == null + or not _session.is_gameplay_session_active() + or ( + not _session.is_host() + and not _session.supports_server_capability( + NetworkWorldSpawnProtocol.CAPABILITY + ) + ) + ): + local_interaction_finished.emit(false, "Catching is unavailable.") + return "" + var request_id: String = _new_id("gather") + var data: Dictionary = { + "session_id": _session.get_session_id(), + "request_id": request_id, + } + if _session.is_host(): + _handle_interaction_begin(_session.get_local_peer_id(), data) + else: + submit_interaction_begin.rpc_id(1, data) + return request_id + + +func finish_local_interaction( + request_id: String, + entity_id: String, + target_position: Vector3, +) -> void: + if request_id.is_empty() or not target_position.is_finite(): + return + var data: Dictionary = { + "session_id": _session.get_session_id(), + "request_id": request_id, + "entity_id": entity_id, + "target_position": NetworkWorldSpawnProtocol.vector3_to_array( + target_position + ), + } + if _session.is_host(): + _handle_interaction_finish(_session.get_local_peer_id(), data) + else: + submit_interaction_finish.rpc_id(1, data) + + +func cancel_local_interaction(request_id: String) -> void: + if request_id.is_empty() or _session == null: + return + if _session.is_host(): + _handle_interaction_cancel(_session.get_local_peer_id(), request_id) + else: + submit_interaction_cancel.rpc_id(1, request_id) + + +func find_entity_near( + position: Vector3, + radius: float, +) -> String: + var best_id: String = "" + var best_distance_squared: float = radius * radius + for entity_id: String in _entities: + var state: Dictionary = _entities[entity_id] + if bool(state.get("locked", false)): + continue + var entity_position: Vector3 = state.get("position", Vector3.ZERO) + var distance_squared: float = Vector2( + entity_position.x - position.x, + entity_position.z - position.z, + ).length_squared() + if distance_squared <= best_distance_squared: + best_distance_squared = distance_squared + best_id = entity_id + return best_id + + +func find_capture_target( + position: Vector3, + tool_id: StringName, +) -> String: + if tool_id.is_empty(): + return "" + var best_id: String = "" + var best_distance_squared: float = INF + for entity_id: String in _entities: + var state: Dictionary = _entities[entity_id] + if bool(state.get("locked", false)): + continue + var entry := state.get("data") as GatherableDataType + if entry == null or entry.required_tool_id != tool_id: + continue + var entity_position: Vector3 = state.get("position", Vector3.ZERO) + var distance_squared: float = Vector2( + entity_position.x - position.x, + entity_position.z - position.z, + ).length_squared() + if ( + distance_squared <= entry.capture_radius * entry.capture_radius + and distance_squared <= best_distance_squared + ): + best_distance_squared = distance_squared + best_id = entity_id + return best_id + + +func get_charge_duration_for_tool(tool_id: StringName) -> float: + var duration: float = 0.0 + if _catalog == null or tool_id.is_empty(): + return duration + for entry: GatherableDataType in _catalog.get_available_entries(): + if entry.required_tool_id == tool_id: + duration = maxf(duration, entry.charge_duration) + return duration + + +func get_entry_for_entity(entity_id: String) -> GatherableDataType: + var state: Dictionary = _entities.get(entity_id, {}) + return state.get("data") as GatherableDataType + + +func _physics_process(delta: float) -> void: + if ( + _session == null + or not _session.is_host() + or not _session.is_gameplay_session_active() + ): + return + _begin_population_if_ready() + _update_pending_capture_timeouts() + _update_respawns() + _update_showcase_deadlines() + _update_host_entities(delta) + _snapshot_elapsed += delta + if _snapshot_elapsed >= SNAPSHOT_INTERVAL_SECONDS: + _snapshot_elapsed = fmod( + _snapshot_elapsed, + SNAPSHOT_INTERVAL_SECONDS, + ) + _broadcast_entity_snapshots() + + +func _begin_population_if_ready() -> void: + if ( + _session == null + or not _session.is_host() + or not _session.is_gameplay_session_active() + or _catalog == null + or _world == null + or _world_root == null + ): + return + var session_id: String = _session.get_session_id() + if session_id.is_empty() or session_id == _population_session_id: + return + _clear_world() + _population_session_id = session_id + for entry: GatherableDataType in _catalog.get_available_entries(): + _cache_spawn_surface(entry) + for _spawn_index: int in entry.population: + _spawn_entity(entry) + + +func _cache_spawn_surface(entry: GatherableDataType) -> void: + if entry == null or _surface_triangles.has(entry.type_id): + return + var triangles: Array[PackedVector3Array] = ( + _world.get_spawn_surface_triangles( + entry.surface_materials, + entry.minimum_surface_y, + ) + ) + var areas := PackedFloat32Array() + var total_area: float = 0.0 + for triangle: PackedVector3Array in triangles: + var area: float = ( + (triangle[1] - triangle[0]).cross( + triangle[2] - triangle[0] + ).length() * 0.5 + ) + total_area += area + areas.append(total_area) + _surface_triangles[entry.type_id] = triangles + _surface_areas[entry.type_id] = areas + _surface_total_areas[entry.type_id] = total_area + if triangles.is_empty(): + push_warning( + "No valid spawn surface was found for %s." % entry.type_id + ) + + +func _spawn_entity(entry: GatherableDataType) -> void: + var position: Vector3 = _sample_surface_position(entry) + if not position.is_finite(): + return + var entity_id: String = _new_id("world") + var state: Dictionary = { + "entity_id": entity_id, + "type_id": entry.type_id, + "data": entry, + "position": position, + "target": _sample_surface_position(entry, position, entry.roam_radius), + "yaw": _rng.randf_range(-PI, PI), + "revision": 1, + "locked": false, + } + if not (state["target"] as Vector3).is_finite(): + state["target"] = position + _entities[entity_id] = state + var envelope: Dictionary = _make_envelope( + &"spawn", + {"entity": _state_to_network(state)}, + ) + _apply_envelope(envelope) + receive_world_envelope.rpc(envelope) + + +func _update_host_entities(delta: float) -> void: + for entity_id: String in _entities.keys(): + var state: Dictionary = _entities.get(entity_id, {}) + if state.is_empty() or bool(state.get("locked", false)): + continue + var entry := state.get("data") as GatherableDataType + if entry == null: + continue + var position: Vector3 = state["position"] + var target: Vector3 = state["target"] + var horizontal_delta := Vector3( + target.x - position.x, + 0.0, + target.z - position.z, + ) + if horizontal_delta.length_squared() <= 0.04: + target = _sample_surface_position( + entry, + position, + entry.roam_radius, + ) + if not target.is_finite(): + target = position + state["target"] = target + horizontal_delta = Vector3( + target.x - position.x, + 0.0, + target.z - position.z, + ) + if not horizontal_delta.is_zero_approx(): + var step: float = minf( + entry.movement_speed * delta, + horizontal_delta.length(), + ) + var direction: Vector3 = horizontal_delta.normalized() + position.x += direction.x * step + position.z += direction.z * step + position.y = lerpf( + position.y, + target.y, + minf(step / maxf(horizontal_delta.length(), 0.001), 1.0), + ) + state["yaw"] = atan2(-direction.x, -direction.z) + state["position"] = position + _entities[entity_id] = state + if _should_scare(entry, position): + _despawn_entity(entity_id, &"scared", true, true) + + +func _should_scare(entry: GatherableDataType, position: Vector3) -> bool: + var radius_squared: float = entry.scare_radius * entry.scare_radius + for peer_id: int in _spawn_service.get_peer_ids(): + var avatar: Player = _spawn_service.get_avatar(peer_id) + if ( + avatar == null + or not avatar.is_moving_horizontally() + or avatar.is_sneaking() + ): + continue + var delta := Vector2( + avatar.global_position.x - position.x, + avatar.global_position.z - position.z, + ) + if delta.length_squared() <= radius_squared: + return true + return false + + +func _sample_surface_position( + entry: GatherableDataType, + origin: Vector3 = Vector3(INF, INF, INF), + maximum_distance: float = INF, +) -> Vector3: + _cache_spawn_surface(entry) + var triangles: Array = _surface_triangles.get(entry.type_id, []) + var cumulative_areas: PackedFloat32Array = _surface_areas.get( + entry.type_id, + PackedFloat32Array(), + ) + var total_area: float = float( + _surface_total_areas.get(entry.type_id, 0.0) + ) + if triangles.is_empty() or total_area <= 0.0: + return Vector3(INF, INF, INF) + var attempts: int = 48 if is_finite(maximum_distance) else 1 + var fallback: Vector3 = Vector3(INF, INF, INF) + for _attempt: int in attempts: + var roll: float = _rng.randf() * total_area + var triangle_index: int = cumulative_areas.bsearch(roll) + triangle_index = clampi(triangle_index, 0, triangles.size() - 1) + var triangle: PackedVector3Array = triangles[triangle_index] + var root: float = sqrt(_rng.randf()) + var barycentric_b: float = root * (1.0 - _rng.randf()) + var barycentric_c: float = root - barycentric_b + var point: Vector3 = ( + triangle[0] * (1.0 - root) + + triangle[1] * barycentric_b + + triangle[2] * barycentric_c + ) + fallback = point + if ( + not is_finite(maximum_distance) + or Vector2(point.x - origin.x, point.z - origin.z).length() + <= maximum_distance + ): + return point + if origin.is_finite() and is_finite(maximum_distance): + return origin + return fallback + + +func _broadcast_entity_snapshots() -> void: + if _entities.is_empty(): + return + var snapshots: Array[Dictionary] = [] + for entity_id: String in _entities: + var state: Dictionary = _entities[entity_id] + state["revision"] = int(state["revision"]) + 1 + _entities[entity_id] = state + snapshots.append(_state_to_network(state)) + while not snapshots.is_empty(): + var chunk: Array[Dictionary] = [] + var chunk_size: int = mini( + NetworkWorldSpawnProtocol.SNAPSHOT_ENTITIES_PER_ENVELOPE, + snapshots.size(), + ) + for _index: int in chunk_size: + chunk.append(snapshots.pop_front()) + var envelope: Dictionary = _make_envelope( + &"snapshot", + {"entities": chunk}, + ) + _apply_envelope(envelope) + receive_world_snapshot_envelope.rpc(envelope) + + +func _despawn_entity( + entity_id: String, + reason: StringName, + with_dust: bool, + schedule_respawn: bool, +) -> void: + var state: Dictionary = _entities.get(entity_id, {}) + if state.is_empty(): + return + var entry := state.get("data") as GatherableDataType + _entities.erase(entity_id) + var envelope: Dictionary = _make_envelope( + &"despawn", + { + "entity_id": entity_id, + "reason": str(reason), + "with_dust": with_dust, + }, + ) + _apply_envelope(envelope) + receive_world_envelope.rpc(envelope) + if schedule_respawn and entry != null: + _respawns.append({ + "type_id": entry.type_id, + "due": _now() + entry.get_respawn_delay(reason, _rng), + }) + + +func _update_respawns() -> void: + var now: float = _now() + var remaining: Array[Dictionary] = [] + for respawn: Dictionary in _respawns: + var type_id := StringName(str(respawn.get("type_id", ""))) + var next_allowed: float = float( + _next_respawn_by_type.get(type_id, 0.0) + ) + if now < maxf(float(respawn.get("due", INF)), next_allowed): + remaining.append(respawn) + continue + var entry: GatherableDataType = _catalog.get_entry( + type_id + ) + if entry != null and entry.is_available(): + _spawn_entity(entry) + _next_respawn_by_type[type_id] = ( + now + entry.minimum_respawn_spacing_seconds + ) + _respawns = remaining + + +@rpc( + "any_peer", + "call_remote", + "reliable", + NetworkWorldSpawnProtocol.RELIABLE_CHANNEL, +) +func submit_interaction_begin(data: Dictionary) -> void: + var peer_id: int = multiplayer.get_remote_sender_id() + if _session.is_host() and _session.is_authenticated_peer(peer_id): + _handle_interaction_begin(peer_id, data) + + +func _handle_interaction_begin(peer_id: int, data: Dictionary) -> void: + var request_id: String = str(data.get("request_id", "")) + if ( + str(data.get("session_id", "")) != _session.get_session_id() + or request_id.is_empty() + or request_id.length() > MAX_REQUEST_ID_LENGTH + or _pending_captures.has(peer_id) + ): + _send_interaction_result(peer_id, request_id, false, "Cannot use the net now.") + return + _charge_requests[peer_id] = { + "request_id": request_id, + "started": _now(), + } + + +@rpc( + "any_peer", + "call_remote", + "reliable", + NetworkWorldSpawnProtocol.RELIABLE_CHANNEL, +) +func submit_interaction_finish(data: Dictionary) -> void: + var peer_id: int = multiplayer.get_remote_sender_id() + if _session.is_host() and _session.is_authenticated_peer(peer_id): + _handle_interaction_finish(peer_id, data) + + +func _handle_interaction_finish(peer_id: int, data: Dictionary) -> void: + var request_id: String = str(data.get("request_id", "")) + var entity_id: String = str(data.get("entity_id", "")) + var charge: Dictionary = _charge_requests.get(peer_id, {}) + _charge_requests.erase(peer_id) + var target_position: Vector3 = NetworkWorldSpawnProtocol.array_to_vector3( + data.get("target_position", []) + ) + var state: Dictionary = _entities.get(entity_id, {}) + var entry := state.get("data") as GatherableDataType + var avatar: Player = _spawn_service.get_avatar(peer_id) + var error: String = "" + if ( + str(data.get("session_id", "")) != _session.get_session_id() + or request_id.is_empty() + or request_id.length() > MAX_REQUEST_ID_LENGTH + or str(charge.get("request_id", "")) != request_id + or not target_position.is_finite() + ): + error = "The catch attempt was invalid." + elif state.is_empty() or entry == null or bool(state.get("locked", false)): + error = "That animal is no longer there." + elif _now() - float(charge.get("started", _now())) + 0.05 < entry.charge_duration: + error = "Pull the net all the way back first." + elif _item_use.get_equipped_item_id(peer_id) != entry.required_tool_id: + error = "Equip the correct gathering tool." + elif avatar == null or not avatar.is_sneaking(): + error = "Sneak closer before swinging the net." + else: + var entity_position: Vector3 = state["position"] + var target_distance: float = Vector2( + entity_position.x - target_position.x, + entity_position.z - target_position.z, + ).length() + var player_distance: float = Vector2( + avatar.global_position.x - entity_position.x, + avatar.global_position.z - entity_position.z, + ).length() + if target_distance > entry.capture_radius: + error = "The net missed." + elif player_distance > entry.interaction_range: + error = "Move closer before swinging the net." + if not error.is_empty(): + _send_interaction_result(peer_id, request_id, false, error) + return + var selector := FishSelectorType.new() + selector.begin_roll() + var fish_catch: FishCatch = selector.create_catch(entry.catch_data) + if fish_catch == null or not fish_catch.is_valid(): + _send_interaction_result(peer_id, request_id, false, "The catch could not be recorded.") + return + state["locked"] = true + _entities[entity_id] = state + var capacity_nonce: String = _new_id("capacity") + _pending_captures[peer_id] = { + "request_id": request_id, + "entity_id": entity_id, + "catch": fish_catch.to_network_dict(), + "result_id": _new_id("gather_result"), + "capacity_nonce": capacity_nonce, + "deadline": _now() + CAPACITY_RESPONSE_TIMEOUT_SECONDS, + } + var probe: Dictionary = { + "session_id": _session.get_session_id(), + "request_id": request_id, + "capacity_nonce": capacity_nonce, + "catch_id": str(fish_catch.catch_id), + } + if peer_id == _session.get_local_peer_id(): + _handle_local_capacity_probe(probe) + else: + receive_capacity_probe.rpc_id(peer_id, probe) + + +@rpc( + "any_peer", + "call_remote", + "reliable", + NetworkWorldSpawnProtocol.RELIABLE_CHANNEL, +) +func submit_interaction_cancel(request_id: String) -> void: + var peer_id: int = multiplayer.get_remote_sender_id() + if _session.is_host() and _session.is_authenticated_peer(peer_id): + _handle_interaction_cancel(peer_id, request_id) + + +func _handle_interaction_cancel(peer_id: int, request_id: String) -> void: + var charge: Dictionary = _charge_requests.get(peer_id, {}) + if str(charge.get("request_id", "")) == request_id: + _charge_requests.erase(peer_id) + + +@rpc( + "authority", + "call_remote", + "reliable", + NetworkWorldSpawnProtocol.RELIABLE_CHANNEL, +) +func receive_capacity_probe(data: Dictionary) -> void: + _handle_local_capacity_probe(data) + + +func _handle_local_capacity_probe(data: Dictionary) -> void: + if str(data.get("session_id", "")) != _session.get_session_id(): + return + var catch_id := StringName(str(data.get("catch_id", ""))) + var can_accept: bool = ( + _local_inventory != null + and _local_capacity != null + and ( + _local_inventory.contains_catch_id(catch_id) + or _local_inventory.get_all_catches().size() + < _local_capacity.get_capacity() + ) + ) + if _session.is_host(): + _handle_capacity_response( + _session.get_local_peer_id(), + str(data.get("request_id", "")), + str(data.get("capacity_nonce", "")), + can_accept, + ) + else: + submit_capacity_response.rpc_id( + 1, + str(data.get("request_id", "")), + str(data.get("capacity_nonce", "")), + can_accept, + ) + + +@rpc( + "any_peer", + "call_remote", + "reliable", + NetworkWorldSpawnProtocol.RELIABLE_CHANNEL, +) +func submit_capacity_response( + request_id: String, + capacity_nonce: String, + can_accept: bool, +) -> void: + var peer_id: int = multiplayer.get_remote_sender_id() + if _session.is_host() and _session.is_authenticated_peer(peer_id): + _handle_capacity_response( + peer_id, + request_id, + capacity_nonce, + can_accept, + ) + + +func _handle_capacity_response( + peer_id: int, + request_id: String, + capacity_nonce: String, + can_accept: bool, +) -> void: + var pending: Dictionary = _pending_captures.get(peer_id, {}) + if ( + pending.is_empty() + or str(pending.get("request_id", "")) != request_id + or str(pending.get("capacity_nonce", "")) != capacity_nonce + ): + return + if not can_accept: + _reject_pending_capture(peer_id, "Cooler is full.") + return + _pending_captures.erase(peer_id) + var entity_id: String = str(pending["entity_id"]) + _despawn_entity(entity_id, &"captured", false, true) + var outcome: Dictionary = { + "session_id": _session.get_session_id(), + "result_id": str(pending["result_id"]), + "request_id": request_id, + "target_peer_id": peer_id, + "catch": (pending["catch"] as Dictionary).duplicate(true), + } + if peer_id == _session.get_local_peer_id(): + _apply_capture_result(outcome) + else: + receive_capture_result.rpc_id(peer_id, outcome) + _broadcast_showcase(peer_id, outcome["catch"], true) + _showcase_deadlines[peer_id] = _now() + 4.5 + + +func _reject_pending_capture(peer_id: int, message: String) -> void: + var pending: Dictionary = _pending_captures.get(peer_id, {}) + _pending_captures.erase(peer_id) + if pending.is_empty(): + return + var entity_id: String = str(pending.get("entity_id", "")) + var state: Dictionary = _entities.get(entity_id, {}) + if not state.is_empty(): + state["locked"] = false + _entities[entity_id] = state + _send_interaction_result( + peer_id, + str(pending.get("request_id", "")), + false, + message, + ) + + +func _update_pending_capture_timeouts() -> void: + var now: float = _now() + for peer_id: int in _pending_captures.keys(): + var pending: Dictionary = _pending_captures[peer_id] + if now >= float(pending.get("deadline", INF)): + _reject_pending_capture(peer_id, "The catch attempt timed out.") + + +@rpc( + "authority", + "call_remote", + "reliable", + NetworkWorldSpawnProtocol.RELIABLE_CHANNEL, +) +func receive_capture_result(data: Dictionary) -> void: + _apply_capture_result(data) + + +func _apply_capture_result(data: Dictionary) -> void: + if ( + str(data.get("session_id", "")) != _session.get_session_id() + or int(data.get("target_peer_id", 0)) != _session.get_local_peer_id() + or typeof(data.get("catch")) != TYPE_DICTIONARY + ): + return + var result_id: String = str(data.get("result_id", "")) + var request_id: String = str(data.get("request_id", "")) + if result_id.is_empty() or request_id.is_empty(): + return + if _received_results.has(result_id): + local_interaction_finished.emit(true, "Caught.") + return + var catch_data: Dictionary = data["catch"] + var fish: FishDataType = _fish_catalog.get_fish_by_id( + StringName(str(catch_data.get("fish_id", ""))) + ) + var fish_catch: FishCatch = FishCatchType.from_network_dict( + catch_data, + fish, + ) + if fish_catch == null or not fish_catch.is_valid(): + return + var already_owned: bool = _local_inventory.contains_catch_id( + fish_catch.catch_id + ) + if ( + not already_owned + and _local_inventory.get_all_catches().size() + >= _local_capacity.get_capacity() + ): + return + if not already_owned: + var experience: int = FishExperienceType.calculate_for_collection( + fish_catch, + _local_collection, + ) + _local_inventory.add_catch(fish_catch) + _local_collection.mark_quality_discovered( + fish_catch.fish_id, + fish_catch.quality, + ) + _local_experience.award_experience(experience) + if not _save_manager.save_if_dirty(): + return + _received_results[result_id] = true + _bound(_received_results) + local_interaction_finished.emit(true, "Caught %s." % fish.display_name) + local_capture_received.emit(fish_catch) + + +func _send_interaction_result( + peer_id: int, + request_id: String, + accepted: bool, + message: String, +) -> void: + var result: Dictionary = { + "session_id": _session.get_session_id(), + "request_id": request_id if not request_id.is_empty() else "invalid", + "target_peer_id": peer_id, + "accepted": accepted, + "message": message.left(160), + } + if peer_id == _session.get_local_peer_id(): + _apply_interaction_result(result) + else: + receive_interaction_result.rpc_id(peer_id, result) + + +@rpc( + "authority", + "call_remote", + "reliable", + NetworkWorldSpawnProtocol.RELIABLE_CHANNEL, +) +func receive_interaction_result(data: Dictionary) -> void: + _apply_interaction_result(data) + + +func _apply_interaction_result(data: Dictionary) -> void: + if ( + str(data.get("session_id", "")) != _session.get_session_id() + or int(data.get("target_peer_id", 0)) != _session.get_local_peer_id() + or typeof(data.get("accepted")) != TYPE_BOOL + ): + return + local_interaction_finished.emit( + bool(data["accepted"]), + str(data.get("message", "")), + ) + + +func _broadcast_showcase( + peer_id: int, + catch_data: Dictionary, + visible: bool, +) -> void: + var envelope: Dictionary = _make_envelope( + &"showcase", + { + "owner_peer_id": peer_id, + "visible": visible, + "catch": catch_data.duplicate(true) if visible else {}, + }, + ) + _apply_envelope(envelope) + receive_world_envelope.rpc(envelope) + + +func _update_showcase_deadlines() -> void: + var now: float = _now() + for peer_id: int in _showcase_deadlines.keys(): + if now < float(_showcase_deadlines[peer_id]): + continue + _showcase_deadlines.erase(peer_id) + _broadcast_showcase(peer_id, {}, false) + + +func _make_envelope( + event_id: StringName, + payload: Dictionary, +) -> Dictionary: + _envelope_sequence += 1 + return NetworkWorldSpawnProtocol.make_envelope( + _session.get_session_id(), + _envelope_sequence, + event_id, + payload, + ) + + +@rpc( + "authority", + "call_remote", + "reliable", + NetworkWorldSpawnProtocol.RELIABLE_CHANNEL, +) +func receive_world_envelope(envelope: Dictionary) -> void: + _apply_envelope(envelope) + + +@rpc( + "authority", + "call_remote", + "unreliable_ordered", + NetworkWorldSpawnProtocol.SNAPSHOT_CHANNEL, +) +func receive_world_snapshot_envelope(envelope: Dictionary) -> void: + _apply_envelope(envelope) + + +func _apply_envelope(envelope: Dictionary) -> void: + if ( + not NetworkWorldSpawnProtocol.validate_envelope(envelope) + or str(envelope["session_id"]) != _session.get_session_id() + ): + return + var event_id := StringName(str(envelope["event_id"])) + var payload: Dictionary = envelope["payload"] + match event_id: + &"spawn": + _apply_entity_state(payload.get("entity", {}), true) + &"snapshot": + _apply_entity_states(payload.get("entities", []), false, false) + &"population": + _apply_entity_states(payload.get("entities", []), true, true) + &"despawn": + _apply_despawn(payload) + &"showcase": + _apply_showcase(payload) + + +func _apply_entity_states( + values: Variant, + immediate: bool, + reconcile: bool, +) -> void: + if ( + typeof(values) != TYPE_ARRAY + or values.size() > NetworkWorldSpawnProtocol.MAX_ENTITIES_PER_SNAPSHOT + ): + return + var included: Dictionary = {} + for value: Variant in values: + if not NetworkWorldSpawnProtocol.validate_entity_state(value): + continue + var state: Dictionary = value + included[str(state["entity_id"])] = true + _apply_entity_state(state, immediate) + if not reconcile: + return + for entity_id: String in _presentations.keys(): + if not included.has(entity_id): + _remove_presentation(entity_id, false) + + +func _apply_entity_state(value: Variant, immediate: bool) -> void: + if not NetworkWorldSpawnProtocol.validate_entity_state(value): + return + var state: Dictionary = value + var entity_id: String = str(state["entity_id"]) + var revision: int = int(state["revision"]) + if revision < int(_entity_revisions.get(entity_id, -1)): + return + var entry: GatherableDataType = _catalog.get_entry( + StringName(str(state["type_id"])) + ) + if entry == null: + return + var position: Vector3 = NetworkWorldSpawnProtocol.array_to_vector3( + state["position"] + ) + var presentation := _presentations.get(entity_id) as WorldGatherableType + if presentation == null or not is_instance_valid(presentation): + presentation = WorldGatherableType.new() + presentation.name = "WorldGatherable_%s" % entity_id.right(12) + _world_root.add_child(presentation) + presentation.configure( + entity_id, + entry, + position, + float(state["yaw"]), + ) + _presentations[entity_id] = presentation + else: + presentation.apply_network_state( + position, + float(state["yaw"]), + immediate, + ) + _entity_revisions[entity_id] = revision + if not _session.is_host(): + _entities[entity_id] = { + "entity_id": entity_id, + "type_id": entry.type_id, + "data": entry, + "position": position, + "yaw": float(state["yaw"]), + "revision": revision, + "locked": false, + } + + +func _apply_despawn(payload: Dictionary) -> void: + var entity_id: String = str(payload.get("entity_id", "")) + if entity_id.is_empty(): + return + if not _session.is_host(): + _entities.erase(entity_id) + _remove_presentation(entity_id, bool(payload.get("with_dust", false))) + + +func _remove_presentation(entity_id: String, with_dust: bool) -> void: + var presentation := _presentations.get(entity_id) as WorldGatherableType + _presentations.erase(entity_id) + _entity_revisions.erase(entity_id) + if presentation != null and is_instance_valid(presentation): + presentation.play_despawn(with_dust) + + +func _apply_showcase(payload: Dictionary) -> void: + if ( + typeof(payload.get("owner_peer_id")) != TYPE_INT + or typeof(payload.get("visible")) != TYPE_BOOL + ): + return + var peer_id: int = int(payload["owner_peer_id"]) + if peer_id == _session.get_local_peer_id(): + return + var avatar: Player = _spawn_service.get_avatar(peer_id) + if avatar == null: + return + if not bool(payload["visible"]): + avatar.end_catch_showcase() + return + if typeof(payload.get("catch")) != TYPE_DICTIONARY: + return + var catch_data: Dictionary = payload["catch"] + var fish: FishDataType = _fish_catalog.get_fish_by_id( + StringName(str(catch_data.get("fish_id", ""))) + ) + var fish_catch: FishCatch = FishCatchType.from_network_dict( + catch_data, + fish, + ) + if fish_catch != null: + avatar.begin_remote_catch_showcase(fish_catch) + + +func _state_to_network(state: Dictionary) -> Dictionary: + return { + "entity_id": str(state["entity_id"]), + "type_id": str(state["type_id"]), + "position": NetworkWorldSpawnProtocol.vector3_to_array( + state["position"] + ), + "yaw": float(state["yaw"]), + "revision": int(state["revision"]), + } + + +func _on_peer_authenticated(peer_id: int, _display_name: String) -> void: + if not _session.is_host(): + return + var states: Array[Dictionary] = [] + for state: Dictionary in _entities.values(): + states.append(_state_to_network(state)) + var envelope: Dictionary = _make_envelope( + &"population", + {"entities": states}, + ) + receive_world_envelope.rpc_id(peer_id, envelope) + + +func _on_peer_removed(peer_id: int) -> void: + _charge_requests.erase(peer_id) + if _pending_captures.has(peer_id): + _unlock_pending_capture(peer_id) + _showcase_deadlines.erase(peer_id) + + +func _unlock_pending_capture(peer_id: int) -> void: + var pending: Dictionary = _pending_captures.get(peer_id, {}) + _pending_captures.erase(peer_id) + if pending.is_empty(): + return + var entity_id: String = str(pending.get("entity_id", "")) + var state: Dictionary = _entities.get(entity_id, {}) + if state.is_empty(): + return + state["locked"] = false + _entities[entity_id] = state + + +func _on_session_state_changed(_state: NetworkSession.State) -> void: + if _session.is_gameplay_session_active(): + _begin_population_if_ready.call_deferred() + return + _clear_world() + _population_session_id = "" + + +func _clear_world() -> void: + for presentation: WorldGatherableType in _presentations.values(): + if presentation != null and is_instance_valid(presentation): + presentation.queue_free() + _entities.clear() + _presentations.clear() + _entity_revisions.clear() + _surface_triangles.clear() + _surface_areas.clear() + _surface_total_areas.clear() + _respawns.clear() + _next_respawn_by_type.clear() + _charge_requests.clear() + _pending_captures.clear() + _showcase_deadlines.clear() + _snapshot_elapsed = 0.0 + + +func _bound(values: Dictionary) -> void: + while values.size() > MAX_LEDGER_ENTRIES: + values.erase(values.keys().front()) + + +func _new_id(prefix: String) -> String: + return "%s:%s" % [ + prefix, + Crypto.new().generate_random_bytes(16).hex_encode(), + ] + + +func _now() -> float: + return Time.get_ticks_msec() / 1000.0 diff --git a/network/network_world_spawn_service.gd.uid b/network/network_world_spawn_service.gd.uid new file mode 100644 index 0000000..e9badc5 --- /dev/null +++ b/network/network_world_spawn_service.gd.uid @@ -0,0 +1 @@ +uid://bmuhkkl2cxsck diff --git a/player/net_attachment.tscn b/player/net_attachment.tscn new file mode 100644 index 0000000..7c16fca --- /dev/null +++ b/player/net_attachment.tscn @@ -0,0 +1,55 @@ +[gd_scene load_steps=7 format=3] + +[sub_resource type="StandardMaterial3D" id="HandleMaterial"] +albedo_color = Color(0.25, 0.13, 0.055, 1) +roughness = 0.9 + +[sub_resource type="CylinderMesh" id="HandleMesh"] +material = SubResource("HandleMaterial") +top_radius = 0.025 +bottom_radius = 0.035 +height = 1.25 + +[sub_resource type="StandardMaterial3D" id="HoopMaterial"] +albedo_color = Color(0.34, 0.39, 0.4, 1) +metallic = 0.25 +roughness = 0.65 + +[sub_resource type="TorusMesh" id="HoopMesh"] +material = SubResource("HoopMaterial") +inner_radius = 0.24 +outer_radius = 0.29 +rings = 12 +ring_segments = 18 + +[sub_resource type="StandardMaterial3D" id="NetMaterial"] +transparency = 1 +albedo_color = Color(0.65, 0.78, 0.75, 0.42) +shading_mode = 0 +cull_mode = 2 + +[sub_resource type="CylinderMesh" id="NetMesh"] +material = SubResource("NetMaterial") +top_radius = 0.24 +bottom_radius = 0.08 +height = 0.34 +radial_segments = 12 +rings = 1 + +[node name="NetAttachment" type="BoneAttachment3D"] +bone_name = "rod_socket" + +[node name="CatchingNet" type="Node3D" parent="."] +visible = false + +[node name="Handle" type="MeshInstance3D" parent="CatchingNet"] +position = Vector3(0, 0.55, 0) +mesh = SubResource("HandleMesh") + +[node name="Hoop" type="MeshInstance3D" parent="CatchingNet"] +position = Vector3(0, 1.17, 0) +mesh = SubResource("HoopMesh") + +[node name="Bag" type="MeshInstance3D" parent="CatchingNet"] +position = Vector3(0, 1.0, 0) +mesh = SubResource("NetMesh") diff --git a/player/player.gd b/player/player.gd index 66dde01..dfead0e 100644 --- a/player/player.gd +++ b/player/player.gd @@ -31,6 +31,7 @@ const ControllerMappingManagerType = preload( const FishingRodAttachmentScene = preload( "res://player/fishing_rod_attachment.tscn" ) +const NetAttachmentScene = preload("res://player/net_attachment.tscn") const FishingRodDataType = preload("res://items/fishing_rod_data.gd") const HeldItemAttachmentScene = preload( "res://player/held_item_attachment.tscn" @@ -382,6 +383,7 @@ var _remote_recovery_visual_origin: Vector3 var _remote_recovery_elapsed: float = 0.0 var _target_zoom: float = 5.0 var _showcase_rod_visibility: bool = true +var _showcase_net_visibility: bool = false var _remote_presentation_visible := true var _showcase_rod_state_stored: bool = false var _showcase_visual_rotation: Vector3 @@ -445,12 +447,14 @@ var _fishing_visual_phase: FishingVisualPhase = FishingVisualPhase.NONE var _fishing_after_release_pending: bool = false var _retract_animation_completed: bool = true var _fishing_rod: Node3D +var _catching_net: Node3D var _fishing_rod_tip: Marker3D var _fishing_rod_model_mount: Node3D var _fishing_rod_fallback_visual: GeometryInstance3D var _custom_fishing_rod_visual: Node3D var _active_fishing_rod_id: StringName var _active_item_is_rod: bool = false +var _active_item_is_net: bool = false var _controller_mapping_manager: ControllerMappingManagerType var _sprint_dust_landing_ready: bool = false var _sprint_dust_airborne: bool = false @@ -475,6 +479,7 @@ func _ready() -> void: bag.contents_changed.connect(_on_bag_contents_changed) _apply_presented_appearance() _initialize_fishing_rod() + _initialize_catching_net() _target_zoom = clampf(_spring_arm.spring_length, minimum_zoom, maximum_zoom) _spring_arm.spring_length = _target_zoom _camera.current = local_control_enabled @@ -590,6 +595,23 @@ func _initialize_fishing_rod() -> void: ) as GeometryInstance3D +func _initialize_catching_net() -> void: + var skeleton := get_node_or_null( + "Visuals/CharacterRig/CharacterRig/Skeleton3D" + ) as Skeleton3D + if skeleton == null: + push_error("Player character skeleton is unavailable for the net.") + return + var attachment := NetAttachmentScene.instantiate() as BoneAttachment3D + if attachment == null: + push_error("Catching net attachment could not be instantiated.") + return + skeleton.add_child(attachment) + _catching_net = attachment.get_node("CatchingNet") as Node3D + if _catching_net != null: + _catching_net.visible = false + + func _initialize_held_item_attachment() -> void: var skeleton := get_node_or_null( "Visuals/CharacterRig/CharacterRig/Skeleton3D" @@ -1765,6 +1787,19 @@ func is_local_control_enabled() -> bool: return local_control_enabled +func is_sneaking() -> bool: + if local_control_enabled: + return ( + _is_movement_input_enabled() + and Input.is_action_pressed("sneak") + ) + return _network_sneak + + +func is_moving_horizontally() -> bool: + return Vector2(velocity.x, velocity.z).length_squared() > 0.01 + + func set_movement_enabled(enabled: bool) -> void: _movement_enabled = enabled if not enabled: @@ -1921,6 +1956,18 @@ func set_active_fishing_rod( set_active_item_is_rod(rod != null and rod.is_available(), animate_transition) +func set_active_catching_net(should_show: bool) -> void: + _active_item_is_net = should_show + if _catching_net == null: + return + if _showcase_rod_state_stored: + _showcase_net_visibility = should_show + return + _catching_net.visible = ( + should_show and not _has_held_show_item() and not _showcase_animation_active + ) + + func _apply_fishing_rod_model(rod: FishingRodDataType) -> void: var next_id: StringName = rod.item_id if rod != null else StringName() if next_id == _active_fishing_rod_id: @@ -1983,6 +2030,12 @@ func _apply_active_rod_visibility() -> void: _showcase_rod_visibility = _active_item_is_rod return _fishing_rod.visible = _active_item_is_rod + if _catching_net != null: + _catching_net.visible = ( + _active_item_is_net + and not _has_held_show_item() + and not _showcase_animation_active + ) func set_active_art_kit(icon: Texture2D, should_show: bool) -> void: @@ -2011,6 +2064,8 @@ func set_active_art_kit(icon: Texture2D, should_show: bool) -> void: if not previous_show_pose: _held_art_kit_display.visible = false _fishing_rod.visible = false + if _catching_net != null: + _catching_net.visible = false if item_changed: _begin_pocket_visual( PocketVisualTarget.ART_KIT, @@ -2059,6 +2114,8 @@ func set_held_fish( if not previous_show_pose: _held_fish_display.visible = false _fishing_rod.visible = false + if _catching_net != null: + _catching_net.visible = false if item_changed: _begin_pocket_visual( PocketVisualTarget.HELD_FISH, @@ -2283,8 +2340,13 @@ func begin_catch_showcase(fish_catch: FishCatchType) -> void: _turn_showcase_toward_position(showcase_camera_position) if not _showcase_rod_state_stored: _showcase_rod_visibility = _fishing_rod.visible + _showcase_net_visibility = ( + _catching_net.visible if _catching_net != null else false + ) _showcase_rod_state_stored = true _fishing_rod.visible = false + if _catching_net != null: + _catching_net.visible = false _catch_sprite.texture = fish_catch.fish.display_texture _catch_display.scale = ( Vector3.ONE @@ -2302,8 +2364,13 @@ func begin_remote_catch_showcase(fish_catch: FishCatchType) -> void: _showcase_animation_active = true set_fishing_visual(false) _showcase_rod_visibility = _fishing_rod.visible + _showcase_net_visibility = ( + _catching_net.visible if _catching_net != null else false + ) _showcase_rod_state_stored = true _fishing_rod.visible = false + if _catching_net != null: + _catching_net.visible = false _catch_sprite.texture = fish_catch.fish.display_texture _catch_display.scale = ( Vector3.ONE @@ -2343,7 +2410,10 @@ func end_catch_showcase( _catch_sprite.texture = null if _showcase_rod_state_stored: _fishing_rod.visible = _showcase_rod_visibility + if _catching_net != null: + _catching_net.visible = _showcase_net_visibility _showcase_rod_visibility = true + _showcase_net_visibility = false _showcase_rod_state_stored = false if ( not _showcase_visual_rotation_stored diff --git a/scripts/run_validations.sh b/scripts/run_validations.sh index 7ae8929..0c7fbae 100755 --- a/scripts/run_validations.sh +++ b/scripts/run_validations.sh @@ -24,6 +24,7 @@ readonly -a QUICK_TESTS=( "tests/terrain_blender_material_validation.gd" "tests/texture_sampling_validation.gd" "tests/world_time_validation.gd" + "tests/world_spawn_protocol_validation.gd" "tests/world_weather_validation.gd" ) @@ -41,6 +42,7 @@ readonly -a HOST_TESTS=( "tests/economy_regression_validation.gd" "tests/fish_hotbar_showcase_validation.gd" "tests/fishing_authority_validation.gd" + "tests/gathering_showcase_validation.gd" "tests/job_system_validation.gd" "tests/surface_drawing_runtime_validation.gd" ) @@ -54,6 +56,7 @@ readonly -a NETWORK_TESTS=( "tests/operator_multiplayer_validation.gd" "tests/surface_drawing_multiplayer_validation.gd" "tests/world_time_multiplayer_validation.gd" + "tests/world_spawn_multiplayer_validation.gd" ) cleanup() { diff --git a/tests/economy_regression_validation.gd b/tests/economy_regression_validation.gd index a0257d0..42c56e3 100644 --- a/tests/economy_regression_validation.gd +++ b/tests/economy_regression_validation.gd @@ -48,8 +48,8 @@ func _run() -> void: as PlayerAssetReservationService ) assert(player != null) - assert(catalog != null and catalog.candidates.size() == 313) - assert(LogbookCatalog.ordered_species(catalog.candidates).size() == 53) + assert(catalog != null and catalog.candidates.size() == 314) + assert(LogbookCatalog.ordered_species(catalog.candidates).size() == 54) assert(sale_service != null) assert(shop_service != null) assert(session != null and session.is_host()) diff --git a/tests/fish_catalog_content_validation.gd b/tests/fish_catalog_content_validation.gd index cc393f6..a2855e5 100644 --- a/tests/fish_catalog_content_validation.gd +++ b/tests/fish_catalog_content_validation.gd @@ -121,7 +121,7 @@ func _validate_weight_based_display_scale() -> void: func _validate_catalog_and_pools() -> void: - assert(Catalog.candidates.size() == 313) + assert(Catalog.candidates.size() == 314) assert(PondPool.candidates.size() == 19) assert(OceanPool.candidates.size() == 34) var active_count: int = 0 @@ -142,7 +142,7 @@ func _validate_catalog_and_pools() -> void: inactive_count += 1 assert(not fish.is_selectable()) assert(fish.display_texture == null) - assert(active_count == 53) + assert(active_count == 54) assert(inactive_count == 260) var inactive_fish: FishDataType = Catalog.get_fish_by_id(&"bowfin") assert(inactive_fish != null and not inactive_fish.active) @@ -173,14 +173,20 @@ func _validate_catalog_and_pools() -> void: assert(OceanPool.get_fish_by_id(fish_id) == null) assert(fresh_fish.is_allowed_in_water(WaterType.Type.FRESH_WATER)) assert(not fresh_fish.is_allowed_in_water(WaterType.Type.SALT_WATER)) - assert(LogbookCatalog.category_for(fresh_fish) == WaterType.Type.FRESH_WATER) + assert( + LogbookCatalog.category_for(fresh_fish) + == LogbookCatalog.Category.FRESH_WATER + ) for fish_id: StringName in SALT_WATER_IDS: var salt_fish: FishDataType = Catalog.get_fish_by_id(fish_id) assert(OceanPool.get_fish_by_id(fish_id) == salt_fish) assert(PondPool.get_fish_by_id(fish_id) == null) assert(salt_fish.is_allowed_in_water(WaterType.Type.SALT_WATER)) assert(not salt_fish.is_allowed_in_water(WaterType.Type.FRESH_WATER)) - assert(LogbookCatalog.category_for(salt_fish) == WaterType.Type.SALT_WATER) + assert( + LogbookCatalog.category_for(salt_fish) + == LogbookCatalog.Category.SALT_WATER + ) for fish_id: StringName in CATFISH_IDS: var fish: FishDataType = Catalog.get_fish_by_id(fish_id) assert(fish != null) @@ -188,13 +194,19 @@ func _validate_catalog_and_pools() -> void: assert(PondPool.get_fish_by_id(fish_id) == fish) assert(OceanPool.get_fish_by_id(fish_id) == null) assert(fish.availability.allowed_location_tags == [&"starter_pond"]) - assert(LogbookCatalog.category_for(fish) == WaterType.Type.FRESH_WATER) + assert( + LogbookCatalog.category_for(fish) + == LogbookCatalog.Category.FRESH_WATER + ) for fish_id: StringName in NEW_FRESH_WATER_IDS: var fish: FishDataType = Catalog.get_fish_by_id(fish_id) assert(fish != null and fish.is_selectable()) assert(PondPool.get_fish_by_id(fish_id) == fish) assert(OceanPool.get_fish_by_id(fish_id) == null) - assert(LogbookCatalog.category_for(fish) == WaterType.Type.FRESH_WATER) + assert( + LogbookCatalog.category_for(fish) + == LogbookCatalog.Category.FRESH_WATER + ) var expected_values: Dictionary[StringName, Array] = { &"catfish_blue": [1, 1.25, 3.0, 12.0, 6, 9], diff --git a/tests/fish_hotbar_showcase_validation.gd b/tests/fish_hotbar_showcase_validation.gd index 06b2f4d..35bb7c4 100644 --- a/tests/fish_hotbar_showcase_validation.gd +++ b/tests/fish_hotbar_showcase_validation.gd @@ -181,7 +181,7 @@ func _run() -> void: var invalid_state: Dictionary = valid_state.duplicate(true) invalid_state["display_scale"] = 1000.0 assert(not NetworkFishShowcaseProtocol.validate_state(invalid_state)) - assert(NetworkProtocol.PROTOCOL_VERSION == 4) + assert(NetworkProtocol.PROTOCOL_VERSION == 5) assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10) assert( NetworkProtocol.FISH_QUALITY_CAPABILITY diff --git a/tests/fish_quality_validation.gd b/tests/fish_quality_validation.gd index 555fb3c..2020575 100644 --- a/tests/fish_quality_validation.gd +++ b/tests/fish_quality_validation.gd @@ -25,7 +25,7 @@ func _run() -> void: _validate_mail_round_trip() _validate_collection_mastery() _validate_version_four_migration() - assert(NetworkProtocol.PROTOCOL_VERSION == 4) + assert(NetworkProtocol.PROTOCOL_VERSION == 5) assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10) assert(NetworkProtocol.FISH_QUALITY_CAPABILITY == "fish_quality_v1") print("Fish quality validation: PASS") diff --git a/tests/gathering_showcase_validation.gd b/tests/gathering_showcase_validation.gd new file mode 100644 index 0000000..0138bad --- /dev/null +++ b/tests/gathering_showcase_validation.gd @@ -0,0 +1,89 @@ +extends SceneTree + +const MainScene: PackedScene = preload("res://main/main.tscn") +const TEST_PORT: int = 18142 + + +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 session := main.get_node("%NetworkSession") as NetworkSession + assert(session.start_private_host(TEST_PORT)) + var save_manager := main.get("_save_manager") as PlayerSaveManager + assert(save_manager.initialize_new_game()) + main.call("_enter_gameplay") + for _frame: int in 8: + await process_frame + + var player := main.get("_player") as Player + 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) + assert(player.bag.add_item(&"crab_net")) + assert(player.hotbar.assign_item(0, &"crab_net")) + assert(player.hotbar.select_slot(0)) + await process_frame + assert(player.hotbar.get_selected_item_id() == &"crab_net") + assert(bool(player.get("_active_item_is_net"))) + + var crab: FishData = catalog.get_fish_by_id(&"crab_brown") + assert(crab != null and crab.is_selectable()) + var crab_catch := FishCatch.new() + crab_catch.fish = crab + crab_catch.fish_id = crab.id + crab_catch.weight_lb = crab.get_minimum_weight() + crab_catch.display_scale = crab.get_display_scale_for_weight( + crab_catch.weight_lb + ) + crab_catch.sale_value = crab.get_sale_value_for_weight( + crab_catch.weight_lb + ) + crab_catch.ensure_identity() + player.inventory.add_catch(crab_catch) + player.collection_log.mark_discovered(crab.id) + + assert(fishing_spot.present_external_catch(crab_catch)) + assert(fishing_spot.state == FishingSpot.FishingState.SHOWING_CATCH) + assert(not player.is_movement_enabled()) + Input.action_release("fish_primary") + await process_frame + assert(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") == null) + + var pocket_deadline: int = Time.get_ticks_msec() + 6000 + while ( + Time.get_ticks_msec() < pocket_deadline + and fishing_spot.state != FishingSpot.FishingState.READY + ): + await process_frame + assert(fishing_spot.state == FishingSpot.FishingState.READY) + assert(player.is_movement_enabled()) + assert(player.inventory.contains_catch_id(crab_catch.catch_id)) + assert(bool(player.get("_active_item_is_net"))) + print("Gathering showcase validation: PASS") + + 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/gathering_showcase_validation.gd.uid b/tests/gathering_showcase_validation.gd.uid new file mode 100644 index 0000000..a0266d4 --- /dev/null +++ b/tests/gathering_showcase_validation.gd.uid @@ -0,0 +1 @@ +uid://cedarlvsw4bpm diff --git a/tests/logbook_runtime_validation.gd b/tests/logbook_runtime_validation.gd index c7fdb26..d1367de 100644 --- a/tests/logbook_runtime_validation.gd +++ b/tests/logbook_runtime_validation.gd @@ -108,9 +108,9 @@ func _validate_save_round_trip( var player := main.get("_player") as Player var catalog := main.get("fish_catalog") as FishPool assert(catalog != null) - assert(catalog.candidates.size() == 313) + assert(catalog.candidates.size() == 314) var active_species := LogbookCatalog.ordered_species(catalog.candidates) - assert(active_species.size() == 53) + assert(active_species.size() == 54) for index: int in 4: _add_test_catch(player, active_species[index]) assert(save_manager.save_now()) @@ -131,7 +131,7 @@ func _validate_save_round_trip( assert(player.inventory.replace_all_catches(no_catches, 1)) assert(player.collection_log.replace_discovered_ids(no_discoveries)) assert(save_manager.load_player_data()) - assert(player.inventory.get_all_catches().size() == 53) + assert(player.inventory.get_all_catches().size() == 54) for fish: FishData in active_species: assert(player.inventory.get_count(fish.id) == 1) assert(player.collection_log.has_discovered(fish.id)) diff --git a/tests/logbook_validation.gd b/tests/logbook_validation.gd index bb43d3d..b8ed8be 100644 --- a/tests/logbook_validation.gd +++ b/tests/logbook_validation.gd @@ -40,9 +40,9 @@ func _run() -> void: func _validate_catalog() -> void: - assert(CatalogResource.candidates.size() == 313) + assert(CatalogResource.candidates.size() == 314) var ordered := LogbookCatalog.ordered_species(CatalogResource.candidates) - assert(ordered.size() == 53) + assert(ordered.size() == 54) var previous_number: int = 0 var catalog_numbers: Dictionary[int, bool] = {} for fish: FishDataType in CatalogResource.candidates: @@ -62,7 +62,7 @@ func _validate_catalog() -> void: assert(LogbookCatalog.catalog_number(fish) == fish.catalog_number) previous_number = fish.catalog_number assert( - LogbookCatalog.empty_state(WaterType.Type.SALT_WATER) + LogbookCatalog.empty_state(LogbookCatalog.Category.SALT_WATER) == "No saltwater catches cataloged yet." ) @@ -78,7 +78,9 @@ func _validate_page() -> void: page.setup(collection, inventory, CatalogResource) page.activate() await process_frame - assert(page.get("_category") == WaterType.Type.FRESH_WATER) + assert( + page.get("_category") == LogbookCatalog.Category.FRESH_WATER + ) assert((page.get("_catalog_grid") as GridContainer).columns == 4) var initial_detail_body := page.get("_detail_body") as VBoxContainer assert(not initial_detail_body.get_parent() is ScrollContainer) @@ -89,14 +91,17 @@ func _validate_page() -> void: var shared_material: Material var silhouette_count: int = 0 - for category: WaterType.Type in [ - WaterType.Type.FRESH_WATER, - WaterType.Type.SALT_WATER, + for category: LogbookCatalog.Category in [ + LogbookCatalog.Category.FRESH_WATER, + LogbookCatalog.Category.SALT_WATER, ]: page.call("_select_category", category) await create_timer(0.25).timeout var entries: Dictionary = page.get("_entry_buttons") - assert(entries.size() == (19 if category == WaterType.Type.FRESH_WATER else 34)) + assert( + entries.size() + == (19 if category == LogbookCatalog.Category.FRESH_WATER else 34) + ) for fish: FishDataType in LogbookCatalog.ordered_species( CatalogResource.candidates ): @@ -148,14 +153,20 @@ func _validate_page() -> void: ) assert(silhouette_count == 53) - page.call("_select_category", WaterType.Type.OTHER) + page.call("_select_category", LogbookCatalog.Category.SHELLFISH) + await create_timer(0.25).timeout + assert((page.get("_entry_buttons") as Dictionary).size() == 1) + assert( + (page.get("_entry_buttons") as Dictionary).has(&"unknown_3906") + ) + page.call("_select_category", LogbookCatalog.Category.OTHER) await create_timer(0.25).timeout assert((page.get("_entry_buttons") as Dictionary).is_empty()) assert( (page.get("_empty_state") as Label).text == "No entries available." ) - page.call("_select_category", WaterType.Type.FRESH_WATER) + page.call("_select_category", LogbookCatalog.Category.FRESH_WATER) await create_timer(0.25).timeout var bluegill = CatalogResource.get_fish_by_id(&"bluegill") diff --git a/tests/world_spawn_multiplayer_validation.gd b/tests/world_spawn_multiplayer_validation.gd new file mode 100644 index 0000000..c2907f0 --- /dev/null +++ b/tests/world_spawn_multiplayer_validation.gd @@ -0,0 +1,168 @@ +extends SceneTree + +const MainScene: PackedScene = preload("res://main/main.tscn") +const TEST_PORT: int = 18141 +const EXPECTED_POPULATION: int = 2 + + +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 spawn 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 service: Node = main.get_node("%NetworkWorldSpawnService") + assert(session.start_dedicated_host(TEST_PORT, 8, "127.0.0.1")) + assert(session.set_host_open(true)) + await _wait_for_population(service) + _validate_population(service) + + var remote_peer_id: int = 0 + var join_deadline: int = Time.get_ticks_msec() + 20000 + while Time.get_ticks_msec() < join_deadline and remote_peer_id == 0: + await process_frame + for peer_id: int in session.get_authenticated_peer_ids(): + if peer_id != session.get_local_peer_id(): + remote_peer_id = peer_id + break + assert(remote_peer_id > 1) + assert(session.peer_supports_capability( + remote_peer_id, + NetworkProtocol.WORLD_SPAWN_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)) + _validate_population(service) + _validate_respawn_budget(service) + print("World spawn multiplayer host validation: PASS") + session.disconnect_session("") + main.queue_free() + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout + quit() + + +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 service: Node = main.get_node("%NetworkWorldSpawnService") + 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()) + assert(session.supports_server_capability( + NetworkProtocol.WORLD_SPAWN_CAPABILITY + )) + await _wait_for_population(service) + _validate_population(service) + print("World spawn multiplayer client validation: PASS") + session.disconnect_session("") + main.queue_free() + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout + quit() + + +func _wait_for_population(service: Node) -> void: + var deadline: int = Time.get_ticks_msec() + 12000 + while Time.get_ticks_msec() < deadline: + await process_frame + if (service.get("_entities") as Dictionary).size() == EXPECTED_POPULATION: + return + assert(false, "Timed out waiting for the world spawn population.") + + +func _validate_population(service: Node) -> void: + var entities: Dictionary = service.get("_entities") + var presentations: Dictionary = service.get("_presentations") + assert(entities.size() == EXPECTED_POPULATION) + assert(presentations.size() == EXPECTED_POPULATION) + for state: Dictionary in entities.values(): + assert(state.get("type_id") == &"crab_brown") + var position: Variant = state.get("position") + assert(typeof(position) == TYPE_VECTOR3) + assert((position as Vector3).is_finite()) + assert((position as Vector3).y > 0.08) + + +func _validate_respawn_budget(service: Node) -> void: + var entities: Dictionary = service.get("_entities") + var entity_ids: Array = entities.keys() + assert(entity_ids.size() == EXPECTED_POPULATION) + for entity_id: Variant in entity_ids: + service.call( + "_despawn_entity", + str(entity_id), + &"captured", + false, + true, + ) + assert((service.get("_entities") as Dictionary).is_empty()) + + var now: float = Time.get_ticks_msec() / 1000.0 + var respawns: Array = service.get("_respawns") + assert(respawns.size() == EXPECTED_POPULATION) + for index: int in respawns.size(): + var respawn: Dictionary = respawns[index] + var delay: float = float(respawn.get("due", 0.0)) - now + assert(delay >= 479.0 and delay <= 721.0) + respawn["due"] = now - 1.0 + respawns[index] = respawn + + service.call("_update_respawns") + assert((service.get("_entities") as Dictionary).size() == 1) + assert((service.get("_respawns") as Array).size() == 1) + var next_by_type: Dictionary = service.get("_next_respawn_by_type") + var next_allowed: float = float(next_by_type.get(&"crab_brown", 0.0)) + assert(next_allowed - now >= 179.0) + + service.call("_update_respawns") + assert((service.get("_entities") as Dictionary).size() == 1) + assert((service.get("_respawns") as Array).size() == 1) + next_by_type[&"crab_brown"] = now - 1.0 + service.call("_update_respawns") + assert((service.get("_entities") as Dictionary).size() == 2) + assert((service.get("_respawns") as Array).is_empty()) + + +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 diff --git a/tests/world_spawn_multiplayer_validation.gd.uid b/tests/world_spawn_multiplayer_validation.gd.uid new file mode 100644 index 0000000..ea4f1de --- /dev/null +++ b/tests/world_spawn_multiplayer_validation.gd.uid @@ -0,0 +1 @@ +uid://wumg5ev7jr6e diff --git a/tests/world_spawn_protocol_validation.gd b/tests/world_spawn_protocol_validation.gd new file mode 100644 index 0000000..f7d611f --- /dev/null +++ b/tests/world_spawn_protocol_validation.gd @@ -0,0 +1,102 @@ +extends SceneTree + +const Gatherables: GatherableCatalog = preload( + "res://gathering/catalog/gatherable_catalog.tres" +) +const FishCatalog: FishPool = preload("res://fish/pools/fish_catalog.tres") + + +func _initialize() -> void: + assert(NetworkProtocol.PROTOCOL_VERSION == 5) + assert( + NetworkWorldSpawnProtocol.CAPABILITY + == NetworkProtocol.WORLD_SPAWN_CAPABILITY + ) + assert(NetworkWorldSpawnProtocol.SNAPSHOT_ENTITIES_PER_ENVELOPE <= 4) + _validate_catalog_statuses() + _validate_envelopes() + print("World spawn protocol validation: PASS") + quit() + + +func _validate_catalog_statuses() -> void: + var brown: GatherableData = Gatherables.get_entry(&"crab_brown") + assert(brown != null and brown.is_available()) + assert(brown.catch_data.active) + assert(brown.catch_data.collection_method == FishData.CollectionMethod.NET) + assert(brown.catch_data.logbook_section == FishData.LogbookSection.SHELLFISH) + assert(brown.population == 2) + assert(is_equal_approx(brown.charge_duration, 2.0)) + assert(is_equal_approx(brown.capture_respawn_min_seconds, 480.0)) + assert(is_equal_approx(brown.capture_respawn_max_seconds, 720.0)) + assert(is_equal_approx(brown.scare_respawn_min_seconds, 45.0)) + assert(is_equal_approx(brown.scare_respawn_max_seconds, 90.0)) + assert(is_equal_approx(brown.minimum_respawn_spacing_seconds, 180.0)) + assert(brown.required_tool_id == &"crab_net") + assert(FishCatalog.get_fish_by_id(&"crab_brown") == brown.catch_data) + assert(not brown.catch_data.is_fishable()) + + for type_id: StringName in [ + &"crab_ghost", + &"crab_blue", + &"crab_dungeness", + ]: + var entry: GatherableData = Gatherables.get_entry(type_id) + assert(entry != null) + assert(entry.is_valid()) + assert(not entry.catch_data.active) + assert(not entry.is_available()) + assert(entry.catch_data.collection_method == FishData.CollectionMethod.NET) + assert( + entry.catch_data.logbook_section + == FishData.LogbookSection.SHELLFISH + ) + + var available: Array[GatherableData] = Gatherables.get_available_entries() + assert(available.size() == 1) + assert(available.front() == brown) + var rng := RandomNumberGenerator.new() + rng.seed = 24680 + var captured_delay: float = brown.get_respawn_delay(&"captured", rng) + var scared_delay: float = brown.get_respawn_delay(&"scared", rng) + assert(captured_delay >= 480.0 and captured_delay <= 720.0) + assert(scared_delay >= 45.0 and scared_delay <= 90.0) + + +func _validate_envelopes() -> void: + var entity: Dictionary = { + "entity_id": "world:sample", + "type_id": "future_shellfish", + "position": [1.0, 2.0, 3.0], + "yaw": 0.5, + "revision": 7, + } + assert(NetworkWorldSpawnProtocol.validate_entity_state(entity)) + var future_entity: Dictionary = entity.duplicate(true) + future_entity["future_state"] = {"buried": false} + assert(NetworkWorldSpawnProtocol.validate_entity_state(future_entity)) + + var envelope: Dictionary = NetworkWorldSpawnProtocol.make_envelope( + "session-test", + 12, + &"future_spawn_type", + {"entity": entity, "future_payload": [1, 2, 3]}, + ) + assert(NetworkWorldSpawnProtocol.validate_envelope(envelope)) + + var malformed_position: Dictionary = entity.duplicate(true) + malformed_position["position"] = [1.0, "not-a-number", 3.0] + assert( + not NetworkWorldSpawnProtocol.validate_entity_state( + malformed_position + ) + ) + assert( + not NetworkWorldSpawnProtocol.array_to_vector3( + malformed_position["position"] + ).is_finite() + ) + + var invalid_envelope: Dictionary = envelope.duplicate(true) + invalid_envelope["envelope_version"] = 0 + assert(not NetworkWorldSpawnProtocol.validate_envelope(invalid_envelope)) diff --git a/tests/world_spawn_protocol_validation.gd.uid b/tests/world_spawn_protocol_validation.gd.uid new file mode 100644 index 0000000..e03c707 --- /dev/null +++ b/tests/world_spawn_protocol_validation.gd.uid @@ -0,0 +1 @@ +uid://clu8j64mh4x54 diff --git a/ui/logbook_catalog.gd b/ui/logbook_catalog.gd index 9bff3cc..cf0f49b 100644 --- a/ui/logbook_catalog.gd +++ b/ui/logbook_catalog.gd @@ -3,23 +3,48 @@ extends RefCounted const FishDataType = preload("res://fish/fish_data.gd") +enum Category { + FRESH_WATER, + SALT_WATER, + OTHER, + SHELLFISH, +} -static func category_for(fish: FishDataType) -> WaterType.Type: + +static func category_for(fish: FishDataType) -> Category: if fish == null: - return WaterType.Type.OTHER - return fish.get_primary_water_type() - - -static func category_label(category: WaterType.Type) -> String: - return WaterType.label(category) - - -static func empty_state(category: WaterType.Type) -> String: - match category: + return Category.OTHER + if fish.logbook_section == FishDataType.LogbookSection.SHELLFISH: + return Category.SHELLFISH + match fish.get_primary_water_type(): WaterType.Type.FRESH_WATER: - return "No freshwater catches cataloged yet." + return Category.FRESH_WATER WaterType.Type.SALT_WATER: + return Category.SALT_WATER + _: + return Category.OTHER + + +static func category_label(category: Category) -> String: + match category: + Category.FRESH_WATER: + return "Fresh Water" + Category.SALT_WATER: + return "Salt Water" + Category.SHELLFISH: + return "Shellfish" + _: + return "Misc" + + +static func empty_state(category: Category) -> String: + match category: + Category.FRESH_WATER: + return "No freshwater catches cataloged yet." + Category.SALT_WATER: return "No saltwater catches cataloged yet." + Category.SHELLFISH: + return "No shellfish cataloged yet." _: return "No entries available." diff --git a/ui/logbook_page.gd b/ui/logbook_page.gd index c579084..efa7ff4 100644 --- a/ui/logbook_page.gd +++ b/ui/logbook_page.gd @@ -50,7 +50,9 @@ const DETAIL_BOTTOM_INSET: float = 35.0 var _collection_log: CollectionLogType var _inventory: FishInventoryType var _catalog: FishPoolType -var _category: WaterType.Type = WaterType.Type.FRESH_WATER +var _category: LogbookCatalog.Category = ( + LogbookCatalog.Category.FRESH_WATER +) var _selected_id: StringName var _selected_entry_key: StringName var _active: bool = false @@ -186,13 +188,14 @@ func _build_interface() -> void: tabs.mouse_filter = Control.MOUSE_FILTER_PASS tabs.add_theme_constant_override("separation", 6) stack.add_child(tabs) - for category: WaterType.Type in [ - WaterType.Type.FRESH_WATER, - WaterType.Type.SALT_WATER, - WaterType.Type.OTHER, + for category: LogbookCatalog.Category in [ + LogbookCatalog.Category.FRESH_WATER, + LogbookCatalog.Category.SALT_WATER, + LogbookCatalog.Category.OTHER, + LogbookCatalog.Category.SHELLFISH, ]: var tab := OrganizerTabType.new() - tab.custom_minimum_size = Vector2(124, 38) + tab.custom_minimum_size = Vector2(90, 38) tab.toggle_mode = true tab.text = LogbookCatalog.category_label(category) tab.palette_index = int(category) @@ -518,7 +521,7 @@ func _add_entry_content( content.add_child(name_label) -func _select_category(category: WaterType.Type) -> void: +func _select_category(category: LogbookCatalog.Category) -> void: if category == _category: return _category = category @@ -717,7 +720,12 @@ func _build_known_details(fish: FishDataType) -> void: facts_column.size_flags_stretch_ratio = 1.0 facts_column.add_theme_constant_override("separation", 6) summary_columns.add_child(facts_column) - var facts_heading := _field_label("fish facts", 16) + var facts_heading := _field_label( + "shellfish facts" + if fish.logbook_section == FishDataType.LogbookSection.SHELLFISH + else "fish facts", + 16, + ) facts_column.add_child(facts_heading) var facts := _label(LogbookCatalog.facts_for(fish), 16) facts.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART @@ -745,8 +753,12 @@ func _build_known_details(fish: FishDataType) -> void: ) _add_detail_row( left_stats, - "body of water", - WaterType.label(fish.get_primary_water_type()).to_lower(), + ( + "habitat" + if fish.logbook_section == FishDataType.LogbookSection.SHELLFISH + else "body of water" + ), + fish.get_habitat_label(), ) _add_detail_row( left_stats, diff --git a/world/regions/starter_island_region.gd b/world/regions/starter_island_region.gd index 4c8880c..f3dd9e0 100644 --- a/world/regions/starter_island_region.gd +++ b/world/regions/starter_island_region.gd @@ -60,6 +60,89 @@ func get_saltwater_shoreline_mesh() -> MeshInstance3D: return get_node_or_null(^"ShorelineRibbons/Ocean") as MeshInstance3D +func get_spawn_surface_triangles( + material_names: Array[StringName], + minimum_global_y: float, + minimum_up_dot: float = 0.6, +) -> Array[PackedVector3Array]: + var triangles: Array[PackedVector3Array] = [] + if material_names.is_empty(): + return triangles + var terrain_root: Node = get_node_or_null(terrain_visual_root_path) + if terrain_root == null: + return triangles + for mesh_instance: MeshInstance3D in _collect_terrain_mesh_instances( + terrain_root + ): + var mesh: Mesh = mesh_instance.mesh + if mesh == null: + continue + for surface_index: int in mesh.get_surface_count(): + var material: Material = mesh_instance.get_active_material( + surface_index + ) + if material == null or not material_names.has( + StringName(material.resource_name) + ): + continue + var arrays: Array = mesh.surface_get_arrays(surface_index) + if arrays.size() <= Mesh.ARRAY_INDEX: + continue + var vertices := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array + var indices := arrays[Mesh.ARRAY_INDEX] as PackedInt32Array + if vertices.is_empty(): + continue + if indices.is_empty(): + for vertex_index: int in range(0, vertices.size() - 2, 3): + _append_spawn_triangle( + triangles, + mesh_instance, + vertices[vertex_index], + vertices[vertex_index + 1], + vertices[vertex_index + 2], + minimum_global_y, + minimum_up_dot, + ) + else: + for index_offset: int in range(0, indices.size() - 2, 3): + _append_spawn_triangle( + triangles, + mesh_instance, + vertices[indices[index_offset]], + vertices[indices[index_offset + 1]], + vertices[indices[index_offset + 2]], + minimum_global_y, + minimum_up_dot, + ) + return triangles + + +func _append_spawn_triangle( + result: Array[PackedVector3Array], + mesh_instance: MeshInstance3D, + local_a: Vector3, + local_b: Vector3, + local_c: Vector3, + minimum_global_y: float, + minimum_up_dot: float, +) -> void: + var a: Vector3 = mesh_instance.to_global(local_a) + var b: Vector3 = mesh_instance.to_global(local_b) + var c: Vector3 = mesh_instance.to_global(local_c) + if ( + a.y <= minimum_global_y + or b.y <= minimum_global_y + or c.y <= minimum_global_y + ): + return + var cross: Vector3 = (b - a).cross(c - a) + if cross.length_squared() <= 0.0000001: + return + if absf(cross.normalized().dot(Vector3.UP)) < minimum_up_dot: + return + result.append(PackedVector3Array([a, b, c])) + + func has_terrain_collision() -> bool: var collision_shape: CollisionShape3D = ( get_node_or_null(terrain_collision_shape_path) as CollisionShape3D diff --git a/world/test_world.gd b/world/test_world.gd index a3ea397..236d8bd 100644 --- a/world/test_world.gd +++ b/world/test_world.gd @@ -61,6 +61,16 @@ func get_saltwater_shoreline_mesh() -> MeshInstance3D: return _starter_island.get_saltwater_shoreline_mesh() +func get_spawn_surface_triangles( + material_names: Array[StringName], + minimum_global_y: float, +) -> Array[PackedVector3Array]: + return _starter_island.get_spawn_surface_triangles( + material_names, + minimum_global_y, + ) + + func _get_regions() -> Array[WorldRegion]: var regions: Array[WorldRegion] = [] for child: Node in _regions_root.get_children():