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 0000000..a06f039 Binary files /dev/null and b/fish/species/crab_brown/crab_brown.png differ diff --git a/fish/species/crab_brown/crab_brown.png.import b/fish/species/crab_brown/crab_brown.png.import new file mode 100644 index 0000000..9faf442 --- /dev/null +++ b/fish/species/crab_brown/crab_brown.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dmn3rafvxtnb1" +path="res://.godot/imported/crab_brown.png-4241f8408bf426d406e45f4cce16bbc8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://fish/species/crab_brown/crab_brown.png" +dest_files=["res://.godot/imported/crab_brown.png-4241f8408bf426d406e45f4cce16bbc8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/fish/species/crab_brown/crab_brown.tres b/fish/species/crab_brown/crab_brown.tres new file mode 100644 index 0000000..14e30c8 --- /dev/null +++ b/fish/species/crab_brown/crab_brown.tres @@ -0,0 +1,27 @@ +[gd_resource type="Resource" script_class="FishData" load_steps=4 format=3] + +[ext_resource type="Script" path="res://fish/fish_data.gd" id="1_fish_data"] +[ext_resource type="Resource" path="res://fishing/common_catch_profile.tres" id="2_profile"] +[ext_resource type="Texture2D" path="res://fish/species/crab_brown/crab_brown.png" id="3_texture"] + +[resource] +script = ExtResource("1_fish_data") +id = &"crab_brown" +display_name = "brown crab" +active = true +catalog_number = 3906 +collection_group = &"Marine crabs" +logbook_fact = "brown crabs hide among rocks and sand along sheltered coasts. broad claws help them feed on shellfish and other small shore animals." +collection_method = 1 +logbook_section = 1 +habitat_label = "dry beach sand" +allowed_water_types = 2 +rarity = 0 +base_catch_weight = 1.0 +catch_profile = ExtResource("2_profile") +weight_min_lb = 0.5 +weight_max_lb = 3.0 +sell_value_min = 5 +sell_value_max = 15 +sell_value_curve = 1.0 +display_texture = ExtResource("3_texture") diff --git a/fish/species/crab_dungeness/crab_dungeness.tres b/fish/species/crab_dungeness/crab_dungeness.tres index 6a75ee3..ada612a 100644 --- a/fish/species/crab_dungeness/crab_dungeness.tres +++ b/fish/species/crab_dungeness/crab_dungeness.tres @@ -17,6 +17,9 @@ active = false catalog_number = 3903 collection_group = &"Marine crabs" logbook_fact = "dungeness crabs bury beneath sand with only their eyes and antennae exposed. broad toothed shells protect them along cool pacific coasts." +collection_method = 1 +logbook_section = 1 +habitat_label = "shallow coastal sand" allowed_water_types = 2 rarity = 1 base_catch_weight = 0.65 diff --git a/fish/species/crab_ghost/crab_ghost.tres b/fish/species/crab_ghost/crab_ghost.tres index 5713645..dca2eff 100644 --- a/fish/species/crab_ghost/crab_ghost.tres +++ b/fish/species/crab_ghost/crab_ghost.tres @@ -17,6 +17,9 @@ active = false catalog_number = 3901 collection_group = &"Marine crabs" logbook_fact = "atlantic ghost crabs dash across beaches on long pale legs and retreat into deep burrows. raised eyes scan nearly every direction." +collection_method = 1 +logbook_section = 1 +habitat_label = "dry beach sand" allowed_water_types = 2 rarity = 2 base_catch_weight = 0.28 diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index 2c8f584..e42883b 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -42,6 +42,7 @@ const FishingSurfaceResolverType = preload( "res://fishing/fishing_surface_resolver.gd" ) const ArtShopStockType = preload("res://economy/art_shop_stock.gd") +const FishingShopStockType = preload("res://economy/fishing_shop_stock.gd") const WorldTimeServiceType = preload("res://world/world_time_service.gd") const WorldWeatherServiceType = preload( "res://world/world_weather_service.gd" @@ -504,6 +505,13 @@ func _unhandled_input(event: InputEvent) -> 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():