diff --git a/economy/fishing_shop_stock.gd b/economy/fishing_shop_stock.gd index 697a402..35f30ed 100644 --- a/economy/fishing_shop_stock.gd +++ b/economy/fishing_shop_stock.gd @@ -11,6 +11,7 @@ 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 STANDARD_SHOVEL_ID: StringName = &"standard_shovel" const ITEM_PRICES: Dictionary[StringName, int] = { &"worms": 1, @@ -28,6 +29,7 @@ const ITEM_PRICES: Dictionary[StringName, int] = { MAGNET_ID: 250, BATTERIES_ID: 15, CRAB_NET_ID: 50, + STANDARD_SHOVEL_ID: 75, } const BAIT_UNLOCK_PRICES: Dictionary[StringName, int] = { &"snails": 400, @@ -49,6 +51,7 @@ const ITEM_ORDER: Array[StringName] = [ FISH_FINDER_ID, MAGNET_ID, CRAB_NET_ID, + STANDARD_SHOVEL_ID, &"coffee", &"energy_drink", &"snack", @@ -116,6 +119,7 @@ static func is_permanent_unlock( or item_id == FISH_FINDER_ID or item_id == MAGNET_ID or item_id == CRAB_NET_ID + or item_id == STANDARD_SHOVEL_ID or item is FishingRodDataType ) ) diff --git a/economy/item_resale_policy.gd b/economy/item_resale_policy.gd new file mode 100644 index 0000000..299fc48 --- /dev/null +++ b/economy/item_resale_policy.gd @@ -0,0 +1,21 @@ +class_name ItemResalePolicy +extends RefCounted + +const FishingShopStockType = preload("res://economy/fishing_shop_stock.gd") +const ItemDataType = preload("res://items/item_data.gd") + + +static func is_sellable(item: ItemDataType) -> bool: + # Bait and lures live in their own tackle collection. Rods, tools, and + # utility gear are permanent equipment. The unified sell tray currently + # accepts ordinary consumable supplies only. + return item != null and item.is_available() and ( + item.category == ItemDataType.Category.CONSUMABLE + ) + + +static func get_unit_value(item: ItemDataType) -> int: + if not is_sellable(item): + return -1 + var purchase_price := FishingShopStockType.get_price(item.item_id) + return purchase_price / 2 if purchase_price >= 0 else -1 diff --git a/economy/item_resale_policy.gd.uid b/economy/item_resale_policy.gd.uid new file mode 100644 index 0000000..b8270b2 --- /dev/null +++ b/economy/item_resale_policy.gd.uid @@ -0,0 +1 @@ +uid://brsykj7darpyw diff --git a/fish/pools/fish_catalog.tres b/fish/pools/fish_catalog.tres index e2f7385..0f27f15 100644 --- a/fish/pools/fish_catalog.tres +++ b/fish/pools/fish_catalog.tres @@ -1,4 +1,4 @@ -[gd_resource type="Resource" load_steps=315 format=3] +[gd_resource type="Resource" load_steps=317 format=3] [ext_resource type="Script" path="res://fish/fish_pool.gd" id="1_pool"] [ext_resource type="Resource" path="res://fish/species/bluegill/bluegill.tres" id="2_bluegill"] @@ -315,7 +315,9 @@ [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"] +[ext_resource type="Resource" path="res://fish/species/clam_manila/clam_manila.tres" id="316_clam_manila"] +[ext_resource type="Resource" path="res://fish/species/beetle_stag_common/beetle_stag_common.tres" id="317_beetle_stag_common"] [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"), ExtResource("315_crab_brown")] +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"), ExtResource("316_clam_manila"), ExtResource("317_beetle_stag_common")] diff --git a/fish/species/beetle_stag_common/beetle_stag_common.png b/fish/species/beetle_stag_common/beetle_stag_common.png new file mode 100644 index 0000000..5ada780 Binary files /dev/null and b/fish/species/beetle_stag_common/beetle_stag_common.png differ diff --git a/fish/species/beetle_stag_common/beetle_stag_common.png.import b/fish/species/beetle_stag_common/beetle_stag_common.png.import new file mode 100644 index 0000000..4ad3482 --- /dev/null +++ b/fish/species/beetle_stag_common/beetle_stag_common.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://blw6lydoba8kd" +path="res://.godot/imported/beetle_stag_common.png-5eb7426d7dbda5083e39efc4cb980c8d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://fish/species/beetle_stag_common/beetle_stag_common.png" +dest_files=["res://.godot/imported/beetle_stag_common.png-5eb7426d7dbda5083e39efc4cb980c8d.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/beetle_stag_common/beetle_stag_common.tres b/fish/species/beetle_stag_common/beetle_stag_common.tres new file mode 100644 index 0000000..776bb22 --- /dev/null +++ b/fish/species/beetle_stag_common/beetle_stag_common.tres @@ -0,0 +1,26 @@ +[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/beetle_stag_common/beetle_stag_common.png" id="3_texture"] + +[resource] +script = ExtResource("1_fish_data") +id = &"beetle_stag_common" +display_name = "common stag beetle" +active = true +catalog_number = 8001 +collection_group = &"Beetles" +logbook_fact = "common stag beetles use broad jaws to wrestle rivals and cling to tree bark. adults feed on sap and other sweet plant juices." +collection_method = 1 +habitat_label = "tree trunks" +allowed_water_types = 0 +rarity = 0 +base_catch_weight = 1.0 +catch_profile = ExtResource("2_profile") +weight_min_lb = 0.02 +weight_max_lb = 0.12 +sell_value_min = 4 +sell_value_max = 12 +sell_value_curve = 1.0 +display_texture = ExtResource("3_texture") diff --git a/fish/species/clam_manila/clam_manila.png b/fish/species/clam_manila/clam_manila.png new file mode 100644 index 0000000..72f5bfb Binary files /dev/null and b/fish/species/clam_manila/clam_manila.png differ diff --git a/fish/species/clam_manila/clam_manila.png.import b/fish/species/clam_manila/clam_manila.png.import new file mode 100644 index 0000000..6e8d0bc --- /dev/null +++ b/fish/species/clam_manila/clam_manila.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://tmbkp1ohcavc" +path="res://.godot/imported/clam_manila.png-b04bc787291fc96749979a40380ba399.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://fish/species/clam_manila/clam_manila.png" +dest_files=["res://.godot/imported/clam_manila.png-b04bc787291fc96749979a40380ba399.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/clam_manila/clam_manila.tres b/fish/species/clam_manila/clam_manila.tres new file mode 100644 index 0000000..761c0d1 --- /dev/null +++ b/fish/species/clam_manila/clam_manila.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/clam_manila/clam_manila.png" id="3_texture"] + +[resource] +script = ExtResource("1_fish_data") +id = &"clam_manila" +display_name = "Manila clam" +active = true +catalog_number = 6406 +collection_group = &"Shellfish" +logbook_fact = "manila clams bury themselves in wet coastal sand and reveal their hiding places with small spurts of water." +collection_method = 2 +logbook_section = 1 +habitat_label = "wet beach sand" +allowed_water_types = 2 +rarity = 0 +base_catch_weight = 1.0 +catch_profile = ExtResource("2_profile") +weight_min_lb = 0.05 +weight_max_lb = 0.3 +sell_value_min = 3 +sell_value_max = 8 +sell_value_curve = 1.0 +display_texture = ExtResource("3_texture") diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index b8a0bdd..3267f88 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -563,10 +563,10 @@ func _unhandled_input(event: InputEvent) -> void: get_viewport().set_input_as_handled() return if ( - _is_cooler_full() + _is_inventory_full() ): status_changed.emit( - "cooler full. sell fish before casting again." + "inventory full. store or sell something before casting." ) get_viewport().set_input_as_handled() return @@ -710,17 +710,15 @@ func _get_active_rod() -> FishingRodDataType: return _get_active_item() as FishingRodDataType -func _is_cooler_full() -> bool: +func _is_inventory_full() -> bool: return ( - _cooler_capacity != null - and _local_inventory != null - and _local_inventory.get_all_catches().size() - >= _cooler_capacity.get_capacity() + _local_inventory != null + and not _local_inventory.can_accept_catch() ) func _on_cooler_availability_changed() -> void: - if state == FishingState.READY and not _is_cooler_full(): + if state == FishingState.READY and not _is_inventory_full(): refresh_active_item_status() @@ -1644,7 +1642,7 @@ func _build_network_evidence() -> Dictionary: "bite_multiplier": 1.0, "rarity_multipliers": rarity_multipliers, "discovered_fish_ids": discovered_ids, - "capacity_available": not _is_cooler_full(), + "capacity_available": not _is_inventory_full(), } diff --git a/gathering/catalog/beetle_stag_common.tres b/gathering/catalog/beetle_stag_common.tres new file mode 100644 index 0000000..118d733 --- /dev/null +++ b/gathering/catalog/beetle_stag_common.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/beetle_stag_common/beetle_stag_common.tres" id="2_catch"] + +[resource] +script = ExtResource("1_data") +type_id = &"beetle_stag_common" +catch_data = ExtResource("2_catch") +required_tool_id = &"crab_net" +spawn_anchor_set_id = &"starter_reachable_tree_trunks" +population = 3 +requires_sneaking = false +movement_speed = 0.0 +roam_radius = 0.1 +scare_radius = 0.1 +capture_radius = 0.34 +interaction_range = 2.8 +charge_duration = 1.0 +sprite_pixel_size = 0.005 +sprite_tilt_degrees = 0.0 +capture_respawn_min_seconds = 90.0 +capture_respawn_max_seconds = 150.0 +scare_respawn_min_seconds = 0.0 +scare_respawn_max_seconds = 0.0 +minimum_respawn_spacing_seconds = 30.0 diff --git a/gathering/catalog/clam_manila.tres b/gathering/catalog/clam_manila.tres new file mode 100644 index 0000000..69a9c89 --- /dev/null +++ b/gathering/catalog/clam_manila.tres @@ -0,0 +1,27 @@ +[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/clam_manila/clam_manila.tres" id="2_catch"] + +[resource] +script = ExtResource("1_data") +type_id = &"clam_manila" +catch_data = ExtResource("2_catch") +required_tool_id = &"standard_shovel" +diggable_area_id = &"starter_beach" +minimum_surface_y = -0.44 +population = 3 +presentation_mode = 1 +requires_sneaking = false +active_lifetime_seconds = 10.0 +movement_speed = 0.0 +roam_radius = 0.1 +scare_radius = 0.1 +capture_radius = 0.38 +interaction_range = 2.4 +charge_duration = 0.55 +capture_respawn_min_seconds = 8.0 +capture_respawn_max_seconds = 14.0 +scare_respawn_min_seconds = 0.0 +scare_respawn_max_seconds = 0.0 +minimum_respawn_spacing_seconds = 4.0 diff --git a/gathering/catalog/gatherable_catalog.tres b/gathering/catalog/gatherable_catalog.tres index 90131fc..4ea1ff4 100644 --- a/gathering/catalog/gatherable_catalog.tres +++ b/gathering/catalog/gatherable_catalog.tres @@ -1,11 +1,13 @@ -[gd_resource type="Resource" script_class="GatherableCatalog" load_steps=6 format=3] +[gd_resource type="Resource" script_class="GatherableCatalog" load_steps=8 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"] +[ext_resource type="Resource" path="res://gathering/catalog/clam_manila.tres" id="6_clam"] +[ext_resource type="Resource" path="res://gathering/catalog/beetle_stag_common.tres" id="7_beetle"] [resource] script = ExtResource("1_catalog") -entries = [ExtResource("2_brown"), ExtResource("3_ghost"), ExtResource("4_blue"), ExtResource("5_dungeness")] +entries = [ExtResource("2_brown"), ExtResource("3_ghost"), ExtResource("4_blue"), ExtResource("5_dungeness"), ExtResource("6_clam"), ExtResource("7_beetle")] diff --git a/gathering/gatherable_data.gd b/gathering/gatherable_data.gd index 31b48c4..8f8e54e 100644 --- a/gathering/gatherable_data.gd +++ b/gathering/gatherable_data.gd @@ -4,12 +4,22 @@ extends Resource const FishDataType = preload("res://fish/fish_data.gd") const FishQualityType = preload("res://fish/fish_quality.gd") +enum PresentationMode { + VISIBLE_CREATURE, + WATER_SPURT, +} + @export var type_id: StringName @export var catch_data: FishDataType @export var required_tool_id: StringName @export var surface_materials: Array[StringName] = [] +@export var diggable_area_id: StringName +@export var spawn_anchor_set_id: StringName @export_range(-100.0, 100.0, 0.01) var minimum_surface_y: float = 0.08 @export_range(0, 64, 1) var population: int = 0 +@export var presentation_mode: PresentationMode = PresentationMode.VISIBLE_CREATURE +@export var requires_sneaking: bool = true +@export_range(0.0, 120.0, 0.1) var active_lifetime_seconds: float = 0.0 @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 @@ -41,17 +51,33 @@ const FishQualityType = preload("res://fish/fish_quality.gd") func is_valid() -> bool: + var uses_diggable_area: bool = not diggable_area_id.is_empty() + var uses_spawn_anchors: bool = not spawn_anchor_set_id.is_empty() + var movement_parameters_valid: bool = ( + movement_speed >= 0.0 + and ( + presentation_mode == PresentationMode.WATER_SPURT + or (roam_radius > 0.0 and scare_radius > 0.0) + ) + ) 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 ( + uses_diggable_area + or uses_spawn_anchors + or not surface_materials.is_empty() + ) + and ( + not uses_diggable_area + or catch_data.collection_method + == FishDataType.CollectionMethod.DIGGING + ) and population > 0 - and movement_speed >= 0.0 - and roam_radius > 0.0 - and scare_radius > 0.0 + and movement_parameters_valid and _quality_multipliers_are_valid( quality_movement_speed_multipliers ) @@ -67,6 +93,18 @@ func is_valid() -> bool: ) +func is_stationary_hotspot() -> bool: + return presentation_mode == PresentationMode.WATER_SPURT + + +func is_stationary_spawn() -> bool: + return is_stationary_hotspot() or not spawn_anchor_set_id.is_empty() + + +func can_be_scared() -> bool: + return not is_stationary_spawn() and scare_radius > 0.0 + + func get_movement_speed_for_quality(quality: int) -> float: return movement_speed * _quality_multiplier( quality_movement_speed_multipliers, diff --git a/gathering/gathering_controller.gd b/gathering/gathering_controller.gd index 2260c68..2859145 100644 --- a/gathering/gathering_controller.gd +++ b/gathering/gathering_controller.gd @@ -35,6 +35,7 @@ var _charging: bool = false var _charge_elapsed: float = 0.0 var _charge_duration: float = 2.0 var _request_id: String = "" +var _active_tool_id: StringName var _gameplay_input_enabled: bool = false @@ -76,15 +77,40 @@ func is_net_selected() -> bool: ) +func is_shovel_selected() -> bool: + return _is_owned_tool_selected(FishingShopStockType.STANDARD_SHOVEL_ID) + + +func _is_owned_tool_selected(tool_id: StringName) -> bool: + return ( + _bag != null + and _hotbar != null + and _hotbar.get_selected_item_id() == tool_id + and _bag.owns_item(tool_id) + ) + + +func _get_selected_gathering_tool_id() -> StringName: + if is_net_selected(): + return FishingShopStockType.CRAB_NET_ID + if is_shovel_selected(): + return FishingShopStockType.STANDARD_SHOVEL_ID + return StringName() + + func _process(delta: float) -> void: + var selected_tool_id := _get_selected_gathering_tool_id() var input_available: bool = ( _gameplay_input_enabled - and is_net_selected() + and not selected_tool_id.is_empty() and _player != null and _player.is_local_control_enabled() and _fishing_spot != null and _fishing_spot.can_change_hotbar_selection() ) + if _charging and selected_tool_id != _active_tool_id: + _cancel_charge() + input_available = false if not input_available: if _charging: _cancel_charge() @@ -105,7 +131,11 @@ func _process(delta: float) -> void: var valid_target: bool = ( fully_charged and not _target_entity_id.is_empty() - and _player.is_sneaking() + and ( + target_entry == null + or not target_entry.requires_sneaking + or _player.is_sneaking() + ) ) _marker.material_override = ( _marker_valid_material if valid_target else _marker_invalid_material @@ -127,7 +157,7 @@ func _unhandled_input(event: InputEvent) -> void: if ( _charging or not _gameplay_input_enabled - or not is_net_selected() + or _get_selected_gathering_tool_id().is_empty() or _fishing_spot == null or not _fishing_spot.can_change_hotbar_selection() ): @@ -136,10 +166,11 @@ func _unhandled_input(event: InputEvent) -> void: if _request_id.is_empty(): return _charging = true + _active_tool_id = _get_selected_gathering_tool_id() _charge_elapsed = 0.0 _charge_duration = maxf( _service.get_charge_duration_for_tool( - FishingShopStockType.CRAB_NET_ID + _active_tool_id ), 0.1, ) @@ -151,11 +182,18 @@ func _unhandled_input(event: InputEvent) -> void: if not _charging: return var fully_charged: bool = _charge_elapsed >= _charge_duration + var target_entry: GatherableData = _service.get_entry_for_entity( + _target_entity_id + ) var valid_capture: bool = ( fully_charged and _marker_has_surface and not _target_entity_id.is_empty() - and _player.is_sneaking() + and ( + target_entry == null + or not target_entry.requires_sneaking + or _player.is_sneaking() + ) ) _player.play_net_strike_visual() if valid_capture: @@ -265,7 +303,7 @@ func _update_target_entity() -> void: _target_entity_id = ( _service.find_capture_target( _marker_position, - FishingShopStockType.CRAB_NET_ID, + _active_tool_id, ) if _marker_has_surface and _service != null else "" @@ -284,6 +322,7 @@ func _reset_charge_state() -> void: _charging = false _charge_elapsed = 0.0 _request_id = "" + _active_tool_id = StringName() _target_entity_id = "" _marker_has_surface = false _set_marker_visible(false) diff --git a/gathering/world_gatherable.gd b/gathering/world_gatherable.gd index e059df1..8a07ba0 100644 --- a/gathering/world_gatherable.gd +++ b/gathering/world_gatherable.gd @@ -4,11 +4,16 @@ extends Node3D const GatherableDataType = preload("res://gathering/gatherable_data.gd") const REFERENCE_SPRITE_PIXEL_SIZE: float = 0.001 const REFERENCE_SPRITE_HEIGHT: float = 0.22 +const WATER_SPURT_INTERVAL_SECONDS: float = 1.15 +const WATER_SPURT_COLOR := Color(0.48, 0.82, 0.84, 1.0) +const WATER_SPURT_HOLE_COLOR := Color(0.23, 0.27, 0.24, 1.0) var entity_id: String = "" var type_id: StringName var data: GatherableDataType var _sprite: Sprite3D +var _water_spurt_root: Node3D +var _water_spurt_elapsed: float = 0.0 var _target_position: Vector3 var _target_yaw: float = 0.0 var _has_state: bool = false @@ -24,14 +29,25 @@ func configure( 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: + if data != null and data.is_stationary_hotspot(): + _ensure_water_spurt_visual() + _water_spurt_elapsed = ( + float(abs(hash(entity_id)) % 1000) / 1000.0 + * WATER_SPURT_INTERVAL_SECONDS + ) + else: + _ensure_visual() + if data != null and data.catch_data != null and _sprite != null: _sprite.texture = data.catch_data.display_texture _sprite.pixel_size = data.sprite_pixel_size _sprite.position.y = ( - REFERENCE_SPRITE_HEIGHT - * data.sprite_pixel_size - / REFERENCE_SPRITE_PIXEL_SIZE + 0.0 + if not data.spawn_anchor_set_id.is_empty() + else ( + REFERENCE_SPRITE_HEIGHT + * data.sprite_pixel_size + / REFERENCE_SPRITE_PIXEL_SIZE + ) ) _sprite.rotation_degrees.x = data.sprite_tilt_degrees apply_network_state(position, yaw, true) @@ -58,6 +74,10 @@ func play_despawn(with_dust: bool) -> void: _despawning = true if with_dust: _emit_dust() + if _water_spurt_root != null: + _water_spurt_root.visible = false + queue_free() + return var tween: Tween = create_tween() tween.set_parallel(true) tween.set_trans(Tween.TRANS_QUAD) @@ -80,6 +100,14 @@ func _process(delta: float) -> void: _target_yaw, 1.0 - exp(-8.0 * delta), ) + if _water_spurt_root != null: + _water_spurt_elapsed += delta + if _water_spurt_elapsed >= WATER_SPURT_INTERVAL_SECONDS: + _water_spurt_elapsed = fmod( + _water_spurt_elapsed, + WATER_SPURT_INTERVAL_SECONDS, + ) + _emit_water_spurt() func _ensure_visual() -> void: @@ -95,6 +123,66 @@ func _ensure_visual() -> void: add_child(_sprite) +func _ensure_water_spurt_visual() -> void: + if _water_spurt_root != null: + return + _water_spurt_root = Node3D.new() + _water_spurt_root.name = "WaterSpurt" + add_child(_water_spurt_root) + var hole_material := StandardMaterial3D.new() + hole_material.albedo_color = WATER_SPURT_HOLE_COLOR + hole_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + hole_material.roughness = 1.0 + hole_material.metallic = 0.0 + var hole_mesh := CylinderMesh.new() + hole_mesh.top_radius = 0.035 + hole_mesh.bottom_radius = 0.035 + hole_mesh.height = 0.004 + hole_mesh.radial_segments = 12 + hole_mesh.rings = 1 + hole_mesh.material = hole_material + var hole := MeshInstance3D.new() + hole.name = "BurrowMark" + hole.position.y = 0.008 + hole.mesh = hole_mesh + hole.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + _water_spurt_root.add_child(hole) + _emit_water_spurt() + + +func _emit_water_spurt() -> void: + if _water_spurt_root == null or not _water_spurt_root.visible: + return + var particles := CPUParticles3D.new() + particles.name = "WaterDroplets" + particles.position.y = 0.035 + particles.amount = 6 + particles.lifetime = 0.42 + particles.one_shot = true + particles.explosiveness = 1.0 + particles.direction = Vector3.UP + particles.spread = 32.0 + particles.gravity = Vector3(0.0, -3.4, 0.0) + particles.initial_velocity_min = 0.8 + particles.initial_velocity_max = 1.2 + particles.scale_amount_min = 0.75 + particles.scale_amount_max = 1.0 + var water_material := StandardMaterial3D.new() + water_material.albedo_color = WATER_SPURT_COLOR + water_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + water_material.roughness = 1.0 + water_material.metallic = 0.0 + var droplet_mesh := BoxMesh.new() + droplet_mesh.size = Vector3(0.022, 0.035, 0.022) + droplet_mesh.material = water_material + particles.mesh = droplet_mesh + _water_spurt_root.add_child(particles) + particles.emitting = true + var cleanup := create_tween() + cleanup.tween_interval(0.55) + cleanup.tween_callback(particles.queue_free) + + func _emit_dust() -> void: var particles := CPUParticles3D.new() particles.name = "DustPoof" diff --git a/inventory/fish_inventory.gd b/inventory/fish_inventory.gd index a4052f5..9318457 100644 --- a/inventory/fish_inventory.gd +++ b/inventory/fish_inventory.gd @@ -9,6 +9,7 @@ signal catches_changed var _catches: Array[FishCatchType] = [] var _next_catch_sequence: int = 1 var _reservation_service: PlayerAssetReservationService +var _inventory_layout: PlayerInventoryLayout func set_reservation_service( @@ -17,11 +18,24 @@ func set_reservation_service( _reservation_service = reservation_service -func add_catch(fish_catch: FishCatchType) -> void: +func set_inventory_layout(layout: PlayerInventoryLayout) -> void: + _inventory_layout = layout + + +func can_accept_catch(catch_id: StringName = StringName()) -> bool: + return ( + _inventory_layout == null + or _inventory_layout.can_accept_catch(catch_id) + ) + + +func add_catch(fish_catch: FishCatchType) -> bool: if fish_catch == null or not fish_catch.is_valid(): - return + return false if get_catch_by_id(fish_catch.catch_id) != null: - return + return false + if not can_accept_catch(fish_catch.catch_id): + return false if fish_catch.catch_sequence <= 0: fish_catch.catch_sequence = _next_catch_sequence _next_catch_sequence = maxi( @@ -34,6 +48,7 @@ func add_catch(fish_catch: FishCatchType) -> void: get_count(fish_catch.fish_id) ) catches_changed.emit() + return true func get_count(fish_id: StringName) -> int: diff --git a/inventory/player_bag.gd b/inventory/player_bag.gd index 3f947ed..e33f1e4 100644 --- a/inventory/player_bag.gd +++ b/inventory/player_bag.gd @@ -13,11 +13,14 @@ enum StorageGroup { NONE, EQUIPMENT, ITEMS, + BAIT, + LURES, } signal contents_changed var _catalog: ItemCatalogType +var _inventory_layout: PlayerInventoryLayout var _items: Array[OwnedItemType] = [] var _unlocked_bait_ids: Array[StringName] = ( DEFAULT_UNLOCKED_BAIT_IDS.duplicate() @@ -28,11 +31,21 @@ func setup(catalog: ItemCatalogType) -> void: _catalog = catalog +func set_inventory_layout(layout: PlayerInventoryLayout) -> void: + _inventory_layout = layout + + func add_item(item_id: StringName, quantity: int = 1) -> bool: if not can_add_item(item_id, quantity): return false var item: ItemDataType = _resolve_valid_item(item_id) var existing: OwnedItemType = get_owned_item(item_id) + if ( + existing == null + and _inventory_layout != null + and not _inventory_layout.can_accept_item(item_id) + ): + return false if existing != null: existing.quantity += quantity else: @@ -57,6 +70,11 @@ func can_add_item(item_id: StringName, quantity: int = 1) -> bool: item.stackable and existing.quantity + quantity <= item.max_stack ) + if ( + _inventory_layout != null + and not _inventory_layout.can_accept_item(item_id) + ): + return false return quantity <= (item.max_stack if item.stackable else 1) @@ -252,8 +270,12 @@ func _first_available_storage_slot( func _storage_group_for_item(item: ItemDataType) -> StorageGroup: - if item == null or item.is_bait() or item.is_lure(): + if item == null: return StorageGroup.NONE + if item.is_bait(): + return StorageGroup.BAIT + if item.is_lure(): + return StorageGroup.LURES return ( StorageGroup.ITEMS if item.category == ItemDataType.Category.CONSUMABLE diff --git a/inventory/player_hotbar.gd b/inventory/player_hotbar.gd index a71ab68..b7d5197 100644 --- a/inventory/player_hotbar.gd +++ b/inventory/player_hotbar.gd @@ -24,6 +24,7 @@ signal selected_assignment_changed( var _bag: PlayerBagType var _catalog: ItemCatalogType var _fish_inventory: FishInventoryType +var _inventory_layout: PlayerInventoryLayout var _slots: Array[StringName] = [] var _fish_slots: Array[StringName] = [] var _selected_slot: int = 0 @@ -40,10 +41,12 @@ func setup( bag: PlayerBagType, catalog: ItemCatalogType, fish_inventory: FishInventoryType = null, + inventory_layout: PlayerInventoryLayout = null, ) -> void: _bag = bag _catalog = catalog _fish_inventory = fish_inventory + _inventory_layout = inventory_layout if _bag != null and not _bag.contents_changed.is_connected( _on_bag_contents_changed ): @@ -55,6 +58,11 @@ func setup( ) ): _fish_inventory.catches_changed.connect(_on_fish_inventory_changed) + if ( + _inventory_layout != null + and not _inventory_layout.layout_changed.is_connected(_validate_assignments) + ): + _inventory_layout.layout_changed.connect(_validate_assignments) _validate_assignments() @@ -63,13 +71,26 @@ func assign_item(slot_index: int, item_id: StringName) -> bool: return false if _slots[slot_index] == item_id: return true + var source_index: int = _slots.find(item_id) + var displaced_item: StringName = _slots[slot_index] + var displaced_fish: StringName = _fish_slots[slot_index] + if ( + _inventory_layout != null + and not _inventory_layout.move_entry( + PlayerInventoryLayout.EntryKind.ITEM, + item_id, + PlayerInventoryLayout.InventoryContainer.HOTBAR, + slot_index, + ) + ): + return false var selected_slot_was_affected: bool = slot_index == _selected_slot - for index: int in range(SLOT_COUNT): - if index != slot_index and _slots[index] == item_id: - _slots[index] = StringName() - selected_slot_was_affected = ( - selected_slot_was_affected or index == _selected_slot - ) + if source_index >= 0 and source_index != slot_index: + _slots[source_index] = displaced_item + _fish_slots[source_index] = displaced_fish + selected_slot_was_affected = ( + selected_slot_was_affected or source_index == _selected_slot + ) _slots[slot_index] = item_id _fish_slots[slot_index] = StringName() slots_changed.emit() @@ -83,13 +104,26 @@ func assign_fish(slot_index: int, catch_id: StringName) -> bool: return false if _fish_slots[slot_index] == catch_id: return true + var source_index: int = _fish_slots.find(catch_id) + var displaced_item: StringName = _slots[slot_index] + var displaced_fish: StringName = _fish_slots[slot_index] + if ( + _inventory_layout != null + and not _inventory_layout.move_entry( + PlayerInventoryLayout.EntryKind.CATCH, + catch_id, + PlayerInventoryLayout.InventoryContainer.HOTBAR, + slot_index, + ) + ): + return false var selected_slot_was_affected: bool = slot_index == _selected_slot - for index: int in range(SLOT_COUNT): - if index != slot_index and _fish_slots[index] == catch_id: - _fish_slots[index] = StringName() - selected_slot_was_affected = ( - selected_slot_was_affected or index == _selected_slot - ) + if source_index >= 0 and source_index != slot_index: + _slots[source_index] = displaced_item + _fish_slots[source_index] = displaced_fish + selected_slot_was_affected = ( + selected_slot_was_affected or source_index == _selected_slot + ) _slots[slot_index] = StringName() _fish_slots[slot_index] = catch_id slots_changed.emit() @@ -107,6 +141,11 @@ func clear_slot(slot_index: int) -> bool: ) ): return false + if ( + _inventory_layout != null + and not _inventory_layout.return_hotbar_entry_to_inventory(slot_index) + ): + return false _slots[slot_index] = StringName() _fish_slots[slot_index] = StringName() slots_changed.emit() @@ -122,6 +161,39 @@ func swap_slots(from_index: int, to_index: int) -> bool: or from_index == to_index ): return false + if _inventory_layout != null: + var kind: PlayerInventoryLayout.EntryKind + var identity: StringName + if not _fish_slots[from_index].is_empty(): + kind = PlayerInventoryLayout.EntryKind.CATCH + identity = _fish_slots[from_index] + elif not _slots[from_index].is_empty(): + kind = PlayerInventoryLayout.EntryKind.ITEM + identity = _slots[from_index] + else: + kind = ( + PlayerInventoryLayout.EntryKind.CATCH + if not _fish_slots[to_index].is_empty() + else PlayerInventoryLayout.EntryKind.ITEM + ) + identity = ( + _fish_slots[to_index] + if not _fish_slots[to_index].is_empty() + else _slots[to_index] + ) + var temporary_index: int = from_index + from_index = to_index + to_index = temporary_index + if ( + identity.is_empty() + or not _inventory_layout.move_entry( + kind, + identity, + PlayerInventoryLayout.InventoryContainer.HOTBAR, + to_index, + ) + ): + return false var temporary: StringName = _slots[from_index] _slots[from_index] = _slots[to_index] _slots[to_index] = temporary @@ -134,6 +206,50 @@ func swap_slots(from_index: int, to_index: int) -> bool: return true +func move_slot_to_container( + slot_index: int, + target_container: int, + target_slot: int, +) -> bool: + if not _is_slot_valid(slot_index) or _inventory_layout == null: + return false + var kind: PlayerInventoryLayout.EntryKind + var identity: StringName + if not _fish_slots[slot_index].is_empty(): + kind = PlayerInventoryLayout.EntryKind.CATCH + identity = _fish_slots[slot_index] + elif not _slots[slot_index].is_empty(): + kind = PlayerInventoryLayout.EntryKind.ITEM + identity = _slots[slot_index] + else: + return false + if not _inventory_layout.move_entry( + kind, + identity, + target_container, + target_slot, + ): + return false + _slots[slot_index] = StringName() + _fish_slots[slot_index] = StringName() + var replacement := _inventory_layout.get_entry_at( + PlayerInventoryLayout.InventoryContainer.HOTBAR, + slot_index, + ) + if not replacement.is_empty(): + var replacement_identity := StringName( + str(replacement.get("identity", "")) + ) + if int(replacement.get("kind", -1)) == PlayerInventoryLayout.EntryKind.CATCH: + _fish_slots[slot_index] = replacement_identity + else: + _slots[slot_index] = replacement_identity + slots_changed.emit() + if slot_index == _selected_slot: + _emit_selected_assignment() + return true + + func select_slot(slot_index: int) -> bool: if not _is_slot_valid(slot_index): return false @@ -202,7 +318,13 @@ func replace_state( slots: Array[StringName], selected_slot: int, fish_slots: Array[StringName] = [], + synchronize_layout: bool = true, ) -> bool: + if ( + synchronize_layout and _inventory_layout != null + and not _inventory_layout.return_all_hotbar_entries_to_inventory() + ): + return false var normalized: Array[StringName] = [] normalized.resize(SLOT_COUNT) normalized.fill(StringName()) @@ -227,6 +349,22 @@ func replace_state( seen_fish[catch_id] = true _slots = normalized _fish_slots = normalized_fish + if _inventory_layout != null and synchronize_layout: + for index: int in SLOT_COUNT: + if not _slots[index].is_empty(): + _inventory_layout.move_entry( + PlayerInventoryLayout.EntryKind.ITEM, + _slots[index], + PlayerInventoryLayout.InventoryContainer.HOTBAR, + index, + ) + elif not _fish_slots[index].is_empty(): + _inventory_layout.move_entry( + PlayerInventoryLayout.EntryKind.CATCH, + _fish_slots[index], + PlayerInventoryLayout.InventoryContainer.HOTBAR, + index, + ) _selected_slot = clampi(selected_slot, 0, SLOT_COUNT - 1) slots_changed.emit() _emit_selected_assignment() @@ -249,7 +387,17 @@ func _can_assign(item_id: StringName) -> bool: if _catalog == null: return false var item: ItemDataType = _catalog.get_item_by_id(item_id) - return item != null and item.is_available() and item.hotbar_allowed + return ( + item != null + and item.is_available() + and item.hotbar_allowed + and ( + _inventory_layout == null + or _inventory_layout.get_container( + PlayerInventoryLayout.EntryKind.ITEM, item_id + ) >= 0 + ) + ) func _can_assign_fish(catch_id: StringName) -> bool: @@ -257,6 +405,12 @@ func _can_assign_fish(catch_id: StringName) -> bool: not catch_id.is_empty() and _fish_inventory != null and _fish_inventory.contains_catch_id(catch_id) + and ( + _inventory_layout == null + or _inventory_layout.get_container( + PlayerInventoryLayout.EntryKind.CATCH, catch_id + ) >= 0 + ) ) diff --git a/inventory/player_inventory_layout.gd b/inventory/player_inventory_layout.gd new file mode 100644 index 0000000..244e10b --- /dev/null +++ b/inventory/player_inventory_layout.gd @@ -0,0 +1,519 @@ +class_name PlayerInventoryLayout +extends Node + +const ItemCatalogType = preload("res://items/item_catalog.gd") +const ItemDataType = preload("res://items/item_data.gd") +const PlayerBagType = preload("res://inventory/player_bag.gd") +const FishInventoryType = preload("res://inventory/fish_inventory.gd") +const PlayerCoolerCapacityType = preload( + "res://progression/player_cooler_capacity.gd" +) +const PlayerWalletType = preload("res://economy/player_wallet.gd") + +const INVENTORY_COLUMNS: int = 9 +const INVENTORY_CAPACITIES: Array[int] = [9, 18, 27, 36] +const BACKPACK_EXPANSION_COSTS: Array[int] = [1500, 4500, 9000] +const MAX_BACKPACK_LEVEL: int = 3 +const MAX_INVENTORY_SLOT_COUNT: int = 36 +const MAX_STORAGE_SLOT_COUNT: int = PlayerCoolerCapacityType.MAX_CAPACITY +const HOTBAR_SLOT_COUNT: int = 9 + +enum EntryKind { + ITEM, + CATCH, +} + +enum InventoryContainer { + INVENTORY, + STORAGE, + HOTBAR, +} + +signal layout_changed +signal backpack_capacity_changed(level: int, capacity: int) + +var _bag: PlayerBagType +var _fish_inventory: FishInventoryType +var _item_catalog: ItemCatalogType +var _storage_capacity: PlayerCoolerCapacityType +var _placements: Dictionary[String, Dictionary] = {} +var _reconciling: bool = false +var _backpack_level: int = 0 + + +func setup( + bag: PlayerBagType, + fish_inventory: FishInventoryType, + item_catalog: ItemCatalogType, + storage_capacity: PlayerCoolerCapacityType, +) -> void: + _bag = bag + _fish_inventory = fish_inventory + _item_catalog = item_catalog + _storage_capacity = storage_capacity + if _bag != null and not _bag.contents_changed.is_connected(_on_contents_changed): + _bag.contents_changed.connect(_on_contents_changed) + if ( + _fish_inventory != null + and not _fish_inventory.catches_changed.is_connected(_on_contents_changed) + ): + _fish_inventory.catches_changed.connect(_on_contents_changed) + if ( + _storage_capacity != null + and not _storage_capacity.capacity_changed.is_connected( + _on_storage_capacity_changed + ) + ): + _storage_capacity.capacity_changed.connect(_on_storage_capacity_changed) + _reconcile(true) + + +func can_accept_item(item_id: StringName) -> bool: + if not is_managed_item(item_id): + return true + var key := item_key(item_id) + return _placements.has(key) or _first_free_slot(InventoryContainer.INVENTORY) >= 0 + + +func can_accept_catch(catch_id: StringName = StringName()) -> bool: + if not catch_id.is_empty() and _placements.has(catch_key(catch_id)): + return true + return _first_free_slot(InventoryContainer.INVENTORY) >= 0 + + +func is_managed_item(item_id: StringName) -> bool: + var item: ItemDataType = ( + _item_catalog.get_item_by_id(item_id) + if _item_catalog != null else null + ) + return item != null and not item.is_bait() and not item.is_lure() + + +func is_item_in_inventory(item_id: StringName) -> bool: + return _is_entry_in_container(item_key(item_id), InventoryContainer.INVENTORY) + + +func is_catch_in_inventory(catch_id: StringName) -> bool: + return _is_entry_in_container(catch_key(catch_id), InventoryContainer.INVENTORY) + + +func is_item_carried(item_id: StringName) -> bool: + return _is_entry_carried(item_key(item_id)) + + +func is_catch_carried(catch_id: StringName) -> bool: + return _is_entry_carried(catch_key(catch_id)) + + +func get_inventory_count() -> int: + return get_entries(InventoryContainer.INVENTORY).size() + + +func get_inventory_capacity() -> int: + return INVENTORY_CAPACITIES[_backpack_level] + + +func get_backpack_level() -> int: + return _backpack_level + + +func get_next_inventory_capacity() -> int: + if _backpack_level >= MAX_BACKPACK_LEVEL: + return -1 + return INVENTORY_CAPACITIES[_backpack_level + 1] + + +func get_next_backpack_cost() -> int: + if _backpack_level >= MAX_BACKPACK_LEVEL: + return -1 + return BACKPACK_EXPANSION_COSTS[_backpack_level] + + +func can_purchase_backpack(wallet: PlayerWalletType) -> bool: + var cost := get_next_backpack_cost() + return wallet != null and cost >= 0 and wallet.can_afford(cost) + + +func purchase_backpack(wallet: PlayerWalletType) -> bool: + if not can_purchase_backpack(wallet): + return false + var cost := get_next_backpack_cost() + if not wallet.debit(cost): + return false + _backpack_level += 1 + backpack_capacity_changed.emit(_backpack_level, get_inventory_capacity()) + layout_changed.emit() + return true + + +func restore_backpack_level(level: int) -> bool: + var validated := clampi(level, 0, MAX_BACKPACK_LEVEL) + var changed := _backpack_level != validated + _backpack_level = validated + if changed: + backpack_capacity_changed.emit(_backpack_level, get_inventory_capacity()) + _reconcile(changed) + return true + + +func get_storage_count() -> int: + return get_entries(InventoryContainer.STORAGE).size() + + +func get_hotbar_count() -> int: + return get_entries(InventoryContainer.HOTBAR).size() + + +func get_storage_capacity() -> int: + return _storage_capacity.get_capacity() if _storage_capacity != null else 0 + + +func get_entries(container: InventoryContainer) -> Array[Dictionary]: + var entries: Array[Dictionary] = [] + for key: String in _placements: + var placement: Dictionary = _placements[key] + if int(placement.get("container", -1)) != int(container): + continue + var entry: Dictionary = placement.duplicate(true) + entry["key"] = key + entries.append(entry) + entries.sort_custom( + func(left: Dictionary, right: Dictionary) -> bool: + return int(left.get("slot", -1)) < int(right.get("slot", -1)) + ) + return entries + + +func get_entry(kind: EntryKind, identity: StringName) -> Dictionary: + var key := _entry_key(kind, identity) + return _placements.get(key, {}).duplicate(true) + + +func get_container(kind: EntryKind, identity: StringName) -> int: + return int( + get_entry(kind, identity).get("container", -1) + ) + + +func get_key_at(container: InventoryContainer, slot: int) -> String: + return _key_at(container, slot) + + +func get_entry_at(container: InventoryContainer, slot: int) -> Dictionary: + var key := _key_at(container, slot) + if key.is_empty(): + return {} + var entry: Dictionary = _placements[key].duplicate(true) + entry["key"] = key + return entry + + +func move_entry( + kind: EntryKind, + identity: StringName, + target_container: InventoryContainer, + target_slot: int, +) -> bool: + var key := _entry_key(kind, identity) + if not _placements.has(key) or not _is_slot_valid(target_container, target_slot): + return false + var source: Dictionary = _placements[key] + if ( + int(source.get("container", -1)) == int(target_container) + and int(source.get("slot", -1)) == target_slot + ): + return true + var displaced_key := _key_at(target_container, target_slot) + var source_container: int = int(source.get("container", InventoryContainer.INVENTORY)) + var source_slot: int = int(source.get("slot", -1)) + source["container"] = int(target_container) + source["slot"] = target_slot + _placements[key] = source + if not displaced_key.is_empty(): + var displaced: Dictionary = _placements[displaced_key] + displaced["container"] = source_container + displaced["slot"] = source_slot + _placements[displaced_key] = displaced + layout_changed.emit() + return true + + +func move_entry_to_first_free( + kind: EntryKind, + identity: StringName, + target_container: InventoryContainer, +) -> bool: + var slot := _first_free_slot(target_container) + return slot >= 0 and move_entry(kind, identity, target_container, slot) + + +func return_hotbar_entry_to_inventory(slot: int) -> bool: + var key := _key_at(InventoryContainer.HOTBAR, slot) + if key.is_empty(): + return true + var placement: Dictionary = _placements[key] + return move_entry_to_first_free( + int(placement.get("kind", -1)) as EntryKind, + StringName(str(placement.get("identity", ""))), + InventoryContainer.INVENTORY, + ) + + +func return_all_hotbar_entries_to_inventory() -> bool: + var entries := get_entries(InventoryContainer.HOTBAR) + if get_inventory_count() + entries.size() > get_inventory_capacity(): + return false + for entry: Dictionary in entries: + if not move_entry_to_first_free( + int(entry.get("kind", -1)) as EntryKind, + StringName(str(entry.get("identity", ""))), + InventoryContainer.INVENTORY, + ): + return false + return true + + +func to_save_data() -> Dictionary: + var serialized: Array[Dictionary] = [] + var keys: Array[String] = [] + keys.assign(_placements.keys()) + keys.sort() + for key: String in keys: + var placement: Dictionary = _placements[key] + serialized.append({ + "kind": int(placement.get("kind", EntryKind.ITEM)), + "identity": str(placement.get("identity", "")), + "container": int(placement.get("container", InventoryContainer.INVENTORY)), + "slot": int(placement.get("slot", -1)), + }) + return { + "backpack_level": _backpack_level, + "placements": serialized, + } + + +func restore_from_save_data(data: Dictionary) -> bool: + var level_value: Variant = data.get("backpack_level", 0) + if ( + typeof(level_value) not in [TYPE_INT, TYPE_FLOAT] + or not is_equal_approx(float(level_value), floorf(float(level_value))) + ): + return false + _backpack_level = clampi(int(level_value), 0, MAX_BACKPACK_LEVEL) + var restored: Dictionary[String, Dictionary] = {} + var values: Variant = data.get("placements", []) + if typeof(values) != TYPE_ARRAY: + return false + for value: Variant in values as Array: + if typeof(value) != TYPE_DICTIONARY: + continue + var record := value as Dictionary + var kind: int = int(record.get("kind", -1)) + var identity := StringName(str(record.get("identity", ""))) + var container: int = int(record.get("container", -1)) + var slot: int = int(record.get("slot", -1)) + if ( + kind not in [EntryKind.ITEM, EntryKind.CATCH] + or identity.is_empty() + or container not in [ + InventoryContainer.INVENTORY, + InventoryContainer.STORAGE, + InventoryContainer.HOTBAR, + ] + or not _is_slot_valid(container as InventoryContainer, slot) + ): + continue + var key := _entry_key(kind as EntryKind, identity) + if restored.has(key) or _placement_slot_occupied(restored, container, slot): + continue + restored[key] = { + "kind": kind, + "identity": identity, + "container": container, + "slot": slot, + } + _placements = restored + _reconcile(true) + return true + + +func reset_to_defaults() -> void: + var capacity_changed := _backpack_level != 0 + _backpack_level = 0 + _placements.clear() + _reconcile(true) + if capacity_changed: + backpack_capacity_changed.emit(_backpack_level, get_inventory_capacity()) + + +static func item_key(item_id: StringName) -> String: + return "item:%s" % String(item_id) + + +static func catch_key(catch_id: StringName) -> String: + return "catch:%s" % String(catch_id) + + +func _entry_key(kind: EntryKind, identity: StringName) -> String: + return item_key(identity) if kind == EntryKind.ITEM else catch_key(identity) + + +func _is_entry_in_container(key: String, container: InventoryContainer) -> bool: + var placement: Dictionary = _placements.get(key, {}) + return int(placement.get("container", -1)) == int(container) + + +func _is_entry_carried(key: String) -> bool: + var placement: Dictionary = _placements.get(key, {}) + var container: int = int(placement.get("container", -1)) + return container in [InventoryContainer.INVENTORY, InventoryContainer.HOTBAR] + + +func _on_contents_changed() -> void: + _reconcile(false) + + +func _on_storage_capacity_changed(_level: int, _capacity: int) -> void: + _reconcile(true) + + +func _reconcile(force_signal: bool) -> void: + if _reconciling or _bag == null or _fish_inventory == null: + return + _reconciling = true + var expected: Dictionary[String, Dictionary] = {} + for owned in _bag.get_all_items(): + if owned == null or not is_managed_item(owned.item_id): + continue + var key := item_key(owned.item_id) + expected[key] = {"kind": EntryKind.ITEM, "identity": owned.item_id} + for fish_catch in _fish_inventory.get_all_catches(): + if fish_catch == null or not fish_catch.is_valid(): + continue + var key := catch_key(fish_catch.catch_id) + expected[key] = {"kind": EntryKind.CATCH, "identity": fish_catch.catch_id} + var changed: bool = false + for key: String in _placements.keys(): + if expected.has(key): + continue + _placements.erase(key) + changed = true + _normalize_existing_placements() + for key: String in expected: + if _placements.has(key): + continue + var target_container := InventoryContainer.INVENTORY + var target_slot := _first_free_slot(target_container) + if target_slot < 0: + target_container = InventoryContainer.STORAGE + target_slot = _first_free_slot(target_container) + if target_slot < 0: + push_error("Inventory and storage cannot fit owned entry '%s'." % key) + continue + var placement: Dictionary = expected[key].duplicate(true) + placement["container"] = int(target_container) + placement["slot"] = target_slot + _placements[key] = placement + changed = true + _reconciling = false + if changed or force_signal: + layout_changed.emit() + + +func _normalize_existing_placements() -> void: + var occupied_inventory: Dictionary[int, bool] = {} + var occupied_storage: Dictionary[int, bool] = {} + var occupied_hotbar: Dictionary[int, bool] = {} + var invalid_keys: Array[String] = [] + var keys: Array[String] = [] + keys.assign(_placements.keys()) + keys.sort() + for key: String in keys: + var placement: Dictionary = _placements[key] + var container: int = int(placement.get("container", -1)) + var slot: int = int(placement.get("slot", -1)) + var occupied: Dictionary[int, bool] + match container: + InventoryContainer.INVENTORY: + occupied = occupied_inventory + InventoryContainer.STORAGE: + occupied = occupied_storage + InventoryContainer.HOTBAR: + occupied = occupied_hotbar + _: + occupied = {} + if ( + container not in [ + InventoryContainer.INVENTORY, + InventoryContainer.STORAGE, + InventoryContainer.HOTBAR, + ] + or not _is_slot_valid(container as InventoryContainer, slot) + or occupied.has(slot) + ): + invalid_keys.append(key) + continue + occupied[slot] = true + for key: String in invalid_keys: + # Invalid records must stop occupying their old slot before free-space + # lookup. Otherwise a duplicate can incorrectly reserve a valid slot. + var placement: Dictionary = _placements[key] + _placements.erase(key) + var target := InventoryContainer.INVENTORY + var slot := _first_free_slot(target) + if slot < 0: + target = InventoryContainer.STORAGE + slot = _first_free_slot(target) + if slot < 0: + _placements.erase(key) + continue + placement["container"] = int(target) + placement["slot"] = slot + _placements[key] = placement + + +func _first_free_slot(container: InventoryContainer) -> int: + var capacity := ( + get_inventory_capacity() if container == InventoryContainer.INVENTORY + else HOTBAR_SLOT_COUNT if container == InventoryContainer.HOTBAR + else get_storage_capacity() + ) + for slot: int in capacity: + if _key_at(container, slot).is_empty(): + return slot + return -1 + + +func _key_at(container: InventoryContainer, slot: int) -> String: + for key: String in _placements: + var placement: Dictionary = _placements[key] + if ( + int(placement.get("container", -1)) == int(container) + and int(placement.get("slot", -1)) == slot + ): + return key + return "" + + +func _is_slot_valid(container: InventoryContainer, slot: int) -> bool: + if slot < 0: + return false + return slot < ( + get_inventory_capacity() if container == InventoryContainer.INVENTORY + else HOTBAR_SLOT_COUNT if container == InventoryContainer.HOTBAR + else get_storage_capacity() + ) + + +func _placement_slot_occupied( + placements: Dictionary[String, Dictionary], + container: int, + slot: int, +) -> bool: + for placement: Dictionary in placements.values(): + if ( + int(placement.get("container", -1)) == container + and int(placement.get("slot", -1)) == slot + ): + return true + return false diff --git a/inventory/player_inventory_layout.gd.uid b/inventory/player_inventory_layout.gd.uid new file mode 100644 index 0000000..debd549 --- /dev/null +++ b/inventory/player_inventory_layout.gd.uid @@ -0,0 +1 @@ +uid://c5cku54c6qgmw diff --git a/items/catalog/item_catalog.tres b/items/catalog/item_catalog.tres index 1715678..1c006f4 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=39 format=3] +[gd_resource type="Resource" script_class="ItemCatalog" load_steps=40 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"] @@ -38,7 +38,8 @@ [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"] +[ext_resource type="Resource" path="res://items/catalog/standard_shovel.tres" id="40_shovel"] [resource] script = ExtResource("1_script") -items = [ExtResource("2_rod"), ExtResource("3_coffee"), ExtResource("4_energy"), ExtResource("5_snack"), ExtResource("6_finder"), ExtResource("8_art_kit"), ExtResource("9_worms"), ExtResource("10_snails"), ExtResource("11_shrimp"), ExtResource("12_squid"), ExtResource("14_sardine"), ExtResource("13_anchovy"), ExtResource("15_roe"), ExtResource("16_standby"), ExtResource("17_batteries"), ExtResource("18_cardboard"), ExtResource("19_pond"), ExtResource("20_river"), ExtResource("21_lake"), ExtResource("22_salt"), ExtResource("23_whisker"), ExtResource("24_reef"), ExtResource("25_moonbeam"), ExtResource("26_sun"), ExtResource("27_rain"), ExtResource("28_fog"), ExtResource("29_guide"), ExtResource("30_small"), ExtResource("31_heavy"), ExtResource("32_rocket"), ExtResource("33_lucky"), ExtResource("34_showboat"), ExtResource("35_deep"), ExtResource("36_oddity"), ExtResource("37_aurora"), ExtResource("38_magnet"), ExtResource("39_crab_net")] +items = [ExtResource("2_rod"), ExtResource("3_coffee"), ExtResource("4_energy"), ExtResource("5_snack"), ExtResource("6_finder"), ExtResource("8_art_kit"), ExtResource("9_worms"), ExtResource("10_snails"), ExtResource("11_shrimp"), ExtResource("12_squid"), ExtResource("14_sardine"), ExtResource("13_anchovy"), ExtResource("15_roe"), ExtResource("16_standby"), ExtResource("17_batteries"), ExtResource("18_cardboard"), ExtResource("19_pond"), ExtResource("20_river"), ExtResource("21_lake"), ExtResource("22_salt"), ExtResource("23_whisker"), ExtResource("24_reef"), ExtResource("25_moonbeam"), ExtResource("26_sun"), ExtResource("27_rain"), ExtResource("28_fog"), ExtResource("29_guide"), ExtResource("30_small"), ExtResource("31_heavy"), ExtResource("32_rocket"), ExtResource("33_lucky"), ExtResource("34_showboat"), ExtResource("35_deep"), ExtResource("36_oddity"), ExtResource("37_aurora"), ExtResource("38_magnet"), ExtResource("39_crab_net"), ExtResource("40_shovel")] diff --git a/items/catalog/standard_shovel.tres b/items/catalog/standard_shovel.tres new file mode 100644 index 0000000..5c44996 --- /dev/null +++ b/items/catalog/standard_shovel.tres @@ -0,0 +1,18 @@ +[gd_resource type="Resource" script_class="ItemData" load_steps=3 format=3] + +[ext_resource type="Script" path="res://items/item_data.gd" id="1_item"] +[ext_resource type="Texture2D" path="res://ui/icons/pictograms/x_light.png" id="2_icon"] + +[resource] +script = ExtResource("1_item") +item_id = &"standard_shovel" +display_name = "standard shovel" +description = "a sturdy shovel for digging up buried finds on the beach." +active = true +category = 1 +icon = ExtResource("2_icon") +stackable = false +max_stack = 1 +usable = false +equippable = true +hotbar_allowed = true diff --git a/jobs/job_catalog.gd b/jobs/job_catalog.gd index 0b9c717..754263d 100644 --- a/jobs/job_catalog.gd +++ b/jobs/job_catalog.gd @@ -23,6 +23,8 @@ const WEATHER_SEGMENT_HOURS: float = ( const WEATHER_SEGMENT_COUNT: int = ( WorldWeatherService.DAILY_PLAN_SEGMENT_COUNT ) +const LEGACY_WEATHER_SEGMENT_HOURS: float = 2.0 +const LEGACY_WEATHER_SEGMENT_COUNT: int = 12 const MAX_JOB_REWARD_COINS: int = 100000 const MAX_JOB_REWARD_EXPERIENCE: int = 10000 @@ -341,6 +343,36 @@ static func is_valid_weather_schedule(value: Variant) -> bool: return WorldWeatherService.is_valid_daily_plan_schedule(value) +static func is_valid_legacy_weather_schedule(value: Variant) -> bool: + if typeof(value) != TYPE_ARRAY: + return false + var schedule: Array = value + if schedule.size() != LEGACY_WEATHER_SEGMENT_COUNT: + return false + for index: int in schedule.size(): + if typeof(schedule[index]) != TYPE_DICTIONARY: + return false + var entry: Dictionary = schedule[index] + var expected_hour: float = fposmod( + WorldTimeService.DAY_START_HOUR + + float(index) * LEGACY_WEATHER_SEGMENT_HOURS, + WorldTimeService.HOURS_PER_DAY, + ) + if ( + typeof(entry.get("start_hour")) not in [TYPE_FLOAT, TYPE_INT] + or not is_equal_approx( + float(entry.get("start_hour", -1.0)), expected_hour + ) + or not is_bounded_integer( + entry.get("weather"), + WorldWeatherService.Weather.SUNNY, + WorldWeatherService.Weather.FOGGY, + ) + ): + return false + return true + + static func _job( id: String, title: String, diff --git a/jobs/player_job_service.gd b/jobs/player_job_service.gd index 684c5ea..7b19cc6 100644 --- a/jobs/player_job_service.gd +++ b/jobs/player_job_service.gd @@ -245,16 +245,30 @@ func restore_from_save_data(data: Dictionary) -> bool: if not validate_save_data(data): return false var received_board: Dictionary = _active_board.duplicate(true) - _host_board = (data.get("host_board", {}) as Dictionary).duplicate(true) - _active_plan_id = str(data.get("active_plan_id", "")) + var saved_host_board: Dictionary = data.get("host_board", {}) + var legacy_weather_board: bool = ( + not saved_host_board.is_empty() + and not validate_board(saved_host_board) + and _validate_legacy_board(saved_host_board) + ) + _host_board = ( + {} + if legacy_weather_board + else saved_host_board.duplicate(true) + ) + _active_plan_id = ( + "" if legacy_weather_board else str(data.get("active_plan_id", "")) + ) _daily_progress.clear() var progress: Dictionary = data.get("daily_progress", {}) - for key: Variant in progress: - _daily_progress[str(key)] = int(progress[key]) + if not legacy_weather_board: + for key: Variant in progress: + _daily_progress[str(key)] = int(progress[key]) _daily_completions.clear() var completions: Dictionary = data.get("daily_completions", {}) - for key: Variant in completions: - _daily_completions[str(key)] = mini(int(completions[key]), 1) + if not legacy_weather_board: + for key: Variant in completions: + _daily_completions[str(key)] = mini(int(completions[key]), 1) _daily_clock_hour = float(data.get( "daily_clock_hour", _world_time.get_time_hours() @@ -345,6 +359,14 @@ static func validate_board(value: Variant) -> bool: ) ): return false + if ( + board.has("calendar_cycle_id") + and ( + typeof(board["calendar_cycle_id"]) != TYPE_STRING + or str(board["calendar_cycle_id"]).length() != 10 + ) + ): + return false var jobs: Array = board.get("jobs", []) if jobs.is_empty() or jobs.size() > 8: return false @@ -378,7 +400,11 @@ static func validate_save_data(value: Variant) -> bool: ): return false var host_board: Dictionary = data.get("host_board", {}) - if not host_board.is_empty() and not validate_board(host_board): + if ( + not host_board.is_empty() + and not validate_board(host_board) + and not _validate_legacy_board(host_board) + ): return false if ( data.has("daily_clock_hour") @@ -436,9 +462,43 @@ static func validate_save_data(value: Variant) -> bool: ) +static func _validate_legacy_board(value: Variant) -> bool: + if typeof(value) != TYPE_DICTIONARY: + return false + var board: Dictionary = value + if ( + typeof(board.get("plan_id")) != TYPE_STRING + or str(board.get("plan_id", "")).is_empty() + or str(board.get("plan_id", "")).length() > 96 + or not JobCatalog.is_bounded_integer( + board.get("cycle"), 0, MAX_PROGRESS_VALUE + ) + or not JobCatalog.is_bounded_integer( + board.get("schedule_anchor_index"), + 0, + JobCatalog.LEGACY_WEATHER_SEGMENT_COUNT - 1, + ) + or typeof(board.get("jobs")) != TYPE_ARRAY + or not JobCatalog.is_valid_legacy_weather_schedule( + board.get("weather_schedule", []) + ) + ): + return false + var jobs: Array = board.get("jobs", []) + if jobs.is_empty() or jobs.size() > 8: + return false + for job: Variant in jobs: + if not JobCatalog.is_valid_job(job): + return false + return true + + func _activate_host_board() -> void: - if _host_board.is_empty(): - _generate_host_board(0) + if _world_time != null: + _daily_clock_hour = _world_time.get_time_hours() + if _host_board.is_empty() or _host_board_is_stale(): + var next_cycle: int = int(_host_board.get("cycle", -1)) + 1 + _generate_host_board(maxi(next_cycle, 0)) else: _active_board = _host_board.duplicate(true) _switch_plan(str(_host_board.get("plan_id", ""))) @@ -472,6 +532,11 @@ func _generate_host_board(cycle: int) -> void: plan_id, jobs, schedule_anchor_index ), } + var calendar_cycle_id: String = _world_time.get_calendar_cycle_id( + DAILY_REFRESH_HOUR + ) + if not calendar_cycle_id.is_empty(): + _host_board["calendar_cycle_id"] = calendar_cycle_id _active_board = _host_board.duplicate(true) _switch_plan(plan_id) _apply_host_weather_plan() @@ -514,7 +579,10 @@ func _on_natural_time_advanced(hours: float) -> void: _progression_ready and _session != null and _session.is_host() - and _crossed_daily_refresh(previous_hour, _daily_clock_hour) + and ( + hours >= WorldTimeService.HOURS_PER_DAY + or _crossed_daily_refresh(previous_hour, _daily_clock_hour) + ) ): var cycle: int = int(_host_board.get("cycle", -1)) + 1 _generate_host_board(maxi(cycle, 0)) @@ -759,6 +827,19 @@ func _current_weather_segment() -> int: ) +func _host_board_is_stale() -> bool: + if _world_time == null: + return false + var current_cycle_id: String = _world_time.get_calendar_cycle_id( + DAILY_REFRESH_HOUR + ) + return ( + not current_cycle_id.is_empty() + and str(_host_board.get("calendar_cycle_id", "")) + != current_cycle_id + ) + + static func _daily_claim_id( plan_id: String, job_id: String, diff --git a/main/main.gd b/main/main.gd index b7d4cea..75aff1a 100644 --- a/main/main.gd +++ b/main/main.gd @@ -33,6 +33,9 @@ const FishingShopType = preload("res://ui/fishing_shop.gd") const FishingShopInteractionType = preload( "res://world/fishing_shop_interaction.gd" ) +const PlayerStorageInteractionType = preload( + "res://world/player_storage_interaction.gd" +) const UIPixelationPresenterType = preload( "res://ui/ui_pixelation_presenter.gd" ) @@ -228,6 +231,7 @@ const SHOP_PATTERN_SCALE: float = 1.75 var _gameplay_started: bool = false var _shop_interaction: FishingShopInteractionType +var _storage_interaction: PlayerStorageInteractionType var _title_music_tween: Tween var _title_music_transition_generation: int = 0 var _title_music_requested: bool = false @@ -432,13 +436,14 @@ func _start_dedicated_server() -> void: _fail_dedicated_server(_discovery.get_host_status_message()) return print( - "NETfishing dedicated server ready: %s on %s:%d (%d players, %s)" + "NETfishing dedicated server ready: %s on %s:%d (%d players, %s, timezone %s)" % [ config.server_name, config.bind_address, config.port, config.max_players, "public" if config.public_listing else "unlisted", + str(Time.get_time_zone_from_system().get("name", "local")), ] ) print( @@ -581,8 +586,22 @@ func _initialize_application(dedicated: bool) -> void: _asset_reservations ) _player.bag.setup(item_catalog) - _player.hotbar.setup(_player.bag, item_catalog, _player.inventory) + _player.inventory_layout.setup( + _player.bag, + _player.inventory, + item_catalog, + _player.cooler_capacity, + ) + _player.bag.set_inventory_layout(_player.inventory_layout) + _player.inventory.set_inventory_layout(_player.inventory_layout) + _player.hotbar.setup( + _player.bag, + item_catalog, + _player.inventory, + _player.inventory_layout, + ) _shop_interaction = _test_world.get_fishing_shop() + _storage_interaction = _test_world.get_player_storage() if not dedicated: _shop_interaction.setup_local_player(_player) _shop_interaction.local_player_range_changed.connect( @@ -591,6 +610,10 @@ func _initialize_application(dedicated: bool) -> void: _game_ui.set_shop_npc_player_in_range( _shop_interaction.is_local_player_in_range() ) + _storage_interaction.setup_local_player(_player) + _storage_interaction.local_player_range_changed.connect( + _on_storage_range_changed + ) _save_manager.setup( _player.inventory, _player.collection_log, @@ -598,6 +621,7 @@ func _initialize_application(dedicated: bool) -> void: fish_catalog, _player.bag, _player.hotbar, + _player.inventory_layout, item_catalog, _player.fishing_upgrades, _player.cooler_capacity, @@ -681,7 +705,7 @@ func _initialize_application(dedicated: bool) -> void: _network_player_list.set_surface_drawing_service( _network_surface_drawing ) - _network_chat.setup(_network_session, _world_time, _world_weather) + _network_chat.setup(_network_session) _network_fishing.setup( _network_session, _player_spawn_service, @@ -706,12 +730,15 @@ func _initialize_application(dedicated: bool) -> void: _network_fishing, _shop_interaction, _player.inventory, + _player.bag, + item_catalog, _player.wallet, _player.fish_sale_service, _save_manager, fish_catalog, sale_buyers, - _asset_reservations + _asset_reservations, + _player.inventory_layout, ) _player_jobs.bind_authoritative_services(_network_fishing, _network_sale) _network_shop.setup( @@ -726,7 +753,8 @@ func _initialize_application(dedicated: bool) -> void: _player.cooler_capacity, _player.art_unlocks, _save_manager, - _asset_reservations + _asset_reservations, + _player.inventory_layout, ) _fishing_spot.setup( _player, @@ -759,10 +787,12 @@ func _initialize_application(dedicated: bool) -> void: _fishing_spot, _player.bag, _player.hotbar, + _player.inventory_layout, item_catalog, main_shop_buyer_profile, _player.fishing_upgrades, _shop_interaction, + _storage_interaction, _player.item_effects, _player.cooler_capacity, _network_session, @@ -1264,6 +1294,9 @@ func _input(event: InputEvent) -> void: return var pause_menu: PauseMenuType = _game_ui.get_pause_menu() var fishing_shop: FishingShopType = _game_ui.get_fishing_shop() + if _game_ui.get_player_storage().consume_escape(): + get_viewport().set_input_as_handled() + return if fishing_shop.consume_escape(): get_viewport().set_input_as_handled() return @@ -1368,6 +1401,15 @@ func _unhandled_input(event: InputEvent) -> void: or (event is InputEventKey and event.echo) ): return + if ( + _storage_interaction != null + and _storage_interaction.is_local_player_in_range() + and _fishing_spot.can_open_fishing_shop() + and _game_ui.get_player_storage().open_storage() + ): + _game_ui.set_storage_prompt_visible(false) + get_viewport().set_input_as_handled() + return if ( _shop_interaction != null and _shop_interaction.is_local_player_in_range() @@ -1391,6 +1433,16 @@ func _process(_delta: float) -> void: show_shop_prompt, shop_prompt_anchor, ) + var show_storage_prompt := _can_show_storage_prompt() + var storage_prompt_anchor := ( + _storage_interaction.get_prompt_anchor_position() + if _storage_interaction != null + else Vector3.ZERO + ) + _game_ui.set_storage_prompt_visible( + show_storage_prompt, + storage_prompt_anchor, + ) func _apply_runtime_settings(settings: PlayerSettingsType) -> void: @@ -1879,6 +1931,7 @@ func _on_water_recovery_starting() -> void: _game_ui.close_player_menu_for_water_recovery() _game_ui.get_pause_menu().close_for_water_recovery() _game_ui.get_fishing_shop().close_for_water_recovery() + _game_ui.get_player_storage().close_for_water_recovery() func _on_water_recovery_finished() -> void: @@ -1933,6 +1986,12 @@ func _on_active_hotbar_item_changed( and item.is_available() and _player.bag.owns_item(item_id) ) + var active_is_shovel: bool = ( + item_id == FishingShopStockType.STANDARD_SHOVEL_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, @@ -1942,6 +2001,7 @@ func _on_active_hotbar_item_changed( active_is_art_kit, ) _player.set_active_catching_net(active_is_catching_net) + _player.set_active_shovel(active_is_shovel) _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) @@ -2067,6 +2127,12 @@ func _on_shop_range_changed(in_range: bool) -> void: _game_ui.set_shop_prompt_visible(_can_show_shop_prompt()) +func _on_storage_range_changed(in_range: bool) -> void: + if not in_range: + _game_ui.get_player_storage().close_for_range_exit() + _game_ui.set_storage_prompt_visible(_can_show_storage_prompt()) + + func _can_show_shop_prompt() -> bool: return ( _gameplay_started @@ -2076,3 +2142,15 @@ func _can_show_shop_prompt() -> bool: and not _water_recovery.is_recovery_active() and _fishing_spot.can_open_fishing_shop() ) + + +func _can_show_storage_prompt() -> bool: + return ( + _gameplay_started + and _storage_interaction != null + and _storage_interaction.is_local_player_in_range() + and not _game_ui.get_player_storage().visible + and not _game_ui.get_fishing_shop().visible + and not _water_recovery.is_recovery_active() + and _fishing_spot.can_open_fishing_shop() + ) diff --git a/network/network_chat_service.gd b/network/network_chat_service.gd index 75cdc81..0502a38 100644 --- a/network/network_chat_service.gd +++ b/network/network_chat_service.gd @@ -5,7 +5,6 @@ const BURST_COUNT: int = 3 const WINDOW_COUNT: int = 5 const WINDOW_SECONDS: float = 10.0 const CALL_COOLDOWN_MILLISECONDS: int = 180 -const WORLD_COMMAND_COOLDOWN_MILLISECONDS: int = 500 const CALL_PITCH_VARIANTS: Array[float] = [ 0.96, 1.03, @@ -23,7 +22,6 @@ signal message_received(message: Dictionary) signal local_message_confirmed(message: Dictionary) signal send_rejected(message: String) signal history_replaced(messages: Array[Dictionary]) -signal world_command_finished(success: bool, message: String) signal character_call_received( peer_id: int, call_id: String, @@ -37,22 +35,13 @@ var _request_ledgers: Dictionary[int, Dictionary] = {} var _rate_times: Dictionary[int, Array] = {} var _last_call_msec: Dictionary[int, int] = {} var _call_variant_indices: Dictionary[int, int] = {} -var _last_world_command_msec: Dictionary[int, int] = {} var _sequence: int = 0 var _peer_names: Dictionary[int, String] = {} var _relationships: PlayerRelationshipStore -var _world_time: WorldTimeService -var _world_weather: WorldWeatherService -func setup( - session: NetworkSession, - world_time: WorldTimeService, - world_weather: WorldWeatherService, -) -> void: +func setup(session: NetworkSession) -> void: _session = session - _world_time = world_time - _world_weather = world_weather _session.peer_authenticated.connect(_on_peer_authenticated) _session.peer_removed.connect(_on_peer_removed) _session.state_changed.connect(_on_session_state_changed) @@ -261,188 +250,6 @@ func broadcast_system_message(body: String) -> bool: return true -func request_world_time_change(phase_name: String) -> bool: - var normalized: String = phase_name.strip_edges().to_lower() - if not _valid_time_phase(normalized) or _session == null: - return false - if _session.is_host(): - return _apply_world_time_change(normalized) - if ( - not _session.is_joined_client() - or not _session.is_local_operator() - or not _session.supports_server_capability( - NetworkProtocol.WORLD_TIME_CAPABILITY - ) - ): - return false - submit_world_time_command.rpc_id(1, normalized) - return true - - -func request_world_weather_change(weather_name: String) -> bool: - var normalized: String = _normalized_weather_name(weather_name) - if normalized.is_empty() or _session == null: - return false - if _session.is_host(): - return _apply_world_weather_change(normalized) - if ( - not _session.is_joined_client() - or not _session.is_local_operator() - or not _session.supports_server_capability( - NetworkProtocol.WORLD_WEATHER_CAPABILITY - ) - ): - return false - submit_world_weather_command.rpc_id(1, normalized) - return true - - -@rpc("any_peer", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL) -func submit_world_time_command(phase_name: String) -> void: - var sender_id: int = multiplayer.get_remote_sender_id() - if not _valid_world_command_sender(sender_id): - _send_world_command_result( - sender_id, false, "Only the host or an operator can change world time." - ) - return - if not _consume_world_command_rate(sender_id): - _send_world_command_result(sender_id, false, "Slow down.") - return - var success: bool = _apply_world_time_change( - phase_name.strip_edges().to_lower() - ) - _send_world_command_result( - sender_id, - success, - "" if success else "World time could not be changed.", - ) - - -@rpc("any_peer", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL) -func submit_world_weather_command(weather_name: String) -> void: - var sender_id: int = multiplayer.get_remote_sender_id() - if not _valid_world_command_sender(sender_id): - _send_world_command_result( - sender_id, - false, - "Only the host or an operator can change world weather.", - ) - return - if not _consume_world_command_rate(sender_id): - _send_world_command_result(sender_id, false, "Slow down.") - return - var success: bool = _apply_world_weather_change( - _normalized_weather_name(weather_name) - ) - _send_world_command_result( - sender_id, - success, - "" if success else "World weather could not be changed.", - ) - - -@rpc("authority", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL) -func receive_world_command_result(success: bool, message: String) -> void: - if _session == null or not _session.is_joined_client(): - return - world_command_finished.emit(success, message.left(120)) - - -func _valid_world_command_sender(peer_id: int) -> bool: - return ( - _session != null - and _session.is_host() - and peer_id > 1 - and _session.is_authenticated_peer(peer_id) - and _session.is_peer_operator(peer_id) - ) - - -func _apply_world_time_change(phase_name: String) -> bool: - if _world_time == null or not _valid_time_phase(phase_name): - return false - var target_hour: float = _time_phase_hour(phase_name) - if not _world_time.set_authoritative_time(target_hour): - return false - broadcast_system_message( - "World time set to %s (%s)." - % [phase_name, _world_time.get_clock_text()] - ) - return true - - -func _apply_world_weather_change(weather_name: String) -> bool: - if _world_weather == null: - return false - var normalized: String = _normalized_weather_name(weather_name) - if normalized.is_empty(): - return false - var target_weather: WorldWeatherService.Weather = ( - _weather_for_name(normalized) - ) - if not _world_weather.set_authoritative_weather(target_weather): - return false - broadcast_system_message("World weather set to %s." % normalized) - return true - - -func _send_world_command_result( - peer_id: int, - success: bool, - message: String, -) -> void: - if peer_id > 1 and _session.is_authenticated_peer(peer_id): - receive_world_command_result.rpc_id(peer_id, success, message.left(120)) - - -func _consume_world_command_rate(peer_id: int) -> bool: - var now_msec: int = Time.get_ticks_msec() - var last_msec: int = _last_world_command_msec.get( - peer_id, now_msec - WORLD_COMMAND_COOLDOWN_MILLISECONDS - ) - if now_msec - last_msec < WORLD_COMMAND_COOLDOWN_MILLISECONDS: - return false - _last_world_command_msec[peer_id] = now_msec - return true - - -static func _valid_time_phase(phase_name: String) -> bool: - return phase_name in ["dawn", "day", "dusk", "night"] - - -static func _time_phase_hour(phase_name: String) -> float: - match phase_name: - "dawn": - return WorldTimeService.DAWN_START_HOUR - "day": - return WorldTimeService.DAWN_END_HOUR - "dusk": - return WorldTimeService.DUSK_START_HOUR - "night": - return WorldTimeService.DUSK_END_HOUR - return -1.0 - - -static func _normalized_weather_name(weather_name: String) -> String: - var normalized: String = weather_name.strip_edges().to_lower() - return "clear" if normalized == "sunny" else normalized if normalized in [ - "clear", "cloudy", "rainy", "foggy" - ] else "" - - -static func _weather_for_name( - weather_name: String, -) -> WorldWeatherService.Weather: - match weather_name: - "cloudy": - return WorldWeatherService.Weather.CLOUDY - "rainy": - return WorldWeatherService.Weather.RAINY - "foggy": - return WorldWeatherService.Weather.FOGGY - return WorldWeatherService.Weather.SUNNY - - func get_history() -> Array[Dictionary]: var result: Array[Dictionary] = [] for message: Dictionary in _history: @@ -638,7 +445,6 @@ func _on_peer_removed(peer_id: int) -> void: _rate_times.erase(peer_id) _last_call_msec.erase(peer_id) _call_variant_indices.erase(peer_id) - _last_world_command_msec.erase(peer_id) if not _session.is_host(): return var display_name: String = _peer_names.get(peer_id, "Player") @@ -701,7 +507,6 @@ func _on_session_state_changed(state: NetworkSession.State) -> void: _rate_times.clear() _last_call_msec.clear() _call_variant_indices.clear() - _last_world_command_msec.clear() _peer_names.clear() _sequence = 0 history_replaced.emit([]) diff --git a/network/network_fishing_service.gd b/network/network_fishing_service.gd index f7e1623..e1dfe56 100644 --- a/network/network_fishing_service.gd +++ b/network/network_fishing_service.gd @@ -253,7 +253,7 @@ func _handle_cast_request(peer_id: int, data: Dictionary) -> void: _record_and_reject(peer_id, request_id, "Already fishing.") return if not bool(data["capacity_available"]): - _record_and_reject(peer_id, request_id, "Cooler is full.") + _record_and_reject(peer_id, request_id, "Inventory is full.") return var rod := _item_catalog.get_item_by_id( StringName(str(data["rod_id"])) @@ -812,11 +812,8 @@ func _handle_local_capacity_probe(data: Dictionary) -> void: return var can_accept: bool = ( _local_inventory != null - and _local_capacity != null - and ( - _local_inventory.contains_catch_id(StringName(data["catch_id"])) - or _local_inventory.get_all_catches().size() - < _local_capacity.get_capacity() + and _local_inventory.can_accept_catch( + StringName(data["catch_id"]) ) ) if _session.is_host(): @@ -864,7 +861,7 @@ func _handle_capacity_response( ): return if not can_accept: - _cancel_attempt(peer_id, "Cooler is full.") + _cancel_attempt(peer_id, "Inventory is full.") return _finalize_catch(attempt) @@ -915,11 +912,7 @@ func _apply_target_outcome(data: Dictionary) -> void: if fish_catch == null: return var already_owned: bool = _local_inventory.contains_catch_id(catch_id) - if ( - not already_owned - and _local_inventory.get_all_catches().size() - >= _local_capacity.get_capacity() - ): + if not already_owned and not _local_inventory.can_accept_catch(catch_id): return if not already_owned: var experience_award: int = ( diff --git a/network/network_item_use_service.gd b/network/network_item_use_service.gd index 1777c51..7ae0f92 100644 --- a/network/network_item_use_service.gd +++ b/network/network_item_use_service.gd @@ -389,6 +389,10 @@ func _apply_equipped(data: Dictionary) -> void: item_id == FishingShopStockType.CRAB_NET_ID and bool(data["owns_item"]), ) + avatar.set_active_shovel( + item_id == FishingShopStockType.STANDARD_SHOVEL_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_mail_service.gd b/network/network_mail_service.gd index eb9fe0f..675a5aa 100644 --- a/network/network_mail_service.gd +++ b/network/network_mail_service.gd @@ -928,7 +928,9 @@ func _can_receive(attachment: Dictionary) -> bool: return _wallet.can_credit(int(attachment["amount"])) PlayerAssetReservationService.AttachmentType.FISH: return ( - _inventory.get_all_catches().size() < _cooler_capacity.get_capacity() + _inventory.can_accept_catch( + StringName(str(attachment["catch_id"])) + ) and not _inventory.contains_catch_id( StringName(str(attachment["catch_id"])) ) diff --git a/network/network_protocol.gd b/network/network_protocol.gd index 484896a..f70edd1 100644 --- a/network/network_protocol.gd +++ b/network/network_protocol.gd @@ -21,6 +21,7 @@ const MAIL_RELIABLE_CHANNEL: int = 9 const ENET_CHANNEL_COUNT: int = 10 const SURFACE_DRAWING_CAPABILITY: String = "surface_drawing_v2" const ART_SHOP_CAPABILITY: String = "art_shop_v1" +const BACKPACK_SHOP_CAPABILITY: String = "backpack_shop_v1" const WORLD_TIME_CAPABILITY: String = "world_time_v1" const WORLD_WEATHER_CAPABILITY: String = "world_weather_v1" const FISH_QUALITY_CAPABILITY: String = "fish_quality_v1" @@ -176,6 +177,7 @@ static func make_client_hello( JOBS_CAPABILITY, WORLD_SPAWN_CAPABILITY, APPEARANCE_PREVIEW_CAPABILITY, + BACKPACK_SHOP_CAPABILITY, ]), "cosmetic_snapshot": cosmetic_snapshot, "identity_fingerprint": identity_fingerprint, @@ -296,6 +298,7 @@ static func make_server_hello( "sale_v1", "shop_v1", ART_SHOP_CAPABILITY, + BACKPACK_SHOP_CAPABILITY, "item_use_v1", "equipment_v1", "fish_showcase_v1", diff --git a/network/network_sale_protocol.gd b/network/network_sale_protocol.gd index 3b80de6..190b718 100644 --- a/network/network_sale_protocol.gd +++ b/network/network_sale_protocol.gd @@ -6,6 +6,8 @@ const RELIABLE_CHANNEL: int = NetworkProtocol.SALE_RELIABLE_CHANNEL const MAX_ID_LENGTH: int = 96 const MAX_CATCH_ID_LENGTH: int = 160 const MAX_CATCHES_PER_REQUEST: int = 64 +const MAX_ITEM_STACKS_PER_REQUEST: int = 20 +const MAX_ITEM_QUANTITY: int = 999 const MAX_MESSAGE_LENGTH: int = 160 enum Rejection { @@ -40,6 +42,7 @@ static func validate_request(data: Variant) -> String: var session_id: String = payload["session_id"] var buyer_id: String = str(payload["buyer_id"]) var catches: Array = payload["catches"] + var items: Array = payload.get("items", []) if ( request_id.is_empty() or request_id.length() > MAX_ID_LENGTH @@ -47,8 +50,10 @@ static func validate_request(data: Variant) -> String: or session_id.length() > MAX_ID_LENGTH or buyer_id.is_empty() or buyer_id.length() > MAX_ID_LENGTH - or catches.is_empty() + or typeof(items) != TYPE_ARRAY + or (catches.is_empty() and items.is_empty()) or catches.size() > MAX_CATCHES_PER_REQUEST + or items.size() > MAX_ITEM_STACKS_PER_REQUEST ): return "Sale could not be completed." var seen: Dictionary[String, bool] = {} @@ -74,6 +79,23 @@ static func validate_request(data: Variant) -> String: ): return "Sale could not be completed." seen[catch_id] = true + var seen_items: Dictionary[String, bool] = {} + for value: Variant in items: + if typeof(value) != TYPE_DICTIONARY: + return "Sale could not be completed." + var item: Dictionary = value + var item_id := str(item.get("item_id", "")) + var quantity := int(item.get("quantity", 0)) + if ( + item_id.is_empty() + or item_id.length() > MAX_ID_LENGTH + or seen_items.has(item_id) + or typeof(item.get("quantity")) != TYPE_INT + or quantity < 1 + or quantity > MAX_ITEM_QUANTITY + ): + return "Sale could not be completed." + seen_items[item_id] = true return "" @@ -93,6 +115,8 @@ static func validate_result(data: Variant) -> bool: and typeof(payload.get("accepted")) == TYPE_BOOL and typeof(payload.get("catch_ids")) == TYPE_ARRAY and payload["catch_ids"].size() <= MAX_CATCHES_PER_REQUEST + and typeof(payload.get("items", [])) == TYPE_ARRAY + and payload.get("items", []).size() <= MAX_ITEM_STACKS_PER_REQUEST and typeof(payload.get("payout")) == TYPE_INT and int(payload["payout"]) >= 0 and typeof(payload.get("base_value")) == TYPE_INT @@ -119,4 +143,20 @@ static func validate_result(data: Variant) -> bool: ): return false seen[catch_id] = true + var seen_items: Dictionary[String, bool] = {} + for value: Variant in payload.get("items", []): + if typeof(value) != TYPE_DICTIONARY: + return false + var item := value as Dictionary + var item_id := str(item.get("item_id", "")) + if ( + item_id.is_empty() + or item_id.length() > MAX_ID_LENGTH + or seen_items.has(item_id) + or typeof(item.get("quantity")) != TYPE_INT + or int(item["quantity"]) < 1 + or int(item["quantity"]) > MAX_ITEM_QUANTITY + ): + return false + seen_items[item_id] = true return true diff --git a/network/network_sale_service.gd b/network/network_sale_service.gd index 11a499c..9207266 100644 --- a/network/network_sale_service.gd +++ b/network/network_sale_service.gd @@ -8,6 +8,10 @@ const FishPoolType = preload("res://fish/fish_pool.gd") const FishBuyerProfileType = preload("res://economy/fish_buyer_profile.gd") const FishSaleServiceType = preload("res://economy/fish_sale_service.gd") const FishSaleResultType = preload("res://economy/fish_sale_result.gd") +const ItemDataType = preload("res://items/item_data.gd") +const ItemCatalogType = preload("res://items/item_catalog.gd") +const PlayerBagType = preload("res://inventory/player_bag.gd") +const ItemResalePolicyType = preload("res://economy/item_resale_policy.gd") const MAX_LEDGER_ENTRIES_PER_PEER: int = 64 const PELICAN_BUYER_ID: StringName = &"pelicans" @@ -27,6 +31,8 @@ var _spawn_service: PlayerSpawnService var _network_fishing: NetworkFishingService var _shop_interaction: FishingShopInteraction var _inventory: FishInventory +var _bag: PlayerBagType +var _item_catalog: ItemCatalogType var _wallet: PlayerWallet var _sale_service: FishSaleServiceType var _save_manager: PlayerSaveManager @@ -40,8 +46,10 @@ var _applied_results: Dictionary[String, bool] = {} var _received_results: Dictionary[String, bool] = {} var _pending_local_request_id: String = "" var _pending_local_catch_ids: Array[StringName] = [] +var _pending_local_items: Array[Dictionary] = [] var _pending_local_buyer_id: StringName var _reservations: PlayerAssetReservationService +var _inventory_layout: PlayerInventoryLayout func setup( @@ -50,18 +58,23 @@ func setup( network_fishing: NetworkFishingService, shop_interaction: FishingShopInteraction, inventory: FishInventory, + bag: PlayerBagType, + item_catalog: ItemCatalogType, wallet: PlayerWallet, sale_service: FishSaleServiceType, save_manager: PlayerSaveManager, fish_catalog: FishPoolType, buyers: Array[FishBuyerProfileType], reservations: PlayerAssetReservationService, + inventory_layout: PlayerInventoryLayout = null, ) -> void: _session = session _spawn_service = spawn_service _network_fishing = network_fishing _shop_interaction = shop_interaction _inventory = inventory + _bag = bag + _item_catalog = item_catalog _wallet = wallet _sale_service = sale_service _save_manager = save_manager @@ -71,6 +84,7 @@ func setup( if buyer != null and buyer.is_valid(): _buyers[buyer.id] = buyer _reservations = reservations + _inventory_layout = inventory_layout if not _session.peer_removed.is_connected(_on_peer_removed): _session.peer_removed.connect(_on_peer_removed) if not _session.state_changed.is_connected(_on_session_state_changed): @@ -102,6 +116,15 @@ func can_request_sale( func request_local_sale( catch_ids: Array[StringName], buyer_id: StringName = PELICAN_BUYER_ID, +) -> String: + var no_items: Dictionary[StringName, int] = {} + return request_local_mixed_sale(catch_ids, no_items, buyer_id) + + +func request_local_mixed_sale( + catch_ids: Array[StringName], + item_quantities: Dictionary[StringName, int], + buyer_id: StringName = MAIN_SHOP_BUYER_ID, ) -> String: for catch_id: StringName in catch_ids: if _reservations != null and _reservations.is_fish_reserved(catch_id): @@ -123,7 +146,12 @@ func request_local_sale( 0 ) return "" - if catch_ids.is_empty() or _inventory == null: + if ( + (catch_ids.is_empty() and item_quantities.is_empty()) + or _inventory == null + or _bag == null + or _item_catalog == null + ): local_sale_finished.emit( "", false, "Sale could not be completed.", _empty_catch_ids(), 0 ) @@ -143,15 +171,41 @@ func request_local_sale( ) return "" evidence.append(fish_catch.to_network_dict()) + var item_evidence: Array[Dictionary] = [] + var item_ids: Array[StringName] = [] + item_ids.assign(item_quantities.keys()) + item_ids.sort_custom( + func(left: StringName, right: StringName) -> bool: + return str(left) < str(right) + ) + for item_id: StringName in item_ids: + var quantity := int(item_quantities.get(item_id, 0)) + var item: ItemDataType = _item_catalog.get_item_by_id(item_id) + if ( + quantity < 1 + or not ItemResalePolicyType.is_sellable(item) + or quantity > _bag.get_quantity(item_id) + or ( + _reservations != null + and quantity > _reservations.get_available_item_quantity(item_id) + ) + ): + local_sale_finished.emit( + "", false, "That item is no longer available.", _empty_catch_ids(), 0 + ) + return "" + item_evidence.append({"item_id": str(item_id), "quantity": quantity}) var request_id: String = _new_id("sale") var request: Dictionary = { "request_id": request_id, "session_id": _session.get_session_id(), "buyer_id": str(buyer.id), "catches": evidence, + "items": item_evidence, } _pending_local_request_id = request_id _pending_local_catch_ids = catch_ids.duplicate() + _pending_local_items = item_evidence.duplicate(true) _pending_local_buyer_id = buyer.id local_sale_pending.emit(request_id) if _session.is_host(): @@ -219,7 +273,11 @@ func _handle_sale_request(peer_id: int, data: Dictionary) -> void: )) return var result: Dictionary = _build_authoritative_result( - peer_id, request_id, data["catches"], buyer + peer_id, + request_id, + data["catches"], + data.get("items", []), + buyer, ) _pending_by_peer[peer_id] = request_id _record_and_send(peer_id, result) @@ -229,9 +287,14 @@ func _build_authoritative_result( peer_id: int, request_id: String, evidence_values: Array, + item_values: Array, buyer: FishBuyerProfileType, ) -> Dictionary: - if _fish_catalog == null or buyer == null: + if ( + buyer == null + or (not evidence_values.is_empty() and _fish_catalog == null) + or (not item_values.is_empty() and _item_catalog == null) + ): return _rejected_result( request_id, "The buyer is unavailable." ) @@ -283,6 +346,27 @@ func _build_authoritative_result( base_value += decoded.sale_value payout += offer catch_ids.append(str(decoded.catch_id)) + var items: Array[Dictionary] = [] + if not item_values.is_empty() and buyer.id != MAIN_SHOP_BUYER_ID: + return _rejected_result(request_id, "This buyer only accepts catches.") + for value: Variant in item_values: + var evidence := value as Dictionary + var item_id := StringName(str(evidence.get("item_id", ""))) + var quantity := int(evidence.get("quantity", 0)) + var item: ItemDataType = _item_catalog.get_item_by_id(item_id) + var unit_value := ItemResalePolicyType.get_unit_value(item) + if quantity < 1 or unit_value < 0: + return _rejected_result(request_id, "That item cannot be sold.") + var item_value := unit_value * quantity + if ( + item_value < 0 + or base_value > 9223372036854775807 - item_value + or payout > 9223372036854775807 - item_value + ): + return _rejected_result(request_id, "Sale could not be completed.") + base_value += item_value + payout += item_value + items.append({"item_id": str(item_id), "quantity": quantity}) return { "result_id": _new_id("sale_result"), "request_id": request_id, @@ -291,6 +375,7 @@ func _build_authoritative_result( "buyer_id": str(buyer.id), "accepted": true, "catch_ids": catch_ids, + "items": items, "payout": payout, "base_value": base_value, "message": "Sale complete.", @@ -313,6 +398,7 @@ func _rejected_result(request_id: String, message: String) -> Dictionary: "target_peer_id": 0, "accepted": false, "catch_ids": [], + "items": [], "payout": 0, "base_value": 0, "message": message.left(NetworkSaleProtocol.MAX_MESSAGE_LENGTH), @@ -388,6 +474,12 @@ func _apply_sale_result(data: Dictionary) -> void: if catch_ids != _pending_local_catch_ids: _fail_local_apply(data, "Sale could not be completed.") return + var items: Array[Dictionary] = [] + for value: Variant in data.get("items", []): + items.append((value as Dictionary).duplicate(true)) + if items != _pending_local_items: + _fail_local_apply(data, "Sale could not be completed.") + return for catch_id: StringName in catch_ids: if _reservations != null and _reservations.is_fish_reserved(catch_id): _fail_local_apply(data, "Reserved in a letter.") @@ -396,10 +488,12 @@ func _apply_sale_result(data: Dictionary) -> void: if buyer == null or not buyer.is_valid(): _fail_local_apply(data, "The buyer is unavailable.") return - var preview: FishSaleResultType = _sale_service.preview_batch( - catch_ids, buyer - ) - if not preview.is_success(): + var catch_payout: int = 0 + var catch_base_value: int = 0 + var preview: FishSaleResultType + if not catch_ids.is_empty(): + preview = _sale_service.preview_batch(catch_ids, buyer) + if preview != null and not preview.is_success(): var message: String = ( "Favorite catches cannot be sold." if preview.status == FishSaleResultType.Status.FAVORITED @@ -407,22 +501,63 @@ func _apply_sale_result(data: Dictionary) -> void: ) _fail_local_apply(data, message) return + if preview != null: + catch_payout = preview.payout + catch_base_value = preview.base_value + var item_payout: int = 0 + for record: Dictionary in items: + var item_id := StringName(str(record.get("item_id", ""))) + var quantity := int(record.get("quantity", 0)) + var item: ItemDataType = _item_catalog.get_item_by_id(item_id) + var unit_value := ItemResalePolicyType.get_unit_value(item) + if ( + quantity < 1 + or unit_value < 0 + or quantity > _bag.get_quantity(item_id) + or ( + _reservations != null + and quantity > _reservations.get_available_item_quantity(item_id) + ) + ): + _fail_local_apply(data, "That item is no longer available.") + return + item_payout += unit_value * quantity if ( - preview.payout != int(data["payout"]) - or preview.base_value != int(data["base_value"]) + catch_payout + item_payout != int(data["payout"]) + or catch_base_value + item_payout != int(data["base_value"]) ): _fail_local_apply(data, "Sale could not be completed.") return var inventory_snapshot: Array[FishCatch] = _inventory.get_all_catches() var sequence_snapshot: int = _inventory.get_next_catch_sequence() - var wallet_snapshot: int = _wallet.get_balance() - var local_result: FishSaleResultType = _sale_service.sell_batch( - catch_ids, buyer + var bag_snapshot := _bag.get_all_items() + var layout_snapshot: Dictionary = ( + _inventory_layout.to_save_data() + if _inventory_layout != null else {} ) - if not local_result.is_success() or not _save_manager.save_if_dirty(): + var wallet_snapshot: int = _wallet.get_balance() + var applied: bool = true + if not catch_ids.is_empty(): + applied = ( + _inventory.remove_catches_by_ids(catch_ids).size() + == catch_ids.size() + ) + if applied: + for record: Dictionary in items: + if not _bag.remove_item( + StringName(str(record["item_id"])), int(record["quantity"]) + ): + applied = false + break + if applied: + applied = _wallet.credit(int(data["payout"])) + if not applied or not _save_manager.save_if_dirty(): _inventory.replace_all_catches( inventory_snapshot, sequence_snapshot ) + _bag.replace_all_items(bag_snapshot) + if _inventory_layout != null: + _inventory_layout.restore_from_save_data(layout_snapshot) _wallet.restore_balance(wallet_snapshot) _save_manager.save_if_dirty() _fail_local_apply(data, "Sale could not be completed.") @@ -457,6 +592,7 @@ func _finish_local_sale( ) -> void: _pending_local_request_id = "" _pending_local_catch_ids.clear() + _pending_local_items.clear() _pending_local_buyer_id = StringName() local_sale_finished.emit( request_id, accepted, message, catch_ids, payout @@ -591,6 +727,7 @@ func _clear_session_state() -> void: _received_results.clear() _pending_local_request_id = "" _pending_local_catch_ids.clear() + _pending_local_items.clear() _pending_local_buyer_id = StringName() diff --git a/network/network_session.gd b/network/network_session.gd index e4c22df..cc564b2 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -271,6 +271,7 @@ func _register_player_host() -> void: NetworkProtocol.WORLD_WEATHER_CAPABILITY, NetworkProtocol.JOBS_CAPABILITY, NetworkProtocol.WORLD_SPAWN_CAPABILITY, + NetworkProtocol.BACKPACK_SHOP_CAPABILITY, ]), ) _registry.update_appearance(1, _local_appearance_snapshot) @@ -554,6 +555,7 @@ func supports_server_capability(capability: StringName) -> bool: return str(capability) in PackedStringArray([ "movement_v1", "fishing_v1", "sale_v1", "shop_v1", NetworkProtocol.ART_SHOP_CAPABILITY, + NetworkProtocol.BACKPACK_SHOP_CAPABILITY, "item_use_v1", "equipment_v1", "fish_showcase_v1", NetworkProtocol.FISH_QUALITY_CAPABILITY, NetworkProtocol.SURFACE_DRAWING_CAPABILITY, @@ -1365,6 +1367,7 @@ func receive_server_hello(data: Dictionary) -> void: NetworkProtocol.SURFACE_DRAWING_CAPABILITY, NetworkProtocol.WORLD_TIME_CAPABILITY, NetworkProtocol.WORLD_WEATHER_CAPABILITY, + NetworkProtocol.BACKPACK_SHOP_CAPABILITY, ]), ) _registry.update_appearance(local_peer_id, _local_appearance_snapshot) diff --git a/network/network_shop_protocol.gd b/network/network_shop_protocol.gd index 11900e9..2de9cc1 100644 --- a/network/network_shop_protocol.gd +++ b/network/network_shop_protocol.gd @@ -3,6 +3,7 @@ extends RefCounted const CAPABILITY: StringName = &"shop_v1" const ART_CAPABILITY: StringName = &"art_shop_v1" +const BACKPACK_CAPABILITY: StringName = &"backpack_shop_v1" const RELIABLE_CHANNEL: int = NetworkProtocol.SHOP_RELIABLE_CHANNEL const MAX_ID_LENGTH: int = 96 const MAX_MESSAGE_LENGTH: int = 160 @@ -16,6 +17,7 @@ enum ProductCategory { COOLER_CAPACITY_UPGRADE, ART_KIT, ART_UPGRADE, + BACKPACK_CAPACITY_UPGRADE, } diff --git a/network/network_shop_service.gd b/network/network_shop_service.gd index 0f26553..f30c1da 100644 --- a/network/network_shop_service.gd +++ b/network/network_shop_service.gd @@ -13,6 +13,7 @@ const SHOP_ID: StringName = &"main_fishing_shop" const REEL_PRODUCT_ID: StringName = &"reel_speed_upgrade" const BARRIER_PRODUCT_ID: StringName = &"barrier_power_upgrade" const COOLER_PRODUCT_ID: StringName = &"cooler_capacity_upgrade" +const BACKPACK_PRODUCT_ID: StringName = &"backpack_capacity_upgrade" const MAX_LEDGER_ENTRIES_PER_PEER: int = 64 signal local_purchase_pending(request_id: String) @@ -35,6 +36,7 @@ var _bag: PlayerBag var _item_catalog: ItemCatalog var _upgrades: PlayerFishingUpgrades var _cooler_capacity: PlayerCoolerCapacity +var _inventory_layout: PlayerInventoryLayout var _art_unlocks: PlayerArtUnlocks var _save_manager: PlayerSaveManager var _request_ledgers: Dictionary[int, Dictionary] = {} @@ -60,6 +62,7 @@ func setup( art_unlocks: PlayerArtUnlocks, save_manager: PlayerSaveManager, reservations: PlayerAssetReservationService, + inventory_layout: PlayerInventoryLayout = null, ) -> void: _session = session _spawn_service = spawn_service @@ -73,6 +76,7 @@ func setup( _art_unlocks = art_unlocks _save_manager = save_manager _reservations = reservations + _inventory_layout = inventory_layout if not _session.peer_removed.is_connected(_on_peer_removed): _session.peer_removed.connect(_on_peer_removed) if not _session.state_changed.is_connected(_on_session_state_changed): @@ -104,6 +108,18 @@ func can_request_art_purchase() -> bool: ) +func can_request_backpack_purchase() -> bool: + return ( + can_request_purchase() + and ( + _session.is_host() + or _session.supports_server_capability( + NetworkShopProtocol.BACKPACK_CAPABILITY + ) + ) + ) + + func is_local_purchase_pending() -> bool: return not _pending_local_request.is_empty() @@ -163,6 +179,16 @@ func request_cooler_capacity_upgrade() -> String: ) +func request_backpack_capacity_upgrade() -> String: + return _request_purchase( + BACKPACK_PRODUCT_ID, + NetworkShopProtocol.ProductCategory.BACKPACK_CAPACITY_UPGRADE, + 1, + _inventory_layout.get_backpack_level() + if _inventory_layout != null else 0, + ) + + func request_art_kit() -> String: return _request_purchase( ArtShopStockType.ART_KIT_ITEM_ID, @@ -230,6 +256,15 @@ func _request_purchase( product_id, category, 0, 0, ) return "" + if ( + category == NetworkShopProtocol.ProductCategory.BACKPACK_CAPACITY_UPGRADE + and not can_request_backpack_purchase() + ): + local_purchase_finished.emit( + "", false, "Backpack upgrades require a newer server.", + product_id, category, 0, 0, + ) + return "" var request_id: String = _new_id("shop") var request: Dictionary = { "request_id": request_id, @@ -372,7 +407,7 @@ func _build_authoritative_result( or current_state + quantity > item.max_stack ): rejection = ( - "Your Bag is full." + "Your inventory is full." if item != null and current_state >= item.max_stack else "Purchase could not be completed." ) @@ -458,6 +493,17 @@ func _build_authoritative_result( ] ) resulting_state = current_state + 1 + NetworkShopProtocol.ProductCategory.BACKPACK_CAPACITY_UPGRADE: + if ( + product_id != BACKPACK_PRODUCT_ID + or current_state >= PlayerInventoryLayout.MAX_BACKPACK_LEVEL + ): + rejection = "Upgrade is already at maximum." + else: + cost = PlayerInventoryLayout.BACKPACK_EXPANSION_COSTS[ + current_state + ] + resulting_state = current_state + 1 NetworkShopProtocol.ProductCategory.ART_KIT: var art_item: ItemDataType = _item_catalog.get_item_by_id( product_id @@ -612,6 +658,10 @@ func _apply_purchase_result(data: Dictionary) -> void: var reel_snapshot: int = _upgrades.get_reel_speed_level() var barrier_snapshot: int = _upgrades.get_barrier_power_level() var cooler_snapshot: int = _cooler_capacity.get_level() + var backpack_snapshot: int = ( + _inventory_layout.get_backpack_level() + if _inventory_layout != null else 0 + ) var art_snapshot: int = _art_unlocks.get_unlock_mask() var applied: bool = _apply_local_product(data) if not applied or not _save_manager.save_if_dirty(): @@ -619,6 +669,8 @@ func _apply_purchase_result(data: Dictionary) -> void: _bag.replace_unlocked_bait_ids(bait_unlock_snapshot) _upgrades.restore_levels(reel_snapshot, barrier_snapshot) _cooler_capacity.restore_level(cooler_snapshot) + if _inventory_layout != null: + _inventory_layout.restore_backpack_level(backpack_snapshot) _art_unlocks.restore_mask(art_snapshot) _wallet.restore_balance(wallet_snapshot) _save_manager.save_if_dirty() @@ -687,7 +739,7 @@ func _validate_local_result(data: Dictionary) -> String: ): return "Purchase could not be completed." if not _bag.can_add_item(product_id, data["quantity"]): - return "Your Bag is full." + return "Your inventory is full." NetworkShopProtocol.ProductCategory.ROD: var rod := ( _item_catalog.get_item_by_id(product_id) @@ -719,6 +771,13 @@ func _validate_local_result(data: Dictionary) -> String: return "Purchase could not be completed." if _cooler_capacity.get_next_cost() != cost: return "Purchase could not be completed." + NetworkShopProtocol.ProductCategory.BACKPACK_CAPACITY_UPGRADE: + if ( + _inventory_layout == null + or _inventory_layout.get_backpack_level() != expected_state + or _inventory_layout.get_next_backpack_cost() != cost + ): + return "Purchase could not be completed." NetworkShopProtocol.ProductCategory.ART_KIT: var art_item: ItemDataType = ( _item_catalog.get_item_by_id(product_id) @@ -768,6 +827,11 @@ func _apply_local_product(data: Dictionary) -> bool: return _upgrades.purchase_barrier_power(_wallet) NetworkShopProtocol.ProductCategory.COOLER_CAPACITY_UPGRADE: return _cooler_capacity.purchase(_wallet) + NetworkShopProtocol.ProductCategory.BACKPACK_CAPACITY_UPGRADE: + return ( + _inventory_layout != null + and _inventory_layout.purchase_backpack(_wallet) + ) NetworkShopProtocol.ProductCategory.ART_KIT: return ( _wallet.debit(int(data["total_cost"])) diff --git a/network/network_world_spawn_service.gd b/network/network_world_spawn_service.gd index 35f7eba..2f1deb6 100644 --- a/network/network_world_spawn_service.gd +++ b/network/network_world_spawn_service.gd @@ -39,6 +39,7 @@ var _entity_revisions: Dictionary = {} var _surface_triangles: Dictionary = {} var _surface_areas: Dictionary = {} var _surface_total_areas: Dictionary = {} +var _spawn_anchor_positions: Dictionary = {} var _respawns: Array[Dictionary] = [] var _next_respawn_by_type: Dictionary[StringName, float] = {} var _charge_requests: Dictionary = {} @@ -151,10 +152,9 @@ func find_entity_near( 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() + var distance_squared: float = entity_position.distance_squared_to( + position + ) if distance_squared <= best_distance_squared: best_distance_squared = distance_squared best_id = entity_id @@ -177,10 +177,9 @@ func find_capture_target( 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() + var distance_squared: float = entity_position.distance_squared_to( + position + ) if ( distance_squared <= entry.capture_radius * entry.capture_radius and distance_squared <= best_distance_squared @@ -248,14 +247,30 @@ func _begin_population_if_ready() -> void: func _cache_spawn_surface(entry: GatherableDataType) -> void: - if entry == null or _surface_triangles.has(entry.type_id): + if ( + entry == null + or _surface_triangles.has(entry.type_id) + or _spawn_anchor_positions.has(entry.type_id) + ): return - var triangles: Array[PackedVector3Array] = ( - _world.get_spawn_surface_triangles( + if not entry.spawn_anchor_set_id.is_empty(): + var anchor_positions: PackedVector3Array = ( + _world.get_gatherable_spawn_positions(entry.spawn_anchor_set_id) + ) + _spawn_anchor_positions[entry.type_id] = anchor_positions + if anchor_positions.is_empty(): + push_warning( + "No gatherable spawn anchors were found for %s." % entry.type_id + ) + return + var triangles: Array[PackedVector3Array] + if not entry.diggable_area_id.is_empty(): + triangles = _world.get_diggable_area_triangles(entry.diggable_area_id) + else: + triangles = _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: @@ -279,6 +294,11 @@ func _spawn_entity(entry: GatherableDataType) -> void: var position: Vector3 = _sample_surface_position(entry) if not position.is_finite(): return + var target: Vector3 = ( + position + if entry.is_stationary_spawn() + else _sample_surface_position(entry, position, entry.roam_radius) + ) var quality: int = FishQualityType.roll(_rng) var entity_id: String = _new_id("world") var state: Dictionary = { @@ -286,11 +306,16 @@ func _spawn_entity(entry: GatherableDataType) -> void: "type_id": entry.type_id, "data": entry, "position": position, - "target": _sample_surface_position(entry, position, entry.roam_radius), + "target": target, "yaw": _rng.randf_range(-PI, PI), "quality": quality, "revision": 1, "locked": false, + "expires_at": ( + _now() + entry.active_lifetime_seconds + if entry.active_lifetime_seconds > 0.0 + else INF + ), } if not (state["target"] as Vector3).is_finite(): state["target"] = position @@ -311,6 +336,11 @@ func _update_host_entities(delta: float) -> void: var entry := state.get("data") as GatherableDataType if entry == null: continue + if _now() >= float(state.get("expires_at", INF)): + _despawn_entity(entity_id, &"expired", false, true) + continue + if entry.is_stationary_spawn(): + continue var quality: int = _get_state_quality(state) var position: Vector3 = state["position"] var target: Vector3 = state["target"] @@ -349,7 +379,7 @@ func _update_host_entities(delta: float) -> void: state["yaw"] = atan2(-direction.x, -direction.z) state["position"] = position _entities[entity_id] = state - if _should_scare(entry, position, quality): + if entry.can_be_scared() and _should_scare(entry, position, quality): _despawn_entity(entity_id, &"scared", true, true) @@ -383,6 +413,8 @@ func _sample_surface_position( maximum_distance: float = INF, ) -> Vector3: _cache_spawn_surface(entry) + if not entry.spawn_anchor_set_id.is_empty(): + return _sample_anchor_position(entry, origin, maximum_distance) var triangles: Array = _surface_triangles.get(entry.type_id, []) var cumulative_areas: PackedFloat32Array = _surface_areas.get( entry.type_id, @@ -420,6 +452,44 @@ func _sample_surface_position( return fallback +func _sample_anchor_position( + entry: GatherableDataType, + origin: Vector3, + maximum_distance: float, +) -> Vector3: + var anchors: PackedVector3Array = _spawn_anchor_positions.get( + entry.type_id, + PackedVector3Array(), + ) + var candidates := PackedVector3Array() + for anchor: Vector3 in anchors: + if not anchor.is_finite() or _anchor_is_occupied(entry.type_id, anchor): + continue + if ( + origin.is_finite() + and is_finite(maximum_distance) + and anchor.distance_to(origin) > maximum_distance + ): + continue + candidates.append(anchor) + if candidates.is_empty(): + return Vector3(INF, INF, INF) + return candidates[_rng.randi_range(0, candidates.size() - 1)] + + +func _anchor_is_occupied(type_id: StringName, anchor: Vector3) -> bool: + for state: Dictionary in _entities.values(): + if StringName(state.get("type_id", StringName())) != type_id: + continue + var position: Variant = state.get("position") + if ( + typeof(position) == TYPE_VECTOR3 + and (position as Vector3).distance_squared_to(anchor) <= 0.0001 + ): + return true + return false + + func _broadcast_entity_snapshots() -> void: if _entities.is_empty(): return @@ -515,7 +585,9 @@ func _handle_interaction_begin(peer_id: int, data: Dictionary) -> void: 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.") + _send_interaction_result( + peer_id, request_id, false, "Cannot use that gathering tool now." + ) return _charge_requests[peer_id] = { "request_id": request_id, @@ -556,27 +628,26 @@ func _handle_interaction_finish(peer_id: int, data: Dictionary) -> void: ): 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." + error = "That gathering spot 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." + error = "Finish readying the tool 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." + elif avatar == null: + error = "The player is unavailable." + elif entry.requires_sneaking and not avatar.is_sneaking(): + error = "Sneak closer before using the tool." else: var entity_position: Vector3 = state["position"] - var target_distance: float = Vector2( - entity_position.x - target_position.x, - entity_position.z - target_position.z, - ).length() + var target_distance: float = entity_position.distance_to(target_position) 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." + error = "The gathering tool missed." elif player_distance > entry.interaction_range: - error = "Move closer before swinging the net." + error = "Move closer before using the tool." if not error.is_empty(): _send_interaction_result(peer_id, request_id, false, error) return @@ -670,12 +741,7 @@ func _handle_local_capacity_probe(data: Dictionary) -> void: 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() - ) + and _local_inventory.can_accept_catch(catch_id) ) if _session.is_host(): _handle_capacity_response( @@ -728,7 +794,7 @@ func _handle_capacity_response( ): return if not can_accept: - _reject_pending_capture(peer_id, "Cooler is full.") + _reject_pending_capture(peer_id, "Inventory is full.") return _pending_captures.erase(peer_id) var entity_id: String = str(pending["entity_id"]) @@ -813,8 +879,7 @@ func _apply_capture_result(data: Dictionary) -> void: ) if ( not already_owned - and _local_inventory.get_all_catches().size() - >= _local_capacity.get_capacity() + and not _local_inventory.can_accept_catch(fish_catch.catch_id) ): return if not already_owned: @@ -1138,6 +1203,7 @@ func _clear_world() -> void: _surface_triangles.clear() _surface_areas.clear() _surface_total_areas.clear() + _spawn_anchor_positions.clear() _respawns.clear() _next_respawn_by_type.clear() _charge_requests.clear() diff --git a/network/network_world_time_service.gd b/network/network_world_time_service.gd index 246af77..c5504b3 100644 --- a/network/network_world_time_service.gd +++ b/network/network_world_time_service.gd @@ -45,20 +45,16 @@ func _on_session_state_changed(state: NetworkSession.State) -> void: _sequence = 0 _last_received_sequence = -1 _sync_elapsed = 0.0 - _world_time.set_persistence_tracking_enabled(true) - _world_time.begin_session( - _world_time.get_persistent_time_hours() - ) + _world_time.begin_authoritative_session() return if state == NetworkSession.State.JOINED_CLIENT: _active_session_id = _session.get_session_id() _last_received_sequence = -1 _sync_elapsed = 0.0 - _world_time.set_persistence_tracking_enabled(false) if _session.supports_server_capability( NetworkProtocol.WORLD_TIME_CAPABILITY ): - _world_time.begin_session() + _world_time.begin_remote_session() else: _world_time.end_session() return @@ -72,7 +68,6 @@ func _on_session_state_changed(state: NetworkSession.State) -> void: _sequence = 0 _last_received_sequence = -1 _sync_elapsed = 0.0 - _world_time.set_persistence_tracking_enabled(false) _world_time.end_session() diff --git a/network/network_world_weather_service.gd b/network/network_world_weather_service.gd index 43d5762..ba0e6e2 100644 --- a/network/network_world_weather_service.gd +++ b/network/network_world_weather_service.gd @@ -3,7 +3,7 @@ extends Node const SYNC_INTERVAL_SECONDS: float = 15.0 const MAX_SESSION_ID_LENGTH: int = 96 -const MAX_REMAINING_SECONDS: float = 1800.0 +const MAX_REMAINING_SECONDS: float = WorldWeatherService.WEATHER_PERIOD_SECONDS var _session: NetworkSession var _world_weather: WorldWeatherService @@ -44,7 +44,6 @@ func _on_session_state_changed(state: NetworkSession.State) -> void: _sequence = 0 _last_received_sequence = -1 _sync_elapsed = 0.0 - _world_weather.set_persistence_tracking_enabled(true) _world_weather.begin_authoritative_session( _active_session_id.hash() ) @@ -53,7 +52,6 @@ func _on_session_state_changed(state: NetworkSession.State) -> void: _active_session_id = _session.get_session_id() _last_received_sequence = -1 _sync_elapsed = 0.0 - _world_weather.set_persistence_tracking_enabled(false) if _session.supports_server_capability( NetworkProtocol.WORLD_WEATHER_CAPABILITY ): @@ -71,7 +69,6 @@ func _on_session_state_changed(state: NetworkSession.State) -> void: _sequence = 0 _last_received_sequence = -1 _sync_elapsed = 0.0 - _world_weather.set_persistence_tracking_enabled(false) _world_weather.end_session() diff --git a/player/player.gd b/player/player.gd index 487c2fa..75d3233 100644 --- a/player/player.gd +++ b/player/player.gd @@ -19,6 +19,9 @@ const PlayerItemEffectsType = preload( const PlayerCoolerCapacityType = preload( "res://progression/player_cooler_capacity.gd" ) +const PlayerInventoryLayoutType = preload( + "res://inventory/player_inventory_layout.gd" +) const PlayerArtUnlocksType = preload( "res://progression/player_art_unlocks.gd" ) @@ -33,6 +36,7 @@ const FishingRodAttachmentScene = preload( ) const NetAttachmentScene = preload("res://player/net_attachment.tscn") const NetAttachmentType = preload("res://player/net_attachment.gd") +const ShovelAttachmentScene = preload("res://player/shovel_attachment.tscn") const FishingRodDataType = preload("res://items/fishing_rod_data.gd") const HeldItemAttachmentScene = preload( "res://player/held_item_attachment.tscn" @@ -347,6 +351,7 @@ class ShowcaseCameraSnapshot: @export_range(0.05, 1.0, 0.01) var showcase_restore_duration: float = 0.24 @export_range(0.05, 1.5, 0.01) var showcase_camera_transition_duration: float = 0.42 @export_range(-180.0, 180.0, 1.0) var showcase_camera_yaw_offset: float = 180.0 +@export_range(-180.0, 180.0, 1.0) var net_showcase_camera_yaw_offset: float = 0.0 @export_range(-80.0, 80.0, 1.0) var showcase_camera_pitch: float = -8.0 @export_range(1.0, 10.0, 0.1) var showcase_camera_zoom_distance: float = 3.6 @export_range(0.5, 3.0, 0.05) var showcase_camera_target_height: float = 1.45 @@ -394,6 +399,7 @@ class ShowcaseCameraSnapshot: @onready var fishing_upgrades: PlayerFishingUpgradesType = %FishingUpgrades @onready var item_effects: PlayerItemEffectsType = %ItemEffects @onready var cooler_capacity: PlayerCoolerCapacityType = %CoolerCapacity +@onready var inventory_layout: PlayerInventoryLayoutType = %InventoryLayout @onready var art_unlocks: PlayerArtUnlocksType = %ArtUnlocks @onready var experience: PlayerExperienceType = %Experience @onready var _cast_origin: Marker3D = %CastOrigin @@ -428,6 +434,7 @@ 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 _showcase_shovel_visibility: bool = false var _remote_presentation_visible := true var _showcase_rod_state_stored: bool = false var _showcase_visual_rotation: Vector3 @@ -502,6 +509,7 @@ var _retract_animation_completed: bool = true var _fishing_rod: Node3D var _catching_net: Node3D var _catching_net_attachment: NetAttachmentType +var _shovel: Node3D var _net_strike_collision_armed: bool = false var _net_strike_has_previous_sample: bool = false var _net_strike_previous_position: Vector3 @@ -520,6 +528,7 @@ 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 _active_item_is_shovel: bool = false var _controller_mapping_manager: ControllerMappingManagerType var _sprint_dust_landing_ready: bool = false var _sprint_dust_airborne: bool = false @@ -545,6 +554,7 @@ func _ready() -> void: _apply_presented_appearance() _initialize_fishing_rod() _initialize_catching_net() + _initialize_shovel() _target_zoom = clampf(_spring_arm.spring_length, minimum_zoom, maximum_zoom) _spring_arm.spring_length = _target_zoom _camera.current = local_control_enabled @@ -861,6 +871,23 @@ func _initialize_catching_net() -> void: _catching_net.visible = false +func _initialize_shovel() -> 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 shovel.") + return + var attachment := ShovelAttachmentScene.instantiate() as BoneAttachment3D + if attachment == null: + push_error("Shovel attachment could not be instantiated.") + return + skeleton.add_child(attachment) + _shovel = attachment.get_node("Shovel") as Node3D + if _shovel != null: + _shovel.visible = false + + func _initialize_held_item_attachment() -> void: var skeleton := get_node_or_null( "Visuals/CharacterRig/CharacterRig/Skeleton3D" @@ -2557,6 +2584,20 @@ func set_active_catching_net(should_show: bool) -> void: ) +func set_active_shovel(should_show: bool) -> void: + _active_item_is_shovel = should_show + if not should_show: + cancel_net_action_visual() + if _shovel == null: + return + if _showcase_rod_state_stored: + _showcase_shovel_visibility = should_show + return + _shovel.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: @@ -2625,6 +2666,12 @@ func _apply_active_rod_visibility() -> void: and not _has_held_show_item() and not _showcase_animation_active ) + if _shovel != null: + _shovel.visible = ( + _active_item_is_shovel + and not _has_held_show_item() + and not _showcase_animation_active + ) func set_active_art_kit(icon: Texture2D, should_show: bool) -> void: @@ -2655,6 +2702,8 @@ func set_active_art_kit(icon: Texture2D, should_show: bool) -> void: _fishing_rod.visible = false if _catching_net != null: _catching_net.visible = false + if _shovel != null: + _shovel.visible = false if item_changed: _begin_pocket_visual( PocketVisualTarget.ART_KIT, @@ -2705,6 +2754,8 @@ func set_held_fish( _fishing_rod.visible = false if _catching_net != null: _catching_net.visible = false + if _shovel != null: + _shovel.visible = false if item_changed: _begin_pocket_visual( PocketVisualTarget.HELD_FISH, @@ -2919,10 +2970,13 @@ func begin_catch_showcase(fish_catch: FishCatchType) -> void: _pending_net_showcase_catch = fish_catch _pending_net_showcase_remote = false return - _begin_catch_showcase_now(fish_catch) + _begin_catch_showcase_now(fish_catch, showcase_camera_yaw_offset) -func _begin_catch_showcase_now(fish_catch: FishCatchType) -> void: +func _begin_catch_showcase_now( + fish_catch: FishCatchType, + camera_yaw_offset_degrees: float, +) -> void: if _pocket_visual_target == PocketVisualTarget.CATCH_SHOWCASE: _cancel_pocket_visual() _showcase_animation_active = true @@ -2933,17 +2987,24 @@ func _begin_catch_showcase_now(fish_catch: FishCatchType) -> void: if not _showcase_visual_rotation_stored: _showcase_visual_rotation = _visuals.rotation _showcase_visual_rotation_stored = true - var showcase_camera_position: Vector3 = _begin_showcase_camera_transition() + var showcase_camera_position: Vector3 = _begin_showcase_camera_transition( + camera_yaw_offset_degrees + ) _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_shovel_visibility = ( + _shovel.visible if _shovel != null else false + ) _showcase_rod_state_stored = true _fishing_rod.visible = false if _catching_net != null: _catching_net.visible = false + if _shovel != null: + _shovel.visible = false _catch_sprite.texture = fish_catch.fish.display_texture _catch_display.scale = ( Vector3.ONE @@ -2972,10 +3033,15 @@ func _begin_remote_catch_showcase_now(fish_catch: FishCatchType) -> void: _showcase_net_visibility = ( _catching_net.visible if _catching_net != null else false ) + _showcase_shovel_visibility = ( + _shovel.visible if _shovel != null else false + ) _showcase_rod_state_stored = true _fishing_rod.visible = false if _catching_net != null: _catching_net.visible = false + if _shovel != null: + _shovel.visible = false _catch_sprite.texture = fish_catch.fish.display_texture _catch_display.scale = ( Vector3.ONE @@ -3009,7 +3075,10 @@ func _begin_pending_net_showcase() -> void: if remote: _begin_remote_catch_showcase_now(fish_catch) else: - _begin_catch_showcase_now(fish_catch) + _begin_catch_showcase_now( + fish_catch, + net_showcase_camera_yaw_offset, + ) func end_catch_showcase( @@ -3043,8 +3112,11 @@ func end_catch_showcase( _fishing_rod.visible = _showcase_rod_visibility if _catching_net != null: _catching_net.visible = _showcase_net_visibility + if _shovel != null: + _shovel.visible = _showcase_shovel_visibility _showcase_rod_visibility = true _showcase_net_visibility = false + _showcase_shovel_visibility = false _showcase_rod_state_stored = false if ( not _showcase_visual_rotation_stored @@ -3230,10 +3302,12 @@ func _capture_showcase_camera_snapshot() -> void: _set_camera_dragging(false) -func _begin_showcase_camera_transition() -> Vector3: +func _begin_showcase_camera_transition( + camera_yaw_offset_degrees: float, +) -> Vector3: var target_world_yaw: float = ( _visuals.global_rotation.y - + deg_to_rad(showcase_camera_yaw_offset) + + deg_to_rad(camera_yaw_offset_degrees) ) var target_local_yaw: float = target_world_yaw - global_rotation.y var shortest_target_yaw: float = ( diff --git a/player/player.tscn b/player/player.tscn index 43ab900..4a2d897 100644 --- a/player/player.tscn +++ b/player/player.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=22 format=3] +[gd_scene load_steps=23 format=3] [ext_resource type="Script" path="res://player/player.gd" id="1_script"] [ext_resource type="Script" path="res://inventory/fish_inventory.gd" id="2_inventory"] @@ -16,6 +16,7 @@ [ext_resource type="Script" path="res://progression/player_art_unlocks.gd" id="14_art_unlocks"] [ext_resource type="Script" path="res://progression/player_experience.gd" id="15_experience"] [ext_resource type="Script" path="res://player/sprint_dust_trail.gd" id="16_sprint_dust"] +[ext_resource type="Script" path="res://inventory/player_inventory_layout.gd" id="17_inventory_layout"] [sub_resource type="CapsuleShape3D" id="PlayerShape"] radius = 0.45 @@ -119,6 +120,10 @@ script = ExtResource("9_effects") unique_name_in_owner = true script = ExtResource("10_capacity") +[node name="InventoryLayout" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("17_inventory_layout") + [node name="ArtUnlocks" type="Node" parent="."] unique_name_in_owner = true script = ExtResource("14_art_unlocks") diff --git a/player/shovel_attachment.tscn b/player/shovel_attachment.tscn new file mode 100644 index 0000000..a57dfe5 --- /dev/null +++ b/player/shovel_attachment.tscn @@ -0,0 +1,68 @@ +[gd_scene load_steps=8 format=3] + +[ext_resource type="Script" path="res://player/tool_socket_attachment.gd" id="1_socket"] + +[sub_resource type="StandardMaterial3D" id="HandleMaterial"] +shading_mode = 0 +albedo_color = Color(0.36, 0.2, 0.1, 1) +metallic = 0.0 +roughness = 1.0 + +[sub_resource type="StandardMaterial3D" id="BladeMaterial"] +shading_mode = 0 +albedo_color = Color(0.34, 0.57, 0.58, 1) +metallic = 0.0 +roughness = 1.0 + +[sub_resource type="CylinderMesh" id="ShaftMesh"] +top_radius = 0.018 +bottom_radius = 0.018 +height = 0.7 +radial_segments = 8 +rings = 1 +material = SubResource("HandleMaterial") + +[sub_resource type="BoxMesh" id="BladeMesh"] +size = Vector3(0.19, 0.25, 0.025) +material = SubResource("BladeMaterial") + +[sub_resource type="BoxMesh" id="GripCrossMesh"] +size = Vector3(0.18, 0.035, 0.035) +material = SubResource("HandleMaterial") + +[sub_resource type="BoxMesh" id="GripSideMesh"] +size = Vector3(0.035, 0.13, 0.035) +material = SubResource("HandleMaterial") + +[node name="ShovelAttachment" type="BoneAttachment3D"] +bone_name = "rod_socket" +script = ExtResource("1_socket") +aligned_mount_path = NodePath("Shovel") + +[node name="Shovel" type="Node3D" parent="."] +visible = false + +[node name="Shaft" type="MeshInstance3D" parent="Shovel"] +position = Vector3(0, 0.35, 0) +cast_shadow = 0 +mesh = SubResource("ShaftMesh") + +[node name="Blade" type="MeshInstance3D" parent="Shovel"] +position = Vector3(0, 0.78, 0) +cast_shadow = 0 +mesh = SubResource("BladeMesh") + +[node name="GripCross" type="MeshInstance3D" parent="Shovel"] +position = Vector3(0, -0.09, 0) +cast_shadow = 0 +mesh = SubResource("GripCrossMesh") + +[node name="GripLeft" type="MeshInstance3D" parent="Shovel"] +position = Vector3(-0.0725, -0.035, 0) +cast_shadow = 0 +mesh = SubResource("GripSideMesh") + +[node name="GripRight" type="MeshInstance3D" parent="Shovel"] +position = Vector3(0.0725, -0.035, 0) +cast_shadow = 0 +mesh = SubResource("GripSideMesh") diff --git a/progression/player_cooler_capacity.gd b/progression/player_cooler_capacity.gd index 1e77784..b8c62be 100644 --- a/progression/player_cooler_capacity.gd +++ b/progression/player_cooler_capacity.gd @@ -5,9 +5,13 @@ const PlayerWalletType = preload("res://economy/player_wallet.gd") signal capacity_changed(level: int, capacity: int) -const CAPACITIES: Array[int] = [12, 18, 24, 32, 40] -const EXPANSION_COSTS: Array[int] = [75, 175, 400, 850] -const MAX_LEVEL: int = 4 +## Kept under the historical class name so existing saves and network product +## identifiers migrate without losing their purchased level. This progression +## now controls the player's private storage box rather than carried catches. +const CAPACITIES: Array[int] = [9, 18, 27, 36, 45, 54, 63, 72] +const EXPANSION_COSTS: Array[int] = [75, 175, 400, 850, 1500, 2500, 4000] +const MAX_LEVEL: int = 7 +const MAX_CAPACITY: int = 72 var _capacity_level: int = 0 diff --git a/save/player_save_manager.gd b/save/player_save_manager.gd index 5147709..159a9bc 100644 --- a/save/player_save_manager.gd +++ b/save/player_save_manager.gd @@ -12,6 +12,9 @@ const ItemCatalogType = preload("res://items/item_catalog.gd") const OwnedItemType = preload("res://items/owned_item.gd") const PlayerBagType = preload("res://inventory/player_bag.gd") const PlayerHotbarType = preload("res://inventory/player_hotbar.gd") +const PlayerInventoryLayoutType = preload( + "res://inventory/player_inventory_layout.gd" +) const PlayerFishingUpgradesType = preload( "res://progression/player_fishing_upgrades.gd" ) @@ -30,7 +33,7 @@ const WorldWeatherServiceType = preload( ) const PlayerJobServiceType = preload("res://jobs/player_job_service.gd") -const SAVE_VERSION: int = 7 +const SAVE_VERSION: int = 8 const BASIC_ROD_ID: StringName = &"basic_fishing_rod" const MAX_SAFE_BALANCE: int = 1000000000000 @@ -47,6 +50,7 @@ class LoadSnapshot: var hotbar_slots: Array[StringName] = [] var fish_hotbar_slots: Array[StringName] = [] var selected_hotbar_slot: int = 0 + var inventory_layout_data: Dictionary = {} var reel_speed_level: int = 0 var barrier_power_level: int = 0 var cooler_capacity_level: int = 0 @@ -71,6 +75,7 @@ var _wallet: PlayerWalletType var _catalog: FishPoolType var _bag: PlayerBagType var _hotbar: PlayerHotbarType +var _inventory_layout: PlayerInventoryLayoutType var _item_catalog: ItemCatalogType var _fishing_upgrades: PlayerFishingUpgradesType var _cooler_capacity: PlayerCoolerCapacityType @@ -117,6 +122,7 @@ func setup( catalog: FishPoolType, bag: PlayerBagType, hotbar: PlayerHotbarType, + inventory_layout: PlayerInventoryLayoutType, item_catalog: ItemCatalogType, fishing_upgrades: PlayerFishingUpgradesType, cooler_capacity: PlayerCoolerCapacityType, @@ -132,6 +138,7 @@ func setup( _catalog = catalog _bag = bag _hotbar = hotbar + _inventory_layout = inventory_layout _item_catalog = item_catalog _fishing_upgrades = fishing_upgrades _cooler_capacity = cooler_capacity @@ -147,6 +154,7 @@ func setup( and _catalog != null and _bag != null and _hotbar != null + and _inventory_layout != null and _item_catalog != null and _fishing_upgrades != null and _cooler_capacity != null @@ -171,6 +179,8 @@ func setup( _bag.contents_changed.connect(_mark_dirty) if not _hotbar.slots_changed.is_connected(_mark_dirty): _hotbar.slots_changed.connect(_mark_dirty) + if not _inventory_layout.layout_changed.is_connected(_mark_dirty): + _inventory_layout.layout_changed.connect(_mark_dirty) if not _hotbar.selected_slot_changed.is_connected( _on_selected_hotbar_slot_changed ): @@ -263,11 +273,6 @@ func load_player_data() -> bool: _bag.replace_all_items(snapshot.bag_items) and _bag.replace_unlocked_bait_ids(snapshot.unlocked_bait_ids) ) - var hotbar_restored: bool = _hotbar.replace_state( - snapshot.hotbar_slots, - snapshot.selected_hotbar_slot, - snapshot.fish_hotbar_slots, - ) var upgrades_restored: bool = _fishing_upgrades.restore_levels( snapshot.reel_speed_level, snapshot.barrier_power_level @@ -275,6 +280,15 @@ func load_player_data() -> bool: var cooler_restored: bool = _cooler_capacity.restore_level( snapshot.cooler_capacity_level ) + var layout_restored: bool = _inventory_layout.restore_from_save_data( + snapshot.inventory_layout_data + ) + var hotbar_restored: bool = _hotbar.replace_state( + snapshot.hotbar_slots, + snapshot.selected_hotbar_slot, + snapshot.fish_hotbar_slots, + false, + ) var art_restored: bool = _art_unlocks.restore_mask( snapshot.art_unlock_mask ) @@ -300,13 +314,36 @@ func load_player_data() -> bool: or not hotbar_restored or not upgrades_restored or not cooler_restored + or not layout_restored or not art_restored or not experience_restored or not world_time_restored or not world_weather_restored or not jobs_restored ): - push_error("Validated player save could not be restored.") + push_error( + ( + "Validated player save could not be restored: " + + "inventory=%s collection=%s wallet=%s bag=%s hotbar=%s " + + "upgrades=%s cooler=%s layout=%s art=%s experience=%s " + + "time=%s weather=%s jobs=%s" + ) + % [ + inventory_restored, + collection_restored, + wallet_restored, + bag_restored, + hotbar_restored, + upgrades_restored, + cooler_restored, + layout_restored, + art_restored, + experience_restored, + world_time_restored, + world_weather_restored, + jobs_restored, + ] + ) return false _is_dirty = false @@ -541,6 +578,7 @@ func _build_save_dictionary() -> Dictionary: "slots": serialized_slots, "fish_slots": serialized_fish_slots, }, + "inventory_layout": _inventory_layout.to_save_data(), "upgrades": _fishing_upgrades.to_save_data(), "cooler": _cooler_capacity.to_save_data(), "art": _art_unlocks.to_save_data(), @@ -563,6 +601,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: or typeof(save_data.get("inventory")) != TYPE_DICTIONARY or typeof(save_data.get("bag")) != TYPE_DICTIONARY or typeof(save_data.get("hotbar")) != TYPE_DICTIONARY + or typeof(save_data.get("inventory_layout")) != TYPE_DICTIONARY or typeof(save_data.get("experience")) != TYPE_DICTIONARY or typeof(save_data.get("jobs")) != TYPE_DICTIONARY ): @@ -572,6 +611,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: var inventory_data: Dictionary = save_data["inventory"] var bag_data: Dictionary = save_data["bag"] var hotbar_data: Dictionary = save_data["hotbar"] + var inventory_layout_data: Dictionary = save_data["inventory_layout"] var experience_data: Dictionary = save_data["experience"] var jobs_data: Dictionary = save_data["jobs"] var world_data: Dictionary = {} @@ -624,6 +664,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: return null var snapshot := LoadSnapshot.new() + snapshot.inventory_layout_data = inventory_layout_data.duplicate(true) snapshot.wallet_balance = balance snapshot.total_experience = _read_integer( experience_data["total_experience"], @@ -922,6 +963,8 @@ func _migrate_save( migrated = _migrate_version_5_to_6(migrated) 6: migrated = _migrate_version_6_to_7(migrated) + 7: + migrated = _migrate_version_7_to_8(migrated) _: return {} if migrated.is_empty(): @@ -1040,6 +1083,124 @@ func _migrate_version_6_to_7(data: Dictionary) -> Dictionary: return migrated +func _migrate_version_7_to_8(data: Dictionary) -> Dictionary: + var migrated: Dictionary = data.duplicate(true) + if ( + typeof(migrated.get("bag")) != TYPE_DICTIONARY + or typeof(migrated.get("inventory")) != TYPE_DICTIONARY + or typeof(migrated.get("hotbar")) != TYPE_DICTIONARY + ): + return {} + var bag_data: Dictionary = migrated["bag"] + var inventory_data: Dictionary = migrated["inventory"] + var hotbar_data: Dictionary = migrated["hotbar"] + if ( + typeof(bag_data.get("items")) != TYPE_ARRAY + or typeof(inventory_data.get("catches")) != TYPE_ARRAY + or typeof(hotbar_data.get("slots")) != TYPE_ARRAY + ): + return {} + + var placements: Array[Dictionary] = [] + var assigned: Dictionary[String, bool] = {} + var item_slots: Array = hotbar_data["slots"] + var fish_slots: Array = [] + if typeof(hotbar_data.get("fish_slots")) == TYPE_ARRAY: + fish_slots = hotbar_data["fish_slots"] + for slot: int in PlayerHotbarType.SLOT_COUNT: + var item_id := StringName( + str(item_slots[slot]) if slot < item_slots.size() else "" + ) + if not item_id.is_empty(): + var item = _item_catalog.get_item_by_id(item_id) + if item != null and not item.is_bait() and not item.is_lure(): + var key := PlayerInventoryLayoutType.item_key(item_id) + if not assigned.has(key): + placements.append({ + "kind": PlayerInventoryLayoutType.EntryKind.ITEM, + "identity": String(item_id), + "container": PlayerInventoryLayoutType.InventoryContainer.HOTBAR, + "slot": slot, + }) + assigned[key] = true + continue + var catch_id := StringName( + str(fish_slots[slot]) if slot < fish_slots.size() else "" + ) + if catch_id.is_empty(): + continue + var catch_key := PlayerInventoryLayoutType.catch_key(catch_id) + if assigned.has(catch_key): + continue + placements.append({ + "kind": PlayerInventoryLayoutType.EntryKind.CATCH, + "identity": String(catch_id), + "container": PlayerInventoryLayoutType.InventoryContainer.HOTBAR, + "slot": slot, + }) + assigned[catch_key] = true + + var next_inventory_slot: int = 0 + var next_storage_slot: int = 0 + for value: Variant in bag_data["items"] as Array: + if typeof(value) != TYPE_DICTIONARY: + continue + var item_id := StringName(str((value as Dictionary).get("item_id", ""))) + var item = _item_catalog.get_item_by_id(item_id) + if item == null or item.is_bait() or item.is_lure(): + continue + var key := PlayerInventoryLayoutType.item_key(item_id) + if assigned.has(key): + continue + var target := PlayerInventoryLayoutType.InventoryContainer.INVENTORY + var target_slot := next_inventory_slot + if next_inventory_slot < PlayerInventoryLayoutType.INVENTORY_CAPACITIES[0]: + next_inventory_slot += 1 + else: + target = PlayerInventoryLayoutType.InventoryContainer.STORAGE + target_slot = next_storage_slot + next_storage_slot += 1 + placements.append({ + "kind": PlayerInventoryLayoutType.EntryKind.ITEM, + "identity": String(item_id), + "container": target, + "slot": target_slot, + }) + assigned[key] = true + for value: Variant in inventory_data["catches"] as Array: + if typeof(value) != TYPE_DICTIONARY: + continue + var catch_id := StringName( + str((value as Dictionary).get("catch_id", "")) + ) + if catch_id.is_empty(): + continue + var key := PlayerInventoryLayoutType.catch_key(catch_id) + if assigned.has(key): + continue + var target := PlayerInventoryLayoutType.InventoryContainer.INVENTORY + var target_slot := next_inventory_slot + if next_inventory_slot < PlayerInventoryLayoutType.INVENTORY_CAPACITIES[0]: + next_inventory_slot += 1 + else: + target = PlayerInventoryLayoutType.InventoryContainer.STORAGE + target_slot = next_storage_slot + next_storage_slot += 1 + placements.append({ + "kind": PlayerInventoryLayoutType.EntryKind.CATCH, + "identity": String(catch_id), + "container": target, + "slot": target_slot, + }) + assigned[key] = true + migrated["inventory_layout"] = { + "backpack_level": 0, + "placements": placements, + } + migrated["save_version"] = 8 + return migrated + + func _mark_dirty() -> void: if ( _is_restoring @@ -1148,9 +1309,10 @@ func _restore_defaults() -> void: _bag.replace_all_items(default_items) var default_unlocked_baits: Array[StringName] = [] _bag.replace_unlocked_bait_ids(default_unlocked_baits) - _hotbar.replace_state(default_slots, 0) _fishing_upgrades.reset_to_defaults() _cooler_capacity.reset_to_defaults() + _inventory_layout.reset_to_defaults() + _hotbar.replace_state(default_slots, 0) _art_unlocks.reset_to_defaults() _experience.reset_to_defaults() _world_time.restore_persistent_time_hours( diff --git a/scripts/run_validations.sh b/scripts/run_validations.sh index 298c4f5..d773c4a 100755 --- a/scripts/run_validations.sh +++ b/scripts/run_validations.sh @@ -21,6 +21,7 @@ readonly -a QUICK_TESTS=( "tests/controller_world_interaction_validation.gd" "tests/controller_ui_navigation_validation.gd" "tests/dedicated_server_config_validation.gd" + "tests/digging_prototype_validation.gd" "tests/exported_decal_hotfix_validation.gd" "tests/file_dialog_controller_navigation_validation.gd" "tests/fish_catalog_content_validation.gd" @@ -43,6 +44,8 @@ readonly -a QUICK_TESTS=( "tests/tackle_order_validation.gd" "tests/terrain_blender_material_validation.gd" "tests/texture_sampling_validation.gd" + "tests/tree_gathering_prototype_validation.gd" + "tests/unified_inventory_validation.gd" "tests/world_time_validation.gd" "tests/world_spawn_protocol_validation.gd" "tests/world_weather_validation.gd" diff --git a/tests/controller_menu_accessibility_validation.gd b/tests/controller_menu_accessibility_validation.gd index f16e571..103b98a 100644 --- a/tests/controller_menu_accessibility_validation.gd +++ b/tests/controller_menu_accessibility_validation.gd @@ -18,11 +18,10 @@ const VoiceProfilesType = preload( ) const PlayerMenuScene = preload("res://ui/player_menu.tscn") const PlayerMenuType = preload("res://ui/player_menu.gd") -const BagItemSpriteScene = preload( - "res://ui/components/bubble_menu/bag_item_sprite.tscn" -) -const OwnedItemType = preload("res://items/owned_item.gd") const PlayerHotbarType = preload("res://inventory/player_hotbar.gd") +const ItemCatalogResource: ItemCatalog = preload( + "res://items/catalog/item_catalog.tres" +) const PlayersPageType = preload("res://ui/players_page.gd") const TheNetPageType = preload("res://ui/the_net_page.gd") const ControllerMappingManagerType = preload( @@ -1077,21 +1076,63 @@ func _validate_inventory_tab_zone_transitions() -> void: var menu := PlayerMenuScene.instantiate() as Control root.add_child(menu) await process_frame + var inventory_state := Node.new() + root.add_child(inventory_state) + var bag := PlayerBag.new() + var catches := FishInventory.new() + var storage_capacity := PlayerCoolerCapacity.new() + var layout := PlayerInventoryLayout.new() + var hotbar := PlayerHotbarType.new() + for node: Node in [bag, catches, storage_capacity, layout, hotbar]: + inventory_state.add_child(node) + bag.setup(ItemCatalogResource) + layout.setup(bag, catches, ItemCatalogResource, storage_capacity) + bag.set_inventory_layout(layout) + catches.set_inventory_layout(layout) + hotbar.setup(bag, ItemCatalogResource, catches, layout) + assert(bag.add_item(&"basic_fishing_rod", 1)) + assert(bag.add_item(&"coffee", 1)) + assert(layout.move_entry( + PlayerInventoryLayout.EntryKind.ITEM, + &"basic_fishing_rod", + PlayerInventoryLayout.InventoryContainer.INVENTORY, + 9, + )) + assert(hotbar.assign_item(0, &"coffee")) + menu.set("_bag", bag) + menu.set("_inventory", catches) + menu.set("_hotbar", hotbar) + menu.set("_inventory_layout", layout) + menu.set("_item_catalog", ItemCatalogResource) + var inventory_grid := menu.get("_general_inventory_grid") as GeneralInventoryGrid + inventory_grid.setup( + layout, + bag, + catches, + hotbar, + ItemCatalogResource, + PlayerInventoryLayout.InventoryContainer.INVENTORY, + ) menu.visible = true menu.call( "_show_section_immediate", - PlayerMenuType.Section.COOLER, + PlayerMenuType.Section.BAG, ) menu.call("_set_content_interactive", true) menu.call("_enter_inventory_tabs_zone") for _frame: int in 2: await process_frame - var cooler_tab := menu.get_node("%CoolerSubTab") as Button - var equipment_tab := menu.get_node("%BagSubTab") as Button - var items_tab := menu.get_node("%ItemsSubTab") as Button + var inventory_tab := menu.get_node("%BagSubTab") as Button + var tackle_tab := menu.get_node("%TackleSubTab") as Button _expect( - root.gui_get_focus_owner() == cooler_tab, - "Inventory tab zone did not begin on the active Cooler tab.", + root.gui_get_focus_owner() == inventory_tab, + "Inventory tab zone did not begin on the unified Inventory tab.", + ) + _expect(inventory_tab.text == "Inventory", "Unified tab is not labelled Inventory.") + _expect( + not (menu.get_node("%CoolerSubTab") as Button).visible + and not (menu.get_node("%ItemsSubTab") as Button).visible, + "Legacy Cooler or Items tabs remain visible.", ) var right := InputEventAction.new() @@ -1103,8 +1144,8 @@ func _validate_inventory_tab_zone_transitions() -> void: ) await _wait_for_player_menu_page_transition(menu) _expect( - menu.get("_current_section") == PlayerMenuType.Section.BAG, - "Controller Right did not switch from Cooler to Equipment.", + menu.get("_current_section") == PlayerMenuType.Section.TACKLE_BOX, + "Controller Right did not switch from Inventory to Tackle.", ) _expect( menu.get("_controller_ownership") @@ -1112,7 +1153,7 @@ func _validate_inventory_tab_zone_transitions() -> void: "Changing Inventory tabs entered the item-content zone without A.", ) _expect( - root.gui_get_focus_owner() == equipment_tab, + root.gui_get_focus_owner() == tackle_tab, "Changing Inventory tabs did not keep focus on the selected tab.", ) var down := InputEventAction.new() @@ -1124,59 +1165,33 @@ func _validate_inventory_tab_zone_transitions() -> void: ) await process_frame _expect( - root.gui_get_focus_owner() == equipment_tab, + root.gui_get_focus_owner() == tackle_tab, "Controller Down escaped the Inventory tab zone before A.", ) - - _expect( - bool(menu.call("_handle_controller_ownership_input", right)), - "Equipment-to-Items navigation did not consume controller Right.", - ) - await _wait_for_player_menu_page_transition(menu) - _expect( - root.gui_get_focus_owner() == items_tab, - "Items tab selection did not retain tab focus before A (focus=%s)." - % root.gui_get_focus_owner(), - ) - _expect( - menu.get("_controller_ownership") - == PlayerMenuType.ControllerOwnership.INVENTORY_TABS, - "Items tab selection changed controller zones before A.", - ) var left := InputEventAction.new() left.action = &"ui_left" left.pressed = true _expect( bool(menu.call("_handle_controller_ownership_input", left)), - "Items-to-Equipment navigation did not consume controller Left.", + "Tackle-to-Inventory navigation did not consume controller Left.", + ) + _expect( + menu.get("_current_section") == PlayerMenuType.Section.BAG + or bool(menu.get("_page_transitioning")), + "Tackle-to-Inventory input did not start the Inventory transition.", ) await _wait_for_player_menu_page_transition(menu) _expect( - root.gui_get_focus_owner() == equipment_tab, - "Returning to Equipment did not retain tab focus before A.", + root.gui_get_focus_owner() == inventory_tab, + "Returning to Inventory did not retain tab focus before A.", ) - - # Add three representative equipment entries so entering the content zone - # exercises the real five-column directional layout. - var item_field := menu.get_node("%BagItemField") as Control - var bag_nodes: Dictionary = menu.get("_bag_item_nodes") - var owned_items: Array[OwnedItemType] = [] - for index: int in 3: - var owned := OwnedItemType.new() - owned.item_id = StringName("controller_test_item_%d" % index) - owned_items.append(owned) - var item_node := BagItemSpriteScene.instantiate() as Button - item_node.set("item_id", owned.item_id) - item_node.position = Vector2(60.0 + float(index) * 180.0, 40.0) - item_field.add_child(item_node) - bag_nodes[owned.item_id] = item_node - menu.set("_sorted_bag_items", owned_items) menu.call("_set_content_interactive", true) menu.call("_configure_bag_item_focus") - for item_node: Control in bag_nodes.values(): + var active_slots := inventory_grid.get_slots() + for slot: GeneralInventorySlot in active_slots: _expect( - item_node.focus_mode == Control.FOCUS_NONE, - "Equipment content remained focusable while tabs owned the controller.", + slot.focus_mode == Control.FOCUS_NONE, + "Inventory slots remained focusable while tabs owned the controller.", ) var accept := InputEventJoypadButton.new() @@ -1193,34 +1208,45 @@ func _validate_inventory_tab_zone_transitions() -> void: ) for _frame: int in 2: await process_frame - var first_item := bag_nodes[owned_items[0].item_id] as Control - var second_item := bag_nodes[owned_items[1].item_id] as Control - for item_node: Control in bag_nodes.values(): + _expect( + menu.get("_current_section") == PlayerMenuType.Section.BAG, + "Entering Inventory contents changed section to %s." + % menu.get("_current_section"), + ) + var first_item := active_slots[0] as Control + var second_item := active_slots[1] as Control + for slot: GeneralInventorySlot in active_slots: _expect( - item_node.focus_mode == Control.FOCUS_ALL, - "Accepting Equipment did not enable its content focus zone.", + slot.focus_mode == Control.FOCUS_ALL, + "Accepting Inventory did not enable its content focus zone.", ) _expect( root.gui_get_focus_owner() == first_item, - "Entering Equipment did not focus its first item.", + "Entering Inventory did not focus its first slot.", ) _expect( first_item.focus_neighbor_right == first_item.get_path_to(second_item), - "Equipment items do not provide horizontal controller navigation.", + "Inventory slots do not provide horizontal controller navigation.", ) Input.parse_input_event(right) for _frame: int in 2: await process_frame _expect( - root.gui_get_focus_owner() == second_item, - "Controller Right did not move between Equipment items.", + menu.get("_current_section") == PlayerMenuType.Section.BAG, + "Inventory slot navigation changed section to %s." + % menu.get("_current_section"), ) - right.pressed = false - Input.parse_input_event(right) - right.pressed = true + _expect( + root.gui_get_focus_owner() == second_item, + "Controller Right did not move between Inventory slots.", + ) + var right_release := InputEventAction.new() + right_release.action = &"ui_right" + right_release.pressed = false + Input.parse_input_event(right_release) _expect( bool(menu.call("consume_escape")), - "Global player-menu Back did not consume Equipment contents.", + "Global player-menu Back did not consume Inventory contents.", ) _expect( menu.visible, @@ -1233,90 +1259,102 @@ func _validate_inventory_tab_zone_transitions() -> void: ) for _frame: int in 2: await process_frame + _expect( + menu.get("_current_section") == PlayerMenuType.Section.BAG, + "Returning from Inventory contents changed section to %s." + % menu.get("_current_section"), + ) _expect( bool(menu.call("_handle_controller_ownership_input", accept)), - "Inventory tab zone did not re-enter Equipment contents.", + "Inventory tab zone did not re-enter Inventory contents.", ) for _frame: int in 2: await process_frame - - var favorite := menu.get_node("%FavoriteBubble") as BaseButton - var sell := menu.get_node("%SellBubble") as BaseButton - var sell_all := menu.get_node("%SellAllBubble") as BaseButton - for action: BaseButton in [favorite, sell, sell_all]: - action.disabled = false - action.focus_mode = Control.FOCUS_ALL - var notepad_actions: Array[BaseButton] = [favorite, sell, sell_all] - menu.call( - "_configure_controller_notepad_action_focus", - notepad_actions, - ) + var notepad_source := active_slots[9] + notepad_source.grab_focus() + var context_press := InputEventJoypadButton.new() + context_press.button_index = JOY_BUTTON_Y + context_press.pressed = true _expect( - sell.get_node(sell.focus_neighbor_bottom) == sell_all, - "Sell All is not reachable below Sell Fish in the notepad zone.", - ) - _expect( - sell_all.get_node(sell_all.focus_neighbor_top) == sell, - "Sell All does not return to the upper notepad actions.", - ) - menu.set( - "_controller_ownership", - PlayerMenuType.ControllerOwnership.NOTEPAD_ACTIONS, - ) - menu.set("_controller_source_section", PlayerMenuType.Section.BAG) - menu.set("_controller_source_identity", owned_items[1].item_id) - menu.call("_apply_inventory_controller_zone_focus_modes") - _expect( - bool(menu.call("consume_escape")), - "Global player-menu Back did not consume the Inventory notepad zone.", - ) - _expect( - menu.visible, - "Global player-menu Back closed Inventory from its notepad zone.", + bool(menu.call( + "_handle_controller_ownership_input", context_press + )), + "Controller Y did not open the selected Inventory notepad.", ) _expect( menu.get("_controller_ownership") + == PlayerMenuType.ControllerOwnership.NOTEPAD_ACTIONS, + "Inventory notepad did not become the active controller zone.", + ) + _expect( + (menu.get_node("%BagDetailConstellation") as Control).visible, + "Inventory notepad remained hidden after controller Y.", + ) + _expect( + bool(menu.call("consume_escape")), + "Controller B did not close the Inventory notepad.", + ) + _expect( + not (menu.get_node("%BagDetailConstellation") as Control).visible + and menu.get("_controller_ownership") == PlayerMenuType.ControllerOwnership.ITEM_LIST, - "Global player-menu Back did not return the notepad to Inventory contents.", + "Closing the Inventory notepad did not restore the item zone.", ) for _frame: int in 2: await process_frame - - var hotbar := PlayerHotbarType.new() - menu.set("_hotbar", hotbar) - var hotbar_slots: Array[StringName] = [] - hotbar_slots.resize(PlayerHotbarType.SLOT_COUNT) - hotbar_slots.fill(StringName()) - hotbar.set("_slots", hotbar_slots) - var hotbar_fish_slots: Array[StringName] = [] - hotbar_fish_slots.resize(PlayerHotbarType.SLOT_COUNT) - hotbar_fish_slots.fill(StringName()) - hotbar_fish_slots[0] = &"controller_test_fish" - hotbar.set("_fish_slots", hotbar_fish_slots) + var accept_release := InputEventJoypadButton.new() + accept_release.button_index = JOY_BUTTON_A + accept_release.pressed = false + notepad_source.grab_focus() + menu.call("_handle_controller_ownership_input", accept) + menu.call("_handle_controller_ownership_input", accept_release) + _expect( + str(menu.get("_inventory_move_identity")) == "basic_fishing_rod", + "Controller A did not pick up the focused Inventory item.", + ) + var move_target := active_slots[10] + move_target.grab_focus() + menu.call("_handle_controller_ownership_input", accept) + menu.call("_handle_controller_ownership_input", accept_release) + _expect( + layout.get_key_at( + PlayerInventoryLayout.InventoryContainer.INVENTORY, 10 + ) == PlayerInventoryLayout.item_key(&"basic_fishing_rod"), + "Controller A did not place the picked-up item in its target slot.", + ) var management_requests: Array[int] = [0] menu.controller_hotbar_management_requested.connect( func(_initial_slot: int) -> void: management_requests[0] += 1 ) - var hotbar_owned := OwnedItemType.new() - hotbar_owned.item_id = &"controller_hotbar_source" - var hotbar_source := BagItemSpriteScene.instantiate() as Button - hotbar_source.set("item_id", hotbar_owned.item_id) - hotbar_source.position = Vector2(60.0, 40.0) - item_field.add_child(hotbar_source) - bag_nodes[hotbar_owned.item_id] = hotbar_source - var hotbar_items: Array[OwnedItemType] = [hotbar_owned] - menu.set("_sorted_bag_items", hotbar_items) menu.set( "_controller_ownership", PlayerMenuType.ControllerOwnership.ITEM_LIST, ) menu.call("_apply_inventory_controller_zone_focus_modes") menu.call("_configure_bag_item_focus") + var hotbar_source := active_slots[10] + _expect( + menu.get("_current_section") == PlayerMenuType.Section.BAG, + "Hotbar navigation test left the unified Inventory section (%s)." + % menu.get("_current_section"), + ) + _expect( + hotbar_source.focus_mode == Control.FOCUS_ALL, + "Final active Inventory row was not controller-focusable.", + ) hotbar_source.grab_focus() + _expect( + root.gui_get_focus_owner() == hotbar_source, + "Final Inventory row did not accept controller focus before Hotbar entry.", + ) + _expect( + bool(menu.call("_controller_focus_is_on_last_inventory_row")), + "Focused Inventory slot was not recognized as part of the final row.", + ) _expect( bool(menu.call("_handle_controller_ownership_input", down)), - "Down from the final Equipment row did not enter the hotbar zone.", + "Down from the final Inventory row did not enter the hotbar zone.", ) _expect( menu.get("_controller_ownership") @@ -1358,9 +1396,10 @@ func _validate_inventory_tab_zone_transitions() -> void: "Hotbar management did not consume controller A.", ) _expect( - hotbar.get_fish_catch_id(0).is_empty(), - "Controller A did not remove the selected fish hotbar assignment.", + hotbar.get_item_id(0).is_empty(), + "Controller A did not return the selected hotbar item to Inventory.", ) + _expect(layout.is_item_in_inventory(&"coffee"), "Cleared hotbar item was lost.") _expect( bool(menu.call("consume_escape")), "Global player-menu Back did not consume hotbar management.", @@ -1376,8 +1415,8 @@ func _validate_inventory_tab_zone_transitions() -> void: ) for _frame: int in 2: await process_frame - hotbar.free() menu.queue_free() + inventory_state.queue_free() await process_frame diff --git a/tests/digging_prototype_validation.gd b/tests/digging_prototype_validation.gd new file mode 100644 index 0000000..c19bf0b --- /dev/null +++ b/tests/digging_prototype_validation.gd @@ -0,0 +1,91 @@ +extends SceneTree + +const StarterIslandScene = preload( + "res://world/regions/starter_island_region.tscn" +) +const ShovelAttachmentScene = preload("res://player/shovel_attachment.tscn") +const ItemCatalogResource: ItemCatalog = preload( + "res://items/catalog/item_catalog.tres" +) +const Gatherables: GatherableCatalog = preload( + "res://gathering/catalog/gatherable_catalog.tres" +) + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _validate_catalog_content() + _validate_flat_shovel() + await _validate_beach_authoring() + print("Digging prototype validation: PASS") + quit() + + +func _validate_catalog_content() -> void: + var shovel: ItemData = ItemCatalogResource.get_available_item_by_id( + &"standard_shovel" + ) + assert(shovel != null) + assert(shovel.category == ItemData.Category.TOOL) + assert(shovel.equippable and shovel.hotbar_allowed) + var clam: GatherableData = Gatherables.get_entry(&"clam_manila") + assert(clam != null and clam.is_available()) + assert(clam.required_tool_id == shovel.item_id) + assert(clam.diggable_area_id == &"starter_beach") + assert(clam.presentation_mode == GatherableData.PresentationMode.WATER_SPURT) + assert(not clam.requires_sneaking) + assert(is_equal_approx(clam.active_lifetime_seconds, 10.0)) + + +func _validate_flat_shovel() -> void: + var attachment := ShovelAttachmentScene.instantiate() as BoneAttachment3D + assert(attachment != null) + var shovel := attachment.get_node("Shovel") as Node3D + assert(shovel != null) + var meshes: Array[MeshInstance3D] = [] + _collect_meshes(shovel, meshes) + assert(meshes.size() == 5) + for mesh_instance: MeshInstance3D in meshes: + assert( + mesh_instance.cast_shadow + == GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + ) + var material := mesh_instance.mesh.surface_get_material(0) as StandardMaterial3D + assert(material != null) + assert(material.shading_mode == BaseMaterial3D.SHADING_MODE_UNSHADED) + assert(material.transparency == BaseMaterial3D.TRANSPARENCY_DISABLED) + assert(material.albedo_texture == null) + assert(is_equal_approx(material.albedo_color.a, 1.0)) + attachment.free() + + +func _validate_beach_authoring() -> void: + var region := StarterIslandScene.instantiate() as WorldRegion + root.add_child(region) + await process_frame + var area: DiggableArea3D = region.get_diggable_area(&"starter_beach") + assert(area != null) + assert(area.terrain_source == NodePath("../../Terrain/Visual")) + assert(area.surface_materials.size() == 1) + assert(area.surface_materials[0] == &"sand") + var triangles: Array[PackedVector3Array] = area.get_surface_triangles() + assert(not triangles.is_empty()) + for triangle: PackedVector3Array in triangles: + assert(triangle.size() == 3) + var center := (triangle[0] + triangle[1] + triangle[2]) / 3.0 + assert(area.generation_bounds.has_point(Vector2(center.x, center.z))) + assert(center.y <= area.maximum_global_y + 0.001) + region.queue_free() + + +func _collect_meshes( + root_node: Node, + result: Array[MeshInstance3D], +) -> void: + for child: Node in root_node.get_children(): + if child is MeshInstance3D: + result.append(child as MeshInstance3D) + _collect_meshes(child, result) diff --git a/tests/digging_prototype_validation.gd.uid b/tests/digging_prototype_validation.gd.uid new file mode 100644 index 0000000..182ab02 --- /dev/null +++ b/tests/digging_prototype_validation.gd.uid @@ -0,0 +1 @@ +uid://c1dt44ieacnvb diff --git a/tests/economy_regression_validation.gd b/tests/economy_regression_validation.gd index cc9059d..a4f5440 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() == 314) - assert(LogbookCatalog.ordered_species(catalog.candidates).size() == 54) + assert(catalog != null and catalog.candidates.size() == 316) + assert(LogbookCatalog.ordered_species(catalog.candidates).size() == 56) assert(sale_service != null) assert(shop_service != null) assert(session != null and session.is_host()) @@ -62,7 +62,6 @@ func _run() -> void: _test_multi_sale(player, catalog, sale_service) _test_reservations(player, catalog, sale_service, reservations) _test_anywhere_sale(player, catalog, sale_service) - await _test_player_menu_sale(main, player, catalog, sale_service) await _test_host_shop_sale(main, player, catalog, sale_service) assert(session.set_host_open(true)) @@ -78,6 +77,8 @@ func _run() -> void: await _test_fishing_shop_sale_ui( main, player, catalog, sale_service, reservations ) + _test_host_backpack_purchase(player, shop_service) + _test_host_storage_purchase(player, shop_service) assert(not sale_service.is_local_sale_pending()) assert(not shop_service.is_local_purchase_pending()) @@ -217,6 +218,17 @@ func _run_multiplayer_client() -> void: print("Client shop result: ", _shop_result) assert(not _shop_result.is_empty() and bool(_shop_result[1])) assert(not shop_service.is_local_purchase_pending()) + var backpack_cost := player.inventory_layout.get_next_backpack_cost() + if player.wallet.get_balance() < backpack_cost: + assert(player.wallet.credit( + backpack_cost - player.wallet.get_balance() + )) + _shop_result.clear() + assert(not shop_service.request_backpack_capacity_upgrade().is_empty()) + while _shop_result.is_empty(): + await process_frame + assert(bool(_shop_result[1])) + assert(player.inventory_layout.get_inventory_capacity() == 18) var required_art_balance: int = ( ArtShopStock.ART_KIT_PRICE + ArtShopStock.UPGRADE_PRICE ) @@ -377,80 +389,39 @@ func _test_host_shop_sale( ) var species_ids: Array[StringName] = [] var expected_payout: int = 0 + var species_balance_before: int = player.wallet.get_balance() for fish: FishData in catalog.candidates: if not fish.active: continue var fish_catch := _make_catch(fish) - player.inventory.add_catch(fish_catch) + assert(player.inventory.add_catch(fish_catch)) species_ids.append(fish_catch.catch_id) expected_payout += fish_catch.sale_value - var species_balance_before: int = player.wallet.get_balance() - _assert_sale( - player, - sale_service, - species_ids, - true, - true, - NetworkSaleService.MAIN_SHOP_BUYER_ID, - ) + if species_ids.size() >= 5: + _assert_sale( + player, + sale_service, + species_ids, + true, + true, + NetworkSaleService.MAIN_SHOP_BUYER_ID, + ) + species_ids.clear() + if not species_ids.is_empty(): + _assert_sale( + player, + sale_service, + species_ids, + true, + true, + NetworkSaleService.MAIN_SHOP_BUYER_ID, + ) assert( player.wallet.get_balance() == species_balance_before + expected_payout ) -func _test_player_menu_sale( - main: Node, - player: Player, - catalog: FishPool, - sale_service: NetworkSaleService, -) -> void: - var game_ui := main.get_node("%GameUI") as GameUI - var player_menu := game_ui.get_node("%PlayerMenu") as PlayerMenu - var fish_catch := _make_catch(catalog.candidates[6]) - player.inventory.add_catch(fish_catch) - player.global_position = Vector3(0.0, 3.95, 13.0) - player_menu.open_menu() - await create_timer(2.2).timeout - player_menu.call("_on_catch_card_pressed", fish_catch.catch_id) - await process_frame - var sell_action := player_menu.get_node("%SellBubble") as Button - var confirmation := player_menu.get_node("%SaleConfirmation") as Control - var confirm_button := player_menu.get_node("%ConfirmSaleButton") as Button - var ui_viewport := main.get_node( - "UIPresentation/UIViewport" - ) as SubViewport - assert(sell_action.visible and not sell_action.disabled) - assert(sell_action.mouse_filter == Control.MOUSE_FILTER_STOP) - assert(ui_viewport != null) - await _activate_pointer_control(sell_action, ui_viewport) - await process_frame - assert(confirmation.visible) - assert(confirm_button.visible and not confirm_button.disabled) - assert( - confirmation.z_index - > (player_menu.get_node("%CoolerOuterWall") as Control).z_index - ) - _sale_result.clear() - await _activate_pointer_control(confirm_button, ui_viewport) - await process_frame - assert(not _sale_result.is_empty() and bool(_sale_result[1])) - assert(not player.inventory.contains_catch_id(fish_catch.catch_id)) - assert(not sale_service.is_local_sale_pending()) - player_menu.close_menu() - await create_timer(2.2).timeout - assert(not player_menu.visible) - player_menu.open_menu() - await create_timer(2.2).timeout - assert(player_menu.visible) - assert( - not sell_action.disabled - or player.inventory.get_all_catches().is_empty() - ) - player_menu.close_menu() - await create_timer(2.2).timeout - - func _activate_pointer_control( control: Control, ui_viewport: SubViewport, @@ -547,6 +518,48 @@ func _test_host_rod_purchase( assert(not shop_service.is_local_purchase_pending()) +func _test_host_backpack_purchase( + player: Player, + shop_service: NetworkShopService, +) -> void: + var layout := player.inventory_layout + assert(layout.get_inventory_capacity() == 9) + var total_cost: int = 0 + for cost: int in PlayerInventoryLayout.BACKPACK_EXPANSION_COSTS: + total_cost += cost + if player.wallet.get_balance() < total_cost: + assert(player.wallet.credit(total_cost - player.wallet.get_balance())) + var balance_before := player.wallet.get_balance() + for expected_capacity: int in [18, 27, 36]: + _shop_result.clear() + assert(not shop_service.request_backpack_capacity_upgrade().is_empty()) + assert(not _shop_result.is_empty() and bool(_shop_result[1])) + assert(layout.get_inventory_capacity() == expected_capacity) + assert(player.wallet.get_balance() == balance_before - total_cost) + assert(layout.get_next_backpack_cost() == -1) + + +func _test_host_storage_purchase( + player: Player, + shop_service: NetworkShopService, +) -> void: + var capacity := player.cooler_capacity + assert(capacity.get_capacity() == 9) + var total_cost: int = 0 + for cost: int in PlayerCoolerCapacity.EXPANSION_COSTS: + total_cost += cost + if player.wallet.get_balance() < total_cost: + assert(player.wallet.credit(total_cost - player.wallet.get_balance())) + var balance_before := player.wallet.get_balance() + for expected_capacity: int in [18, 27, 36, 45, 54, 63, 72]: + _shop_result.clear() + assert(not shop_service.request_cooler_capacity_upgrade().is_empty()) + assert(not _shop_result.is_empty() and bool(_shop_result[1])) + assert(capacity.get_capacity() == expected_capacity) + assert(player.wallet.get_balance() == balance_before - total_cost) + assert(capacity.get_next_cost() == -1) + + func _test_fishing_shop_sale_ui( main: Node, player: Player, @@ -608,7 +621,11 @@ func _test_fishing_shop_sale_ui( "QuantityBadge/Quantity" ) as Label assert(tackle_quantity.text == expected_bait_supply) - assert(tackle_bait_button.tooltip_text.contains(expected_bait_supply)) + assert( + str(tackle_bait_button.get_meta( + &"inventory_context_text", "" + )).contains(expected_bait_supply) + ) var tackle_badge := tackle_quantity.get_parent() as Panel assert( is_equal_approx( @@ -654,7 +671,10 @@ func _test_fishing_shop_sale_ui( assert((shop.get_node("%Upgrades") as Control).visible) assert(not (shop.get_node("%Supplies") as Control).visible) for upgrade_name: String in [ - "ReelPurchase", "BarrierPurchase", "CoolerPurchase" + "ReelPurchase", + "BarrierPurchase", + "CoolerPurchase", + "BackpackPurchase", ]: var upgrade_button := shop.get_node("%%%s" % upgrade_name) as Button assert(upgrade_button != null) @@ -662,6 +682,15 @@ func _test_fishing_shop_sale_ui( assert(upgrade_button.text.is_empty()) assert(upgrade_button.icon != null) assert(upgrade_button.tooltip_text.contains("level")) + for placeholder_name: String in ["CoolerPurchase", "BackpackPurchase"]: + var placeholder_button := shop.get_node( + "%%%s" % placeholder_name + ) as Button + assert( + placeholder_button.icon.resource_path.ends_with( + "/pictograms/x_light.png" + ) + ) assert(not shop.has_node("%ReelLevel")) assert(not shop.has_node("%BarrierEffect")) assert(not shop.has_node("%CoolerLevel")) @@ -674,7 +703,9 @@ func _test_fishing_shop_sale_ui( "/shop/32_currency.png" ) ) - for cost_name: String in ["ReelCost", "BarrierCost", "CoolerCost"]: + for cost_name: String in [ + "ReelCost", "BarrierCost", "CoolerCost", "BackpackCost" + ]: var cost_display := shop.get_node("%%%s" % cost_name) as CurrencyAmount assert(cost_display != null) var cost_icon := cost_display.get_node("Icon") as TextureRect @@ -690,7 +721,7 @@ func _test_fishing_shop_sale_ui( var art_supplies_tab := shop_tabs[4] as Button assert(art_supplies_tab != null and art_supplies_tab.text == "Art Supplies") var sell_mode := shop_tabs[5] as Button - assert(sell_mode != null and sell_mode.text == "Sell Fish") + assert(sell_mode != null and sell_mode.text == "Sell") var equipment_tab := shop_tabs[3] as Button assert(equipment_tab != null and equipment_tab.text == "Equipment") var supplies_list := shop.get_node("%SuppliesList") as VBoxContainer @@ -881,44 +912,47 @@ func _test_fishing_shop_sale_ui( assert((shop.get_node("%ShopPanel") as Control).visible) assert(not (shop.get_node("ShopPanel/Margin/Layout/Body") as Control).visible) assert(not (shop.get_node("%Feedback") as Control).visible) - var mounted_cooler := player_menu.get("_cooler_page") as Control - assert(mounted_cooler != null and mounted_cooler.visible) + var sell_inventory := shop.get("_sell_inventory") as ShopSellInventory + assert(sell_inventory != null and sell_inventory.visible) + var sell_tray_grid := sell_inventory.get("_tray_grid") as GridContainer assert( - mounted_cooler.get_parent() == shop.get_node("%ShopCoolerMount") + sell_tray_grid.columns == PlayerInventoryLayout.INVENTORY_COLUMNS ) - var cooler_outer_wall := player_menu.get("_cooler_outer_wall") as Control - var water_surface := player_menu.get("_cooler_water_surface") as ColorRect - assert(cooler_outer_wall != null and cooler_outer_wall.visible) - assert(water_surface.visible and water_surface.material is ShaderMaterial) - var cooler_sort_option := player_menu.get("_cooler_sort_option") as Control - await _activate_pointer_control(cooler_sort_option, ui_viewport) - var cooler_choice_panel := cooler_sort_option.get("_choice_panel") as Control - assert(cooler_choice_panel.visible) - cooler_sort_option.call("close_choices") assert( - StringName( - (player_menu.get("_sale_buyer_override") as FishBuyerProfile).id - ) == NetworkSaleService.MAIN_SHOP_BUYER_ID + sell_tray_grid.get_theme_constant("h_separation") + == GeneralInventoryGrid.DEFAULT_SLOT_SEPARATION ) - var fish_nodes: Dictionary = player_menu.get("_fish_nodes") - var fish_button := fish_nodes.get(fish_catch.catch_id) as Button - var reserved_button := fish_nodes.get(reserved_catch.catch_id) as Button - assert(fish_button != null and fish_button.visible) - assert(reserved_button != null and reserved_button.visible) - await _activate_pointer_control(fish_button, ui_viewport) - var sell_button := player_menu.get("_sell_bubble") as Button - assert(sell_button.visible and not sell_button.disabled) - await _activate_pointer_control(sell_button, ui_viewport) - await process_frame - var confirmation := player_menu.get("_sale_confirmation") as Control - var confirm_button := player_menu.get("_confirm_sale_button") as Button - assert(confirmation.visible) assert( - confirmation.z_index - > cooler_outer_wall.z_index + sell_inventory.get_parent() == shop.get_node("%ShopCoolerMount") ) + var staged: Dictionary = sell_inventory.get("_staged") + sell_inventory.call( + "_stage", + PlayerInventoryLayout.EntryKind.CATCH, + reserved_catch.catch_id, + ) + assert(staged.is_empty()) + sell_inventory.call( + "_stage", + PlayerInventoryLayout.EntryKind.CATCH, + fish_catch.catch_id, + ) + assert(staged.has(PlayerInventoryLayout.catch_key(fish_catch.catch_id))) + assert((sell_inventory.get("_feedback") as Label).text.is_empty()) + var source_grid := sell_inventory.get("_inventory_grid") as GeneralInventoryGrid + var staged_source_found := false + for source_slot: GeneralInventorySlot in source_grid.get_slots(): + if source_slot.entry_identity == fish_catch.catch_id: + staged_source_found = bool(source_slot.get("_staged")) + break + assert(staged_source_found) + var feedback := sell_inventory.get("_feedback") as Label + var total_label := sell_inventory.get("_total_label") as Label + var sell_button := sell_inventory.get("_sell_button") as Button + assert(feedback.get_index() < total_label.get_parent().get_index()) + assert(total_label.get_parent().get_index() < sell_button.get_index()) _sale_result.clear() - await _activate_pointer_control(confirm_button, ui_viewport) + sell_inventory.call("_submit_sale") await process_frame assert(not _sale_result.is_empty() and bool(_sale_result[1])) assert(not player.inventory.contains_catch_id(fish_catch.catch_id)) @@ -929,7 +963,7 @@ func _test_fishing_shop_sale_ui( await process_frame assert(shop.visible and (shop.get_node("%ShopPanel") as Control).visible) assert(not (shop.get_node("%ShopCoolerPage") as Control).visible) - assert(not player_menu.is_shop_cooler_mounted()) + assert(not sell_inventory.is_visible_in_tree()) shop.close_shop() await shop.menu_visibility_changed assert(not shop.visible) diff --git a/tests/equipment_catalog_validation.gd b/tests/equipment_catalog_validation.gd index 498c2ba..cbbbedc 100644 --- a/tests/equipment_catalog_validation.gd +++ b/tests/equipment_catalog_validation.gd @@ -55,6 +55,17 @@ func _initialize() -> void: assert(crab_net != null) assert(crab_net.icon != null) assert(crab_net.icon.resource_path.ends_with("/equipment/temp_net.png")) + var shovel: ItemDataType = ItemCatalogResource.get_available_item_by_id( + &"standard_shovel" + ) + assert(shovel != null) + assert(shovel.category == ItemDataType.Category.TOOL) + assert(shovel.icon != null) + assert(shovel.equippable) + assert(shovel.hotbar_allowed) + assert(FishingShopStockType.get_price(&"standard_shovel") == 75) + assert(FishingShopStockType.get_stock_item_ids().has(&"standard_shovel")) + assert(FishingShopStockType.is_permanent_unlock(&"standard_shovel", shovel)) var wallet := PlayerWalletType.new() wallet.current_balance = 250 diff --git a/tests/fish_catalog_content_validation.gd b/tests/fish_catalog_content_validation.gd index a2855e5..6fe3ec8 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() == 314) + assert(Catalog.candidates.size() == 316) 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 == 54) + assert(active_count == 56) assert(inactive_count == 260) var inactive_fish: FishDataType = Catalog.get_fish_by_id(&"bowfin") assert(inactive_fish != null and not inactive_fish.active) @@ -455,6 +455,7 @@ func _validate_catches_and_authoritative_sale() -> void: 1, "catalog_sale_%d" % catch_sequence, [loaded.to_network_dict()], + [], PelicanBuyer, ) assert(bool(sale_result.get("accepted", false))) diff --git a/tests/fish_hotbar_showcase_validation.gd b/tests/fish_hotbar_showcase_validation.gd index 5281d77..356791d 100644 --- a/tests/fish_hotbar_showcase_validation.gd +++ b/tests/fish_hotbar_showcase_validation.gd @@ -104,7 +104,7 @@ func _run() -> void: var hotbar_data: Dictionary = (parsed as Dictionary)["hotbar"] assert(typeof(hotbar_data.get("fish_slots")) == TYPE_ARRAY) assert(str((hotbar_data["fish_slots"] as Array)[1]) == fish_catch.catch_id) - assert(int((parsed as Dictionary)["save_version"]) == 7) + assert(int((parsed as Dictionary)["save_version"]) == 8) assert( int((parsed as Dictionary)["experience"]["total_experience"]) == 125 diff --git a/tests/fish_quality_validation.gd b/tests/fish_quality_validation.gd index 7d729ac..6d90042 100644 --- a/tests/fish_quality_validation.gd +++ b/tests/fish_quality_validation.gd @@ -305,6 +305,7 @@ func _validate_catch_round_trip_and_sale() -> void: 1, "quality_sale", [network_data], + [], PelicanBuyer, ) assert(bool(accepted.get("accepted", false))) @@ -336,6 +337,7 @@ func _validate_catch_round_trip_and_sale() -> void: 1, "quality_sale_forged", [forged], + [], PelicanBuyer, ) assert(not bool(rejected.get("accepted", false))) @@ -431,7 +433,7 @@ func _validate_version_four_migration() -> void: version_four, 4, ) - assert(int(migrated.get("save_version", -1)) == 7) + assert(int(migrated.get("save_version", -1)) == 8) assert(int((migrated["experience"] as Dictionary)["total_experience"]) == 0) assert( is_equal_approx( diff --git a/tests/fur_pattern_validation.gd b/tests/fur_pattern_validation.gd index 2ba3c6e..07efe54 100644 --- a/tests/fur_pattern_validation.gd +++ b/tests/fur_pattern_validation.gd @@ -288,9 +288,9 @@ func _validate_fur_color_ui(snapshot: Dictionary) -> void: var picker_popup := picker.get_popup() var picker_control := picker.get_picker() assert(color_panel != null and color_panel.size.x > 360.0) - assert(channel_grid != null and channel_grid.columns == 4) + assert(channel_grid != null and channel_grid.columns == 2) assert(channel_grid.get_child_count() == 4) - assert(palette_grid != null and palette_grid.columns == 10) + assert(palette_grid != null and palette_grid.columns == 6) assert(palette_grid.get_child_count() == 30) for option_button: Button in palette_grid.get_children(): assert( diff --git a/tests/inventory_notepad_art_validation.gd b/tests/inventory_notepad_art_validation.gd index 2a526e4..4a021b7 100644 --- a/tests/inventory_notepad_art_validation.gd +++ b/tests/inventory_notepad_art_validation.gd @@ -1,14 +1,17 @@ extends SceneTree const PlayerMenuScene = preload("res://ui/player_menu.tscn") +const HotbarScene = preload("res://ui/hotbar.tscn") const UIReferencePresentationType = preload( "res://ui/ui_reference_presentation.gd" ) const EXPECTED_HOST_SIZE := Vector2(278.0, 484.0) const EXPECTED_ART_SIZE := Vector2(306.28125, 580.8) const EXPECTED_ART_POSITION := Vector2(-4.140625, -28.4) -const INVENTORY_PANEL_RECT := Rect2(54.0, 166.0, 882.0, 484.0) -const NOTEPAD_HOST_RECT := Rect2(952.0, 166.0, 278.0, 484.0) +const LEGACY_COOLER_PANEL_RECT := Rect2(54.0, 166.0, 882.0, 484.0) +const LEGACY_COOLER_NOTEPAD_RECT := Rect2(952.0, 166.0, 278.0, 484.0) +const INVENTORY_PANEL_RECT := Rect2(199.0, 166.0, 882.0, 484.0) +const NOTEPAD_HOST_RECT := Rect2(501.0, 166.0, 278.0, 484.0) const NOTEPAD_ART_RECT := Rect2( NOTEPAD_HOST_RECT.position + EXPECTED_ART_POSITION, EXPECTED_ART_SIZE, @@ -57,6 +60,11 @@ func _run() -> void: + InventoryNotepad.NOTEPAD_ART_OFFSET )) _validate_shared_inventory_geometry(player_menu) + _validate_first_open_inventory(player_menu) + await _validate_inventory_grid_centering(player_menu, presentation_stage) + _validate_utility_page_geometry(player_menu) + await _validate_profile_content_bounds(player_menu) + await _validate_hotbar_centering(presentation_stage) _validate_inventory_layering(player_menu) _validate_tackle_to_items_transition(player_menu) _validate_cooler_notepad_typography(player_menu) @@ -103,23 +111,151 @@ func _apply_stage_layout(stage: Control, display_size: Vector2) -> void: func _validate_shared_inventory_geometry(player_menu: PlayerMenu) -> void: for node_name: StringName in [ - &"CoolerOuterWall", &"BagOuterWall", &"TackleMainPanel", ]: var main_panel := player_menu.get_node("%%%s" % node_name) as Control assert(main_panel != null) assert(main_panel.position.is_equal_approx(INVENTORY_PANEL_RECT.position)) - assert(main_panel.size.is_equal_approx(INVENTORY_PANEL_RECT.size)) + assert( + main_panel.size.is_equal_approx(INVENTORY_PANEL_RECT.size), + "%s was %s, expected %s" % [ + node_name, + main_panel.size, + INVENTORY_PANEL_RECT.size, + ], + ) + var cooler_panel := player_menu.get_node("%CoolerOuterWall") as Control + var cooler_notepad := player_menu.get_node("%DetailConstellation") as Control + assert(cooler_panel.position.is_equal_approx( + LEGACY_COOLER_PANEL_RECT.position + )) + assert(cooler_panel.size.is_equal_approx(LEGACY_COOLER_PANEL_RECT.size)) + assert(cooler_notepad.position.is_equal_approx( + LEGACY_COOLER_NOTEPAD_RECT.position + )) + assert(cooler_notepad.size.is_equal_approx( + LEGACY_COOLER_NOTEPAD_RECT.size + )) for node_name: StringName in [ - &"DetailConstellation", &"BagDetailConstellation", &"TackleDetailPanel", ]: var host := player_menu.get_node("%%%s" % node_name) as Control assert(host != null) - assert(host.position.is_equal_approx(Vector2(952.0, 166.0))) + assert(host.position.is_equal_approx(NOTEPAD_HOST_RECT.position)) assert(host.size.is_equal_approx(EXPECTED_HOST_SIZE)) + assert(is_equal_approx(INVENTORY_PANEL_RECT.get_center().x, 640.0)) + + +func _validate_first_open_inventory(player_menu: PlayerMenu) -> void: + var legacy_slots: Array = player_menu.get("_bag_slot_nodes") as Array + assert(legacy_slots.is_empty()) + var old_slot_nodes: Array[Node] = player_menu.find_children( + "*", "BagStorageSlot", true, false + ) + assert(old_slot_nodes.is_empty()) + + +func _validate_inventory_grid_centering( + player_menu: PlayerMenu, + stage: Control, +) -> void: + var grid := player_menu.get("_general_inventory_grid") as Control + assert(grid != null) + grid.custom_minimum_size = Vector2(782.0, 342.0) + grid.size = grid.custom_minimum_size + player_menu.call("_layout_general_inventory_grid") + await process_frame + assert(is_equal_approx(grid.position.y, 39.0)) + assert( + is_equal_approx( + grid.get_global_rect().get_center().x, + stage.get_global_rect().get_center().x, + ), + "inventory center %s did not match stage center %s" % [ + grid.get_global_rect().get_center().x, + stage.get_global_rect().get_center().x, + ], + ) + + +func _validate_utility_page_geometry(player_menu: PlayerMenu) -> void: + for page_name: StringName in [ + &"TheNetPage", + &"MailPage", + &"ProfilePage", + &"PlayersPage", + ]: + var page := player_menu.get_node("%%%s" % page_name) as Control + assert(page != null) + var shell := page.find_child( + "UtilityMainBox", true, false + ) as Control + assert(shell != null) + assert(shell.position.is_equal_approx(UtilityPageStyle.LAPTOP_RECT.position)) + assert(shell.size.is_equal_approx(UtilityPageStyle.LAPTOP_RECT.size)) + assert(is_equal_approx(shell.get_rect().get_center().x, 640.0)) + + +func _validate_profile_content_bounds(player_menu: PlayerMenu) -> void: + var profile := player_menu.get_node("%ProfilePage") as ProfilePage + assert(profile != null) + var menu_was_visible: bool = player_menu.visible + var profile_was_visible: bool = profile.visible + player_menu.visible = true + profile.visible = true + await process_frame + await process_frame + var option_list := profile.get("_option_list") as Control + var body := option_list.get_parent() as Control + var preview := profile.get("_preview") as Control + var shell := profile.find_child("UtilityMainBox", true, false) as Control + assert(option_list != null and body != null and preview != null and shell != null) + for category_id: String in ["fur_pattern", "voice"]: + profile.call("_select_category", category_id) + await process_frame + await process_frame + assert( + body.get_combined_minimum_size().x <= body.size.x, + "%s customization content overflowed its body: %s > %s" % [ + category_id, + body.get_combined_minimum_size().x, + body.size.x, + ], + ) + assert( + preview.get_global_rect().end.x + <= shell.get_global_rect().end.x, + "%s preview overflowed the utility content area" % category_id, + ) + profile.visible = profile_was_visible + player_menu.visible = menu_was_visible + + +func _validate_hotbar_centering(parent: Control) -> void: + var hotbar := HotbarScene.instantiate() as HotbarUI + parent.add_child(hotbar) + await process_frame + var presentation := hotbar.get_node( + "%HotbarPresentationScaleRoot" + ) as Control + var field := hotbar.get_node("%BubbleField") as Control + hotbar.set_player_menu_context(true) + var displayed_center_x: float = ( + presentation.position.x + + field.get_rect().get_center().x * presentation.scale.x + ) + assert(is_equal_approx(displayed_center_x, 640.0)) + var displayed_top: float = ( + presentation.position.y + field.position.y * presentation.scale.y + ) + var displayed_bottom: float = ( + displayed_top + field.size.y * presentation.scale.y + ) + assert(displayed_top < INVENTORY_PANEL_RECT.end.y) + assert(displayed_bottom > INVENTORY_PANEL_RECT.end.y) + hotbar.queue_free() func _validate_inventory_layering(player_menu: PlayerMenu) -> void: @@ -136,13 +272,31 @@ func _validate_inventory_layering(player_menu: PlayerMenu) -> void: assert(cooler_panel != null) assert(tackle_panel != null) assert(inventory_tabs != null) - assert(items_tab != null and items_tab.text == "Items") - assert(bait_list != null and bait_list.columns == 3) - assert(lure_list != null and lure_list.columns == 3) + assert(items_tab != null and not items_tab.visible) + assert(not (player_menu.get_node("%CoolerSubTab") as Button).visible) + assert((player_menu.get_node("%BagSubTab") as Button).text == "Inventory") + assert(bait_list != null and bait_list.columns == 4) + assert(lure_list != null and lure_list.columns == 4) assert(bait_list.get_theme_constant("h_separation") == 28) assert(lure_list.get_theme_constant("h_separation") == 28) assert(sale_confirmation.z_index > cooler_panel.z_index) assert(sale_confirmation.z_index > tackle_panel.z_index) + assert( + (player_menu.get_node("%BagDetailConstellation") as Control).z_index + > bag_panel.z_index + ) + assert( + (player_menu.get_node("%BagModalBlocker") as Control).z_index + > bag_panel.z_index + ) + assert( + (player_menu.get_node("%BagDetailConstellation") as Control).z_index + > (player_menu.get_node("%BagModalBlocker") as Control).z_index + ) + assert( + (player_menu.get_node("%TackleDetailPanel") as Control).z_index + > tackle_panel.z_index + ) # The contextual Hotbar uses z=90 while the Player Menu is open. assert(sale_confirmation.z_index > 90) assert(sale_confirmation.position.is_equal_approx(Vector2(380.0, 265.0))) @@ -159,25 +313,55 @@ func _validate_inventory_layering(player_menu: PlayerMenu) -> void: func _validate_tackle_to_items_transition(player_menu: PlayerMenu) -> void: - var empty_state := player_menu.get_node("%BagEmptyState") as Label - player_menu.set("_bag_view", PlayerMenu.BagView.EQUIPMENT) - player_menu.call("_refresh_bag") - assert(empty_state.text == "No equipment in your Bag.") + var inventory_notepad := player_menu.get_node( + "%BagDetailBubble" + ) as InventoryNotepad + assert(inventory_notepad != null) + assert(inventory_notepad.title_text == "inventory notes") player_menu.call( "_show_section_immediate", PlayerMenu.Section.TACKLE_BOX ) - player_menu.call("_show_bag_view", PlayerMenu.BagView.CONSUMABLES) - assert( - player_menu.get("_current_section") == PlayerMenu.Section.BAG - ) - assert( - player_menu.get("_bag_view") == PlayerMenu.BagView.CONSUMABLES - ) - assert(empty_state.text == "No items in your Bag.") + player_menu.call("_show_inventory_tab", 0) player_menu.call("_cancel_page_tween") - player_menu.call( - "_show_section_immediate", PlayerMenu.Section.COOLER + player_menu.call("_show_section_immediate", PlayerMenu.Section.BAG) + player_menu.call("_update_bag_detail") + assert(player_menu.get("_current_section") == PlayerMenu.Section.BAG) + assert(not (player_menu.get_node("%BagDetailConstellation") as Control).visible) + assert( + (player_menu.get_node("%BagSpriteDetailData") as Label).text + == "select an item for details." ) + player_menu.visible = true + player_menu.call("_set_content_interactive", true) + var no_actions: Array[BaseButton] = [] + player_menu.call( + "_open_inventory_notepad", + PlayerMenu.Section.BAG, + StringName("modal-test"), + no_actions, + ) + assert((player_menu.get_node("%BagDetailConstellation") as Control).visible) + assert((player_menu.get_node("%BagModalBlocker") as Control).visible) + assert( + (player_menu.get_node("%BagModalBlocker") as Control).mouse_filter + == Control.MOUSE_FILTER_STOP + ) + assert( + (player_menu.get_node("%InventoryTab") as Button).focus_mode + == Control.FOCUS_NONE + ) + assert( + (player_menu.get_node("%BagSpriteDetailData") as Label).get_theme_font( + "font" + ) == InventoryNotepad.NOTEPAD_FONT + ) + player_menu.call( + "_release_controller_ownership", false, false + ) + assert(not (player_menu.get_node("%BagModalBlocker") as Control).visible) + player_menu.call("_set_content_interactive", false) + player_menu.visible = false + player_menu.call("_cancel_page_tween") func _validate_cooler_notepad_typography(player_menu: PlayerMenu) -> void: @@ -314,17 +498,16 @@ func _capture_inventory_pages(player_menu: PlayerMenu) -> void: return player_menu.visible = true var sections: Array[PlayerMenu.Section] = [ - PlayerMenu.Section.COOLER, PlayerMenu.Section.BAG, PlayerMenu.Section.TACKLE_BOX, ] - var suffixes: Array[String] = ["cooler", "equipment", "tackle"] + var suffixes: Array[String] = ["inventory", "tackle"] for index: int in sections.size(): player_menu.call("_show_section_immediate", sections[index]) await process_frame await process_frame await _save_capture(suffixes[index]) - player_menu.call("_show_section_immediate", PlayerMenu.Section.COOLER) + player_menu.call("_show_section_immediate", PlayerMenu.Section.BAG) var sale_confirmation := player_menu.get_node("%SaleConfirmation") as Control var confirmation_message := player_menu.get_node( "%ConfirmationMessage" diff --git a/tests/inventory_storage_validation.gd b/tests/inventory_storage_validation.gd index b3438f0..eccb66d 100644 --- a/tests/inventory_storage_validation.gd +++ b/tests/inventory_storage_validation.gd @@ -1,31 +1,7 @@ extends SceneTree -const BagItemSpriteType = preload( - "res://ui/components/bubble_menu/bag_item_sprite.gd" -) -const BagStorageSlotType = preload( - "res://ui/components/bubble_menu/bag_storage_slot.gd" -) -const Catalog: ItemCatalog = preload( - "res://items/catalog/item_catalog.tres" -) -const OwnedItemType = preload("res://items/owned_item.gd") -const PlayerBagType = preload("res://inventory/player_bag.gd") -const PlayerHotbarType = preload("res://inventory/player_hotbar.gd") -const PlayerMenuScene = preload("res://ui/player_menu.tscn") -const PlayerMenuType = preload("res://ui/player_menu.gd") - -const EQUIPMENT_IDS: Array[StringName] = [ - &"basic_fishing_rod", - &"art_kit", - &"crab_net", - &"magnet", -] -const ITEM_IDS: Array[StringName] = [ - &"coffee", - &"energy_drink", - &"snack", -] +const Catalog: ItemCatalog = preload("res://items/catalog/item_catalog.tres") +const Bluegill: FishData = preload("res://fish/species/bluegill/bluegill.tres") func _initialize() -> void: @@ -33,140 +9,119 @@ func _initialize() -> void: func _run() -> void: - root.size = Vector2i(1280, 720) - var bag := PlayerBagType.new() + var state := Node.new() + root.add_child(state) + var bag := PlayerBag.new() + var catches := FishInventory.new() + var storage_capacity := PlayerCoolerCapacity.new() + var layout := PlayerInventoryLayout.new() + var hotbar := PlayerHotbar.new() + for node: Node in [bag, catches, storage_capacity, layout, hotbar]: + state.add_child(node) bag.setup(Catalog) - for item_id: StringName in EQUIPMENT_IDS + ITEM_IDS: - assert(bag.add_item(item_id)) - for index: int in EQUIPMENT_IDS.size(): - assert(bag.get_storage_slot(EQUIPMENT_IDS[index]) == index) - for index: int in ITEM_IDS.size(): - assert(bag.get_storage_slot(ITEM_IDS[index]) == index) + layout.setup(bag, catches, Catalog, storage_capacity) + bag.set_inventory_layout(layout) + catches.set_inventory_layout(layout) + hotbar.setup(bag, Catalog, catches, layout) - assert(bag.move_item_to_storage_slot(&"basic_fishing_rod", 14)) - assert(bag.get_storage_slot(&"basic_fishing_rod") == 14) - assert(bag.move_item_to_storage_slot(&"crab_net", 14)) - assert(bag.get_storage_slot(&"crab_net") == 14) - assert(bag.get_storage_slot(&"basic_fishing_rod") == 2) - var saved_record: Dictionary = bag.get_owned_item( - &"crab_net" - ).to_save_dict() - assert(int(saved_record.get("storage_slot", -1)) == 14) + assert(bag.add_item(&"basic_fishing_rod", 1)) + assert(bag.add_item(&"coffee", 3)) + var fish_catch := _make_bluegill() + assert(catches.add_catch(fish_catch)) - var legacy_equipment := OwnedItemType.new() - legacy_equipment.item_id = &"basic_fishing_rod" - var legacy_item := OwnedItemType.new() - legacy_item.item_id = &"coffee" - var legacy_records: Array[OwnedItemType] = [ - legacy_equipment, - legacy_item, - ] - var legacy_bag := PlayerBagType.new() - legacy_bag.setup(Catalog) - assert(legacy_bag.replace_all_items(legacy_records)) - assert(legacy_bag.get_storage_slot(&"basic_fishing_rod") == 0) - assert(legacy_bag.get_storage_slot(&"coffee") == 0) - legacy_bag.free() - - var menu := PlayerMenuScene.instantiate() as PlayerMenu - root.add_child(menu) - await process_frame - menu.set("_bag", bag) - var hotbar := PlayerHotbarType.new() - hotbar.setup(bag, Catalog) - menu.set("_hotbar", hotbar) - menu.set("_item_catalog", Catalog) - menu.set("_bag_view", PlayerMenuType.BagView.EQUIPMENT) - menu.visible = true - menu.call("_show_section_immediate", PlayerMenuType.Section.BAG) - menu.call("_set_content_interactive", true) - menu.call("_refresh_bag") - await process_frame - _validate_grid(menu, EQUIPMENT_IDS.size()) - - var item_nodes: Dictionary = menu.get("_bag_item_nodes") - var crab_node := item_nodes.get(&"crab_net") as BagItemSpriteType - assert(crab_node != null) - menu.set( - "_controller_ownership", - PlayerMenuType.ControllerOwnership.ITEM_LIST, + var inventory_grid := GeneralInventoryGrid.new() + inventory_grid.set_slot_presentation(Vector2(78.0, 78.0), 10) + root.add_child(inventory_grid) + inventory_grid.setup( + layout, + bag, + catches, + hotbar, + Catalog, + PlayerInventoryLayout.InventoryContainer.INVENTORY, ) - menu.call("_apply_inventory_controller_zone_focus_modes") - crab_node.grab_focus() - assert(bool(menu.call("_try_begin_controller_storage_placement"))) - await process_frame - assert( - menu.get("_controller_ownership") - == PlayerMenuType.ControllerOwnership.STORAGE_PLACEMENT - ) - var slots: Array = menu.get("_bag_slot_nodes") - var bottom_slot := slots[12] as BagStorageSlotType - bottom_slot.grab_focus() - var down := InputEventAction.new() - down.action = &"ui_down" - down.pressed = true - assert(bool(menu.call("_handle_controller_ownership_input", down))) - assert( - menu.get("_controller_ownership") - == PlayerMenuType.ControllerOwnership.HOTBAR_PLACEMENT - ) - var up := InputEventAction.new() - up.action = &"ui_up" - up.pressed = true - assert(bool(menu.call("_handle_controller_ownership_input", up))) - assert( - menu.get("_controller_ownership") - == PlayerMenuType.ControllerOwnership.STORAGE_PLACEMENT + var storage_grid := GeneralInventoryGrid.new() + root.add_child(storage_grid) + storage_grid.setup( + layout, + bag, + catches, + hotbar, + Catalog, + PlayerInventoryLayout.InventoryContainer.STORAGE, ) await process_frame - assert(root.gui_get_focus_owner() == bottom_slot) - assert(StringName(menu.get("_controller_storage_identity")) == &"crab_net") - var target_slot := slots[7] as BagStorageSlotType - target_slot.grab_focus() - menu.call("_confirm_controller_storage_placement") - await process_frame - assert(bag.get_storage_slot(&"crab_net") == 7) - assert( - menu.get("_controller_ownership") - == PlayerMenuType.ControllerOwnership.ITEM_LIST + + var inventory_slots: Array = inventory_grid.get("_slots") + var storage_slots: Array = storage_grid.get("_slots") + assert(inventory_slots.size() == 36) + assert(inventory_grid.get_slots().size() == 9) + assert(inventory_grid.custom_minimum_size == Vector2(782.0, 342.0)) + assert(storage_capacity.get_capacity() == 9) + assert(storage_slots.size() == 72) + assert(storage_grid.get_slots().size() == 9) + assert((storage_slots[0] as Control).custom_minimum_size == Vector2(52.0, 52.0)) + assert((storage_slots[9] as GeneralInventorySlot).disabled) + + var coffee_slot := _find_slot(inventory_slots, &"coffee") + assert(coffee_slot != null) + (inventory_slots[7] as GeneralInventorySlot).call( + "_drop_data", + Vector2.ZERO, + {"kind": "bag_item", "item_id": "coffee"}, ) + assert(int(layout.get_entry( + PlayerInventoryLayout.EntryKind.ITEM, &"coffee" + ).get("slot", -1)) == 7) + assert(coffee_slot.entry_identity.is_empty()) - menu.call("_show_bag_view", PlayerMenuType.BagView.CONSUMABLES) - await process_frame - _validate_grid(menu, ITEM_IDS.size()) - menu.call("_on_bag_item_dropped", &"coffee", 12) - await process_frame - assert(bag.get_storage_slot(&"coffee") == 12) + (storage_slots[0] as GeneralInventorySlot).call( + "_drop_data", + Vector2.ZERO, + {"kind": "cooler_fish", "catch_id": String(fish_catch.catch_id)}, + ) + assert(layout.get_container( + PlayerInventoryLayout.EntryKind.CATCH, fish_catch.catch_id + ) == PlayerInventoryLayout.InventoryContainer.STORAGE) + + assert(hotbar.assign_item(0, &"basic_fishing_rod")) + assert(not layout.is_item_in_inventory(&"basic_fishing_rod")) + (inventory_slots[8] as GeneralInventorySlot).call( + "_drop_data", + Vector2.ZERO, + {"kind": "hotbar_slot", "slot_index": 0}, + ) + assert(hotbar.get_item_id(0).is_empty()) + assert(layout.is_item_in_inventory(&"basic_fishing_rod")) + assert(int(layout.get_entry( + PlayerInventoryLayout.EntryKind.ITEM, &"basic_fishing_rod" + ).get("slot", -1)) == 8) - menu.queue_free() - hotbar.free() - bag.free() - await process_frame print("Inventory storage validation: PASS") + state.free() + inventory_grid.free() + storage_grid.free() quit() -func _validate_grid(menu: PlayerMenu, expected_items: int) -> void: - var slots: Array = menu.get("_bag_slot_nodes") - assert(slots.size() == PlayerBagType.MIN_STORAGE_SLOT_COUNT) - var seen_positions: Dictionary[Vector2, bool] = {} - for index: int in slots.size(): - var slot := slots[index] as BagStorageSlotType - assert(slot != null) - assert(slot.storage_slot_index == index) - assert(not seen_positions.has(slot.position)) - seen_positions[slot.position] = true - var normal := slot.get_theme_stylebox("normal") as StyleBoxFlat - assert(normal != null) - assert(normal.bg_color.a >= 0.7) - assert((slots[1] as Control).position.x > (slots[0] as Control).position.x) - assert((slots[5] as Control).position.y > (slots[0] as Control).position.y) - var item_nodes := menu.get("_bag_item_nodes") as Dictionary - assert( - item_nodes.size() == expected_items, - "expected %d visible bag items, found %d: %s" % [ - expected_items, - item_nodes.size(), - str(item_nodes.keys()), - ], +func _make_bluegill() -> FishCatch: + var fish_catch := FishCatch.new() + fish_catch.fish = Bluegill + fish_catch.fish_id = Bluegill.id + fish_catch.catch_id = &"bluegill:inventory_storage_test" + fish_catch.weight_lb = Bluegill.get_minimum_weight() + fish_catch.display_scale = Bluegill.get_display_scale_for_weight( + fish_catch.weight_lb ) + fish_catch.sale_value = Bluegill.get_sale_value_for_weight( + fish_catch.weight_lb + ) + return fish_catch + + +func _find_slot(slots: Array, identity: StringName) -> GeneralInventorySlot: + for candidate: Variant in slots: + var slot := candidate as GeneralInventorySlot + if slot != null and slot.entry_identity == identity: + return slot + return null diff --git a/tests/job_system_validation.gd b/tests/job_system_validation.gd index 32fe10e..81d97e2 100644 --- a/tests/job_system_validation.gd +++ b/tests/job_system_validation.gd @@ -203,7 +203,7 @@ func _run() -> void: save_file.close() assert(typeof(parsed) == TYPE_DICTIONARY) var save_data: Dictionary = parsed - assert(int(save_data.get("save_version", -1)) == 7) + assert(int(save_data.get("save_version", -1)) == 8) assert(PlayerJobService.validate_save_data(save_data.get("jobs", {}))) _validate_pause_session_switch(main, session) diff --git a/tests/logbook_runtime_validation.gd b/tests/logbook_runtime_validation.gd index d1367de..a57311a 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() == 314) + assert(catalog.candidates.size() == 316) var active_species := LogbookCatalog.ordered_species(catalog.candidates) - assert(active_species.size() == 54) + assert(active_species.size() == 56) 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() == 54) + assert(player.inventory.get_all_catches().size() == 56) 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 0d98609..bf1e468 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() == 314) + assert(CatalogResource.candidates.size() == 316) var ordered := LogbookCatalog.ordered_species(CatalogResource.candidates) - assert(ordered.size() == 54) + assert(ordered.size() == 56) var previous_number: int = 0 var catalog_numbers: Dictionary[int, bool] = {} for fish: FishDataType in CatalogResource.candidates: @@ -113,6 +113,10 @@ func _validate_page() -> void: LogbookCatalog.Category.OTHER, ]) assert(category_tabs.size() == category_tab_categories.size()) + assert( + LogbookCatalog.category_label(LogbookCatalog.Category.OTHER) + == "Insects" + ) for tab_node: Variant in category_tabs: var category_tab := tab_node as Button assert(category_tab.size == LogbookPage.LOGBOOK_TAB_SIZE) @@ -203,16 +207,18 @@ func _validate_page() -> void: 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).size() == 2) assert( (page.get("_entry_buttons") as Dictionary).has(&"unknown_3906") ) + assert( + (page.get("_entry_buttons") as Dictionary).has(&"unknown_6406") + ) 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("_entry_buttons") as Dictionary).size() == 1) assert( - (page.get("_empty_state") as Label).text - == "No entries available." + (page.get("_entry_buttons") as Dictionary).has(&"unknown_8001") ) page.call("_select_category", LogbookCatalog.Category.FRESH_WATER) await create_timer(0.25).timeout diff --git a/tests/player_experience_validation.gd b/tests/player_experience_validation.gd index c9b36cd..2250706 100644 --- a/tests/player_experience_validation.gd +++ b/tests/player_experience_validation.gd @@ -143,7 +143,7 @@ func _validate_save_migration() -> void: version_five, 5, ) - assert(int(migrated.get("save_version", -1)) == 7) + assert(int(migrated.get("save_version", -1)) == 8) var experience_data: Dictionary = migrated.get("experience", {}) assert(int(experience_data.get("total_experience", -1)) == 0) var world_data: Dictionary = migrated.get("world", {}) diff --git a/tests/player_gathering_animation_validation.gd b/tests/player_gathering_animation_validation.gd index 40ca8f6..52b95b2 100644 --- a/tests/player_gathering_animation_validation.gd +++ b/tests/player_gathering_animation_validation.gd @@ -62,10 +62,14 @@ func _run() -> void: assert(StringName(player.get("_animation_action_id")).is_empty()) assert(animation_player.current_animation == &"idle") Input.action_press(&"sneak") + var visuals := player.get_node("Visuals") as Node3D assert(player.play_net_strike_visual()) player.resolve_net_strike_visual(true) player.net_success_contact_pause_duration = 0.05 + player.showcase_turn_duration = 0.05 + player.showcase_camera_transition_duration = 0.05 + player.net_showcase_camera_yaw_offset = 0.0 var crab_catch := FishCatch.new() crab_catch.fish = CrabBrown crab_catch.fish_id = CrabBrown.id @@ -89,9 +93,27 @@ func _run() -> void: await contact_pause.finished assert(StringName(player.get("_animation_action_id")).is_empty()) assert(bool(player.get("_showcase_animation_active"))) + var stored_showcase_rotation: Vector3 = player.get( + "_showcase_visual_rotation" + ) + await create_timer(0.1).timeout + assert( + absf(wrapf( + visuals.rotation.y - stored_showcase_rotation.y, + -PI, + PI, + )) > 3.0 + ) var catch_display := player.get("_catch_display") as Node3D assert(catch_display != null and catch_display.visible) - player.end_catch_showcase(Callable(), true) + player.end_catch_showcase(Callable(), true, true) + assert( + absf(wrapf( + visuals.rotation.y - stored_showcase_rotation.y, + -PI, + PI, + )) < 0.01 + ) Input.action_release(&"sneak") player.queue_free() diff --git a/tests/tackle_order_validation.gd b/tests/tackle_order_validation.gd index 43da0e9..5a38e06 100644 --- a/tests/tackle_order_validation.gd +++ b/tests/tackle_order_validation.gd @@ -45,6 +45,17 @@ func _run() -> void: for owned: OwnedItem in tackle_items: sorted_ids.append(owned.item_id) assert(sorted_ids == EXPECTED_BAIT_ORDER) + var bag := PlayerBag.new() + bag.setup(Catalog) + assert(bag.add_item(&"worms", 1)) + assert(bag.add_item(&"shrimp", 1)) + var worms_slot: int = bag.get_storage_slot(&"worms") + var shrimp_slot: int = bag.get_storage_slot(&"shrimp") + assert(worms_slot >= 0 and shrimp_slot >= 0 and worms_slot != shrimp_slot) + assert(bag.move_item_to_storage_slot(&"worms", shrimp_slot)) + assert(bag.get_storage_slot(&"worms") == shrimp_slot) + assert(bag.get_storage_slot(&"shrimp") == worms_slot) + bag.free() player_menu.free() print("Tackle order validation: PASS") quit() diff --git a/tests/tree_gathering_prototype_validation.gd b/tests/tree_gathering_prototype_validation.gd new file mode 100644 index 0000000..d97f67e --- /dev/null +++ b/tests/tree_gathering_prototype_validation.gd @@ -0,0 +1,99 @@ +extends SceneTree + +const StarterIslandScene = preload( + "res://world/regions/starter_island_region.tscn" +) +const Gatherables: GatherableCatalog = preload( + "res://gathering/catalog/gatherable_catalog.tres" +) +const FishCatalog: FishPool = preload("res://fish/pools/fish_catalog.tres") + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _validate_beetle_data() + await _validate_tree_anchors() + _validate_anchored_presentation() + _validate_three_dimensional_targeting() + print("Tree gathering prototype validation: PASS") + quit() + + +func _validate_beetle_data() -> void: + var beetle: GatherableData = Gatherables.get_entry(&"beetle_stag_common") + assert(beetle != null and beetle.is_available()) + assert(beetle.catch_data == FishCatalog.get_fish_by_id(&"beetle_stag_common")) + assert(beetle.catch_data.display_texture != null) + assert(beetle.catch_data.collection_method == FishData.CollectionMethod.NET) + assert(beetle.catch_data.collection_group == &"Beetles") + assert(beetle.required_tool_id == &"crab_net") + assert(beetle.spawn_anchor_set_id == &"starter_reachable_tree_trunks") + assert(beetle.population == 3) + assert(is_equal_approx(beetle.sprite_pixel_size, 0.005)) + assert(beetle.is_stationary_spawn()) + assert(not beetle.can_be_scared()) + + +func _validate_tree_anchors() -> void: + var region := StarterIslandScene.instantiate() as WorldRegion + root.add_child(region) + await process_frame + var anchor_set: GatherableAnchorSet3D = region.get_gatherable_anchor_set( + &"starter_reachable_tree_trunks" + ) + assert(anchor_set != null) + var positions: PackedVector3Array = anchor_set.get_spawn_positions() + assert(positions.size() == 8) + for position: Vector3 in positions: + assert(position.is_finite()) + assert(position.y >= 4.3 and position.y <= 4.5) + region.queue_free() + + +func _validate_anchored_presentation() -> void: + var beetle: GatherableData = Gatherables.get_entry(&"beetle_stag_common") + var presentation := WorldGatherable.new() + root.add_child(presentation) + presentation.configure("beetle-visual", beetle, Vector3.ZERO, 0.0) + var sprite := presentation.get_node("GatherableSprite") as Sprite3D + assert(sprite != null) + assert(is_zero_approx(sprite.position.y)) + assert(is_equal_approx(sprite.pixel_size, 0.005)) + assert(not sprite.shaded) + assert(sprite.billboard == BaseMaterial3D.BILLBOARD_ENABLED) + assert(sprite.texture_filter == BaseMaterial3D.TEXTURE_FILTER_NEAREST) + presentation.queue_free() + + +func _validate_three_dimensional_targeting() -> void: + var service := NetworkWorldSpawnService.new() + var beetle: GatherableData = Gatherables.get_entry(&"beetle_stag_common") + service.set( + "_entities", + { + "beetle-test": { + "entity_id": "beetle-test", + "type_id": &"beetle_stag_common", + "data": beetle, + "position": Vector3(2.0, 4.4, 3.0), + "locked": false, + }, + }, + ) + assert( + service.find_capture_target( + Vector3(2.0, 4.4, 3.0), + &"crab_net", + ) + == "beetle-test" + ) + assert( + service.find_capture_target( + Vector3(2.0, 3.0, 3.0), + &"crab_net", + ).is_empty() + ) + service.free() diff --git a/tests/tree_gathering_prototype_validation.gd.uid b/tests/tree_gathering_prototype_validation.gd.uid new file mode 100644 index 0000000..defb119 --- /dev/null +++ b/tests/tree_gathering_prototype_validation.gd.uid @@ -0,0 +1 @@ +uid://d0g1xubbi48n diff --git a/tests/unified_inventory_validation.gd b/tests/unified_inventory_validation.gd new file mode 100644 index 0000000..dacc5a0 --- /dev/null +++ b/tests/unified_inventory_validation.gd @@ -0,0 +1,199 @@ +extends SceneTree + +const ItemCatalogResource: ItemCatalog = preload( + "res://items/catalog/item_catalog.tres" +) +const Bluegill: FishData = preload( + "res://fish/species/bluegill/bluegill.tres" +) +const MainShopBuyer: FishBuyerProfile = preload( + "res://economy/buyers/main_fishing_shop.tres" +) +const FishCatalogResource: FishPool = preload( + "res://fish/pools/fish_catalog.tres" +) + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var root := Node.new() + get_root().add_child(root) + var bag := PlayerBag.new() + var catches := FishInventory.new() + var capacity := PlayerCoolerCapacity.new() + var layout := PlayerInventoryLayout.new() + var hotbar := PlayerHotbar.new() + for node: Node in [bag, catches, capacity, layout, hotbar]: + root.add_child(node) + bag.setup(ItemCatalogResource) + layout.setup(bag, catches, ItemCatalogResource, capacity) + bag.set_inventory_layout(layout) + catches.set_inventory_layout(layout) + hotbar.setup(bag, ItemCatalogResource, catches, layout) + assert(PlayerInventoryLayout.INVENTORY_CAPACITIES == [9, 18, 27, 36]) + assert(PlayerCoolerCapacity.CAPACITIES == [9, 18, 27, 36, 45, 54, 63, 72]) + + assert(bag.add_item(&"coffee", 3)) + assert(layout.get_inventory_count() == 1) + assert(layout.get_inventory_capacity() == 9) + assert(layout.get_hotbar_count() == 0) + assert(hotbar.assign_item(2, &"coffee")) + assert(layout.get_inventory_count() == 0) + assert(layout.get_hotbar_count() == 1) + assert( + layout.get_container(PlayerInventoryLayout.EntryKind.ITEM, &"coffee") + == PlayerInventoryLayout.InventoryContainer.HOTBAR + ) + assert(hotbar.clear_slot(2)) + assert(layout.get_inventory_count() == 1) + assert(layout.get_hotbar_count() == 0) + + var fish_catch := FishCatch.new() + fish_catch.fish = Bluegill + fish_catch.fish_id = Bluegill.id + fish_catch.catch_id = &"bluegill:unified_inventory_test" + fish_catch.weight_lb = Bluegill.get_minimum_weight() + fish_catch.display_scale = Bluegill.get_display_scale_for_weight( + fish_catch.weight_lb + ) + fish_catch.sale_value = Bluegill.get_sale_value_for_weight( + fish_catch.weight_lb + ) + assert(catches.add_catch(fish_catch)) + assert(layout.get_inventory_count() == 2) + assert(layout.move_entry_to_first_free( + PlayerInventoryLayout.EntryKind.CATCH, + fish_catch.catch_id, + PlayerInventoryLayout.InventoryContainer.STORAGE, + )) + assert(layout.get_inventory_count() == 1) + assert(layout.get_storage_count() == 1) + assert(layout.get_storage_capacity() == 9) + var inventory_grid := GeneralInventoryGrid.new() + root.add_child(inventory_grid) + inventory_grid.set_slot_presentation(Vector2(78.0, 78.0), 10) + inventory_grid.setup( + layout, + bag, + catches, + hotbar, + ItemCatalogResource, + PlayerInventoryLayout.InventoryContainer.INVENTORY, + ) + var all_inventory_slots: Array = inventory_grid.get("_slots") + assert(all_inventory_slots.size() == 36) + assert(inventory_grid.get_slots().size() == 9) + for slot_index: int in all_inventory_slots.size(): + var slot := all_inventory_slots[slot_index] as GeneralInventorySlot + assert(slot.custom_minimum_size == Vector2(78.0, 78.0)) + assert(slot.disabled == (slot_index >= 9)) + assert(slot.tooltip_text.is_empty()) + var normal := slot.get_theme_stylebox("normal") as StyleBoxFlat + assert(normal != null and normal.corner_radius_top_left == 39) + var locked_slot := all_inventory_slots[9] as GeneralInventorySlot + var locked_icon := locked_slot.get("_icon") as TextureRect + assert(locked_icon != null) + assert(is_equal_approx(locked_icon.size.x, 24.0)) + assert(is_equal_approx(locked_icon.size.y, 24.0)) + assert(is_equal_approx(locked_icon.modulate.a, 0.18)) + var staged_slot := all_inventory_slots[0] as GeneralInventorySlot + staged_slot.set_staged(true) + assert( + (staged_slot.get_theme_stylebox("normal") as StyleBoxFlat).bg_color + == Color(UtilityPageStyle.OCEAN_SELECTED, 0.92) + ) + staged_slot.set_staged(false) + var sale_tray_slot := ShopSaleTraySlot.new() + root.add_child(sale_tray_slot) + assert( + sale_tray_slot.custom_minimum_size + == GeneralInventoryGrid.DEFAULT_SLOT_SIZE + ) + var sale_tray_style := sale_tray_slot.get_theme_stylebox( + "normal" + ) as StyleBoxFlat + assert(sale_tray_style != null) + assert(sale_tray_style.corner_radius_top_left == 26) + var wallet := PlayerWallet.new() + root.add_child(wallet) + assert(wallet.restore_balance(15000)) + assert(layout.get_next_backpack_cost() == 1500) + assert(layout.purchase_backpack(wallet)) + assert(layout.get_inventory_capacity() == 18) + assert(inventory_grid.get_slots().size() == 18) + assert(layout.get_next_backpack_cost() == 4500) + assert(layout.purchase_backpack(wallet)) + assert(layout.get_inventory_capacity() == 27) + assert(inventory_grid.get_slots().size() == 27) + assert(layout.get_next_backpack_cost() == 9000) + assert(layout.purchase_backpack(wallet)) + assert(layout.get_inventory_capacity() == 36) + assert(inventory_grid.get_slots().size() == 36) + assert(layout.get_next_backpack_cost() == -1) + var storage_grid := GeneralInventoryGrid.new() + root.add_child(storage_grid) + storage_grid.setup( + layout, + bag, + catches, + hotbar, + ItemCatalogResource, + PlayerInventoryLayout.InventoryContainer.STORAGE, + ) + var all_storage_slots: Array = storage_grid.get("_slots") + assert(all_storage_slots.size() == 72) + assert(storage_grid.get_slots().size() == 9) + assert( + (all_storage_slots[9] as GeneralInventorySlot).accessibility_name + == "locked storage slot 10" + ) + var storage_wallet := PlayerWallet.new() + root.add_child(storage_wallet) + assert(storage_wallet.restore_balance(9500)) + for expected_capacity: int in [18, 27, 36, 45, 54, 63, 72]: + assert(capacity.purchase(storage_wallet)) + assert(layout.get_storage_capacity() == expected_capacity) + assert(storage_grid.get_slots().size() == expected_capacity) + assert(capacity.get_next_capacity() == -1) + assert(capacity.get_next_cost() == -1) + + var saved := layout.to_save_data() + assert(layout.move_entry_to_first_free( + PlayerInventoryLayout.EntryKind.ITEM, + &"coffee", + PlayerInventoryLayout.InventoryContainer.STORAGE, + )) + assert(layout.restore_from_save_data(saved)) + assert(layout.is_item_in_inventory(&"coffee")) + assert( + layout.get_container( + PlayerInventoryLayout.EntryKind.CATCH, + fish_catch.catch_id, + ) == PlayerInventoryLayout.InventoryContainer.STORAGE + ) + + var network_sale := NetworkSaleService.new() + var session := NetworkSession.new() + root.add_child(session) + root.add_child(network_sale) + network_sale.set("_session", session) + network_sale.set("_item_catalog", ItemCatalogResource) + network_sale.set("_fish_catalog", FishCatalogResource) + var result: Dictionary = network_sale.call( + "_build_authoritative_result", + 1, + "mixed_sale_test", + [], + [{"item_id": "coffee", "quantity": 2}], + MainShopBuyer, + ) + assert(bool(result.get("accepted", false))) + assert(int(result.get("payout", -1)) == 20) + assert((result.get("items", []) as Array).size() == 1) + + print("Unified inventory validation: PASS") + root.free() + quit() diff --git a/tests/unified_inventory_validation.gd.uid b/tests/unified_inventory_validation.gd.uid new file mode 100644 index 0000000..baaf9c3 --- /dev/null +++ b/tests/unified_inventory_validation.gd.uid @@ -0,0 +1 @@ +uid://osuf2xa2bxht diff --git a/tests/world_spawn_multiplayer_validation.gd b/tests/world_spawn_multiplayer_validation.gd index f1001a3..5c9b51c 100644 --- a/tests/world_spawn_multiplayer_validation.gd +++ b/tests/world_spawn_multiplayer_validation.gd @@ -2,7 +2,12 @@ extends SceneTree const MainScene: PackedScene = preload("res://main/main.tscn") const TEST_PORT: int = 18170 -const EXPECTED_POPULATION: int = 2 +const EXPECTED_POPULATION: int = 8 +const EXPECTED_BY_TYPE: Dictionary = { + &"crab_brown": 2, + &"clam_manila": 3, + &"beetle_stag_common": 3, +} func _initialize() -> void: @@ -28,6 +33,7 @@ func _run_host() -> void: assert(session.start_dedicated_host(TEST_PORT, 8, "127.0.0.1")) assert(session.set_host_open(true)) await _wait_for_population(service) + _freeze_timed_entries(service) _validate_population(service, true) var remote_peer_id: int = 0 @@ -83,6 +89,9 @@ func _run_client() -> void: )) await _wait_for_population(service) _validate_population(service, false) + # Give the host process time to observe the authenticated peer before this + # deliberately short client validation disconnects. + await create_timer(0.75).timeout print("World spawn multiplayer client validation: PASS") session.disconnect_session("") main.queue_free() @@ -106,8 +115,10 @@ func _validate_population(service: Node, expect_authoritative_state: bool) -> vo var presentations: Dictionary = service.get("_presentations") assert(entities.size() == EXPECTED_POPULATION) assert(presentations.size() == EXPECTED_POPULATION) + var counts: Dictionary = {} for state: Dictionary in entities.values(): - assert(state.get("type_id") == &"crab_brown") + var type_id := state.get("type_id") as StringName + counts[type_id] = int(counts.get(type_id, 0)) + 1 var position: Variant = state.get("position") assert(typeof(position) == TYPE_VECTOR3) assert((position as Vector3).is_finite()) @@ -120,27 +131,43 @@ func _validate_population(service: Node, expect_authoritative_state: bool) -> vo if expect_authoritative_state: var quality: int = int(state.get("quality", -1)) assert(FishQuality.is_valid(quality)) - assert(entry.get_movement_speed_for_quality(quality) > 0.0) - assert(entry.get_scare_radius_for_quality(quality) > 0.0) + if entry.is_stationary_spawn(): + assert(entry.get_movement_speed_for_quality(quality) <= 0.0) + assert(not entry.can_be_scared()) + else: + assert(entry.get_movement_speed_for_quality(quality) > 0.0) + assert(entry.get_scare_radius_for_quality(quality) > 0.0) + assert(counts == EXPECTED_BY_TYPE) + + +func _freeze_timed_entries(service: Node) -> void: + for state: Dictionary in (service.get("_entities") as Dictionary).values(): + var entry := state.get("data") as GatherableData + if entry != null and entry.active_lifetime_seconds > 0.0: + state["expires_at"] = INF 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: + var crab_ids: Array[String] = [] + for entity_id: String in entities: + var state: Dictionary = entities[entity_id] + if state.get("type_id") == &"crab_brown": + crab_ids.append(entity_id) + assert(crab_ids.size() == 2) + for entity_id: String in crab_ids: service.call( "_despawn_entity", - str(entity_id), + entity_id, &"captured", false, true, ) - assert((service.get("_entities") as Dictionary).is_empty()) + assert((service.get("_entities") as Dictionary).size() == 6) var now: float = Time.get_ticks_msec() / 1000.0 var respawns: Array = service.get("_respawns") - assert(respawns.size() == EXPECTED_POPULATION) + assert(respawns.size() == crab_ids.size()) for index: int in respawns.size(): var respawn: Dictionary = respawns[index] var delay: float = float(respawn.get("due", 0.0)) - now @@ -149,19 +176,20 @@ func _validate_respawn_budget(service: Node) -> void: respawns[index] = respawn service.call("_update_respawns") - assert((service.get("_entities") as Dictionary).size() == 1) + assert((service.get("_entities") as Dictionary).size() == 7) 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("_entities") as Dictionary).size() == 7) 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("_entities") as Dictionary).size() == 8) assert((service.get("_respawns") as Array).is_empty()) + _validate_population(service, true) func _create_initialized_main() -> Node: diff --git a/tests/world_spawn_protocol_validation.gd b/tests/world_spawn_protocol_validation.gd index 0238e3a..9afded4 100644 --- a/tests/world_spawn_protocol_validation.gd +++ b/tests/world_spawn_protocol_validation.gd @@ -52,6 +52,30 @@ func _validate_catalog_statuses() -> void: assert(is_equal_approx(brown.minimum_surface_y, -0.44)) assert(FishCatalog.get_fish_by_id(&"crab_brown") == brown.catch_data) assert(not brown.catch_data.is_fishable()) + var clam: GatherableData = Gatherables.get_entry(&"clam_manila") + assert(clam != null and clam.is_available()) + assert(clam.catch_data.collection_method == FishData.CollectionMethod.DIGGING) + assert(clam.catch_data.logbook_section == FishData.LogbookSection.SHELLFISH) + assert(clam.required_tool_id == &"standard_shovel") + assert(clam.diggable_area_id == &"starter_beach") + assert(clam.presentation_mode == GatherableData.PresentationMode.WATER_SPURT) + assert(clam.is_stationary_hotspot()) + assert(not clam.requires_sneaking) + assert(not clam.can_be_scared()) + assert(is_equal_approx(clam.active_lifetime_seconds, 10.0)) + assert(FishCatalog.get_fish_by_id(&"clam_manila") == clam.catch_data) + assert(not clam.catch_data.is_fishable()) + var beetle: GatherableData = Gatherables.get_entry(&"beetle_stag_common") + assert(beetle != null and beetle.is_available()) + assert(beetle.catch_data.collection_method == FishData.CollectionMethod.NET) + assert(beetle.required_tool_id == &"crab_net") + assert(beetle.spawn_anchor_set_id == &"starter_reachable_tree_trunks") + assert(beetle.is_stationary_spawn()) + assert(not beetle.is_stationary_hotspot()) + assert(not beetle.requires_sneaking) + assert(not beetle.can_be_scared()) + assert(FishCatalog.get_fish_by_id(&"beetle_stag_common") == beetle.catch_data) + assert(not beetle.catch_data.is_fishable()) for type_id: StringName in [ &"crab_ghost", @@ -76,8 +100,10 @@ func _validate_catalog_statuses() -> void: ) var available: Array[GatherableData] = Gatherables.get_available_entries() - assert(available.size() == 1) - assert(available.front() == brown) + assert(available.size() == 3) + assert(available.has(brown)) + assert(available.has(clam)) + assert(available.has(beetle)) var rng := RandomNumberGenerator.new() rng.seed = 24680 var captured_delay: float = brown.get_respawn_delay(&"captured", rng) @@ -111,6 +137,18 @@ func _validate_billboard_presentation() -> void: assert(not sprite.shaded) gatherable.free() + var hotspot := WorldGatherableType.new() + hotspot.call("_ensure_water_spurt_visual") + var hole := hotspot.get_node("WaterSpurt/BurrowMark") as MeshInstance3D + assert(hole != null) + assert(hole.cast_shadow == GeometryInstance3D.SHADOW_CASTING_SETTING_OFF) + var hole_material := hole.mesh.surface_get_material(0) as StandardMaterial3D + assert(hole_material != null) + assert(hole_material.shading_mode == BaseMaterial3D.SHADING_MODE_UNSHADED) + assert(hole_material.transparency == BaseMaterial3D.TRANSPARENCY_DISABLED) + assert(is_equal_approx(hole_material.albedo_color.a, 1.0)) + hotspot.queue_free() + func _validate_quality_behavior(entry: GatherableData) -> void: var prior_speed: float = -1.0 diff --git a/tests/world_time_multiplayer_validation.gd b/tests/world_time_multiplayer_validation.gd index cebb0eb..73659a7 100644 --- a/tests/world_time_multiplayer_validation.gd +++ b/tests/world_time_multiplayer_validation.gd @@ -31,19 +31,10 @@ func _run_host() -> void: main.get_node("%WorldWeatherService") as WorldWeatherService ) assert(session.start_private_host(TEST_PORT)) - var game_ui := main.get_node("%GameUI") as CanvasLayer - var chat_ui := game_ui.get_node("%ChatUI") as ChatUI - assert(chat_ui.call("_handle_chat_command", "/weather clear")) - assert(world_weather.get_weather() == WorldWeatherService.Weather.SUNNY) - world_time.synchronize_time(INITIAL_HOST_TIME) - assert(is_equal_approx( - world_time.get_persistent_time_hours(), INITIAL_HOST_TIME + assert(world_time.set_authoritative_time(INITIAL_HOST_TIME)) + assert(world_weather.set_authoritative_weather( + WorldWeatherService.Weather.RAINY )) - assert(chat_ui.call("_handle_chat_command", "/weather rainy")) - assert( - world_weather.get_persistent_weather() - == WorldWeatherService.Weather.RAINY - ) assert(session.set_host_open(true)) var remote_peer_id: int = 0 @@ -61,35 +52,12 @@ func _run_host() -> void: assert(session.peer_supports_capability( remote_peer_id, NetworkProtocol.WORLD_WEATHER_CAPABILITY )) - var remote_record: PeerRegistry.PeerRecord = session.get_peer_record( - remote_peer_id - ) - assert(remote_record != null and remote_record.identity_authenticated) - assert(session.set_peer_operator( - remote_peer_id, - remote_record.identity_fingerprint, - true, - )) assert(world_time.get_phase() == WorldTimeService.Phase.DUSK) - - var command_deadline: int = Time.get_ticks_msec() + 10000 - while ( - Time.get_ticks_msec() < command_deadline - and _wrapped_time_difference( - world_time.get_time_hours(), UPDATED_HOST_TIME - ) > TIME_TOLERANCE_HOURS - ): - await process_frame - assert(is_equal_approx( - world_time.get_persistent_time_hours(), UPDATED_HOST_TIME + await create_timer(1.0).timeout + assert(world_time.set_authoritative_time(UPDATED_HOST_TIME)) + assert(world_weather.set_authoritative_weather( + WorldWeatherService.Weather.FOGGY )) - var fog_deadline: int = Time.get_ticks_msec() + 10000 - while Time.get_ticks_msec() < fog_deadline and not world_weather.is_foggy(): - await process_frame - assert( - world_weather.get_persistent_weather() - == WorldWeatherService.Weather.FOGGY - ) var disconnect_deadline: int = Time.get_ticks_msec() + 12000 while ( Time.get_ticks_msec() < disconnect_deadline @@ -131,12 +99,6 @@ func _run_client() -> void: assert(session.supports_server_capability( NetworkProtocol.WORLD_WEATHER_CAPABILITY )) - assert(world_time.restore_persistent_time_hours(15.25)) - assert(world_weather.restore_persistent_state( - WorldWeatherService.Weather.CLOUDY, - 222.0, - )) - var initial_deadline: int = Time.get_ticks_msec() + 8000 while ( Time.get_ticks_msec() < initial_deadline @@ -170,11 +132,7 @@ func _run_client() -> void: assert(is_equal_approx(clock_panel.position.y, 10.0)) assert(is_equal_approx(weather_icon.position.y, 10.0)) assert(clock_panel.position.y + clock_panel.size.y < chat_panel.position.y) - var operator_deadline: int = Time.get_ticks_msec() + 10000 - while Time.get_ticks_msec() < operator_deadline and not session.is_local_operator(): - await process_frame - assert(session.is_local_operator()) - assert(chat_ui.call("_handle_chat_command", "/time night")) + assert(not chat_ui.has_method("_handle_chat_command")) var update_deadline: int = Time.get_ticks_msec() + 10000 while ( @@ -188,10 +146,7 @@ func _run_client() -> void: world_time.get_time_hours(), UPDATED_HOST_TIME ) <= TIME_TOLERANCE_HOURS) assert(world_time.get_phase() == WorldTimeService.Phase.NIGHT) - assert(is_equal_approx(world_time.get_persistent_time_hours(), 15.25)) assert(clock_label.text == world_time.get_clock_text()) - await create_timer(0.6).timeout - assert(chat_ui.call("_handle_chat_command", "/weather foggy")) var fog_deadline: int = Time.get_ticks_msec() + 8000 while ( Time.get_ticks_msec() < fog_deadline @@ -200,14 +155,6 @@ func _run_client() -> void: await process_frame assert(world_weather.is_foggy()) assert(weather_icon.get_weather() == WorldWeatherService.Weather.FOGGY) - assert( - world_weather.get_persistent_weather() - == WorldWeatherService.Weather.CLOUDY - ) - assert(is_equal_approx( - world_weather.get_persistent_seconds_remaining(), - 222.0, - )) chat_ui.call("set_dock_right", true) await process_frame assert(clock_panel.position.x > 1000.0) diff --git a/tests/world_time_validation.gd b/tests/world_time_validation.gd index b043718..6b91301 100644 --- a/tests/world_time_validation.gd +++ b/tests/world_time_validation.gd @@ -29,6 +29,7 @@ func _initialize() -> void: func _run() -> void: _validate_clock_boundaries_and_duration() + _validate_system_clock_authority() _validate_persistent_host_clock() _validate_fishing_availability() _validate_fishing_spot_context() @@ -40,7 +41,34 @@ func _run() -> void: func _validate_clock_boundaries_and_duration() -> void: - assert(WorldTimeServiceType.REAL_SECONDS_PER_CYCLE == 3600.0) + assert(WorldTimeServiceType.REAL_SECONDS_PER_CYCLE == 86400.0) + assert(is_equal_approx( + WorldTimeServiceType.HOURS_PER_REAL_SECOND, + 1.0 / 3600.0, + )) + assert(is_equal_approx( + WorldTimeServiceType.time_hours_from_datetime({ + "hour": 17, "minute": 30, "second": 18, + }), + 17.505, + )) + assert( + WorldTimeServiceType.date_id_from_datetime({ + "year": 2026, "month": 8, "day": 18, + }) == "2026-08-18" + ) + assert( + WorldTimeServiceType.calendar_cycle_id_from_datetime({ + "year": 2026, "month": 8, "day": 18, + "hour": 7, "minute": 59, "second": 0, + }, WorldTimeServiceType.DAY_START_HOUR) == "2026-08-17" + ) + assert( + WorldTimeServiceType.calendar_cycle_id_from_datetime({ + "year": 2026, "month": 8, "day": 18, + "hour": 8, "minute": 0, "second": 0, + }, WorldTimeServiceType.DAY_START_HOUR) == "2026-08-18" + ) assert( WorldTimeServiceType.phase_for_hour(7.49) == WorldTimeServiceType.Phase.NIGHT @@ -71,12 +99,12 @@ func _validate_clock_boundaries_and_duration() -> void: var clock := WorldTimeServiceType.new() root.add_child(clock) - clock.begin_session(8.0) - clock.advance_time(1800.0) + clock.begin_test_session(8.0) + clock.advance_time(12.0 * 60.0 * 60.0) assert(is_equal_approx(clock.get_time_hours(), 20.0)) assert(clock.is_night_period()) assert(clock.is_transition()) - clock.advance_time(1800.0) + clock.advance_time(12.0 * 60.0 * 60.0) assert(is_equal_approx(clock.get_time_hours(), 8.0)) assert(not clock.is_night_period()) assert(clock.is_transition()) @@ -88,10 +116,10 @@ func _validate_clock_boundaries_and_duration() -> void: func(time_hours: float, _phase: WorldTimeService.Phase) -> void: emitted_times.append(time_hours) ) - clock.begin_session(12.0 + 31.0 / 60.0) + clock.begin_test_session(12.0 + 31.0 / 60.0) emitted_times.clear() - for _frame: int in 1201: - clock.advance_time(1.0 / 240.0) + for _frame: int in 7201: + clock.advance_time(1.0 / 60.0) assert(clock.get_clock_text() == "12:33 pm") assert(emitted_times.size() >= 2) clock.queue_free() @@ -101,20 +129,41 @@ func _validate_persistent_host_clock() -> void: var clock := WorldTimeServiceType.new() root.add_child(clock) assert(clock.restore_persistent_time_hours(18.75)) - clock.set_persistence_tracking_enabled(true) - clock.begin_session(clock.get_persistent_time_hours()) + clock.begin_test_session(18.75) assert(is_equal_approx(clock.get_time_hours(), 18.75)) clock.synchronize_time(19.25) assert(is_equal_approx(clock.get_persistent_time_hours(), 19.25)) - clock.set_persistence_tracking_enabled(false) clock.synchronize_time(6.5) assert(is_equal_approx(clock.get_time_hours(), 6.5)) - assert(is_equal_approx(clock.get_persistent_time_hours(), 19.25)) + assert(is_equal_approx(clock.get_persistent_time_hours(), 6.5)) assert(not clock.restore_persistent_time_hours(-1.0)) assert(not clock.restore_persistent_time_hours(24.0)) clock.queue_free() +func _validate_system_clock_authority() -> void: + var clock := WorldTimeServiceType.new() + root.add_child(clock) + clock.begin_authoritative_session() + var system_datetime: Dictionary = Time.get_datetime_dict_from_system(false) + var system_hour: float = WorldTimeServiceType.time_hours_from_datetime( + system_datetime + ) + assert(clock.is_using_system_clock()) + assert(_wrapped_time_difference( + clock.get_time_hours(), system_hour + ) < 2.0 / 3600.0) + assert( + clock.get_calendar_date_id() + == WorldTimeServiceType.date_id_from_datetime(system_datetime) + ) + assert(clock.set_authoritative_time(12.0)) + assert(not clock.is_using_system_clock()) + clock.clear_editor_time_override() + assert(clock.is_using_system_clock()) + clock.queue_free() + + func _validate_fishing_availability() -> void: var day_context := FishingContextType.new() day_context.location_tags = [&"starter_pond"] @@ -165,7 +214,7 @@ func _validate_fishing_availability() -> void: func _validate_fishing_spot_context() -> void: var clock := WorldTimeServiceType.new() - clock.begin_session(20.25) + clock.begin_test_session(20.25) var fishing_spot := FishingSpotType.new() fishing_spot.set("_world_time", clock) var region := FishableWaterRegionType.new() @@ -246,6 +295,10 @@ func _validate_environment_presentation() -> void: float(runtime_sky_material.get_shader_parameter("moon_visibility")) < 0.01 ) + assert( + float(runtime_sky_material.get_shader_parameter("star_visibility")) + < 0.01 + ) var day_ambient_energy: float = runtime_environment.ambient_light_energy assert(not runtime_environment.fog_enabled) assert(is_equal_approx(runtime_environment.fog_sky_affect, 0.35)) @@ -257,6 +310,9 @@ func _validate_environment_presentation() -> void: ) assert("uniform float fog_horizon_occlusion" in sky_shader.code) assert("fog_horizon_color.rgb" in sky_shader.code) + assert("uniform float star_visibility" in sky_shader.code) + assert("float procedural_star_field" in sky_shader.code) + assert("upper_sky_fade" in sky_shader.code) assert("fog_disabled" in water_shader.code) assert("surface_view_position = view_vertex.xyz" in water_shader.code) assert("surface_view_distance = length(surface_view_position)" in water_shader.code) @@ -301,6 +357,17 @@ func _validate_environment_presentation() -> void: runtime_sky_material.get_shader_parameter("sun_direction") as Vector3 ) assert(night_moon_visibility > 0.99) + assert( + float(runtime_sky_material.get_shader_parameter("star_visibility")) + > 0.99 + ) + assert(( + runtime_sky_material.get_shader_parameter("star_color") as Color + ).is_equal_approx(WorldTimeVisualController.STAR_COLOR)) + assert(is_equal_approx( + float(runtime_sky_material.get_shader_parameter("star_strength")), + WorldTimeVisualController.STAR_STRENGTH, + )) assert(night_moon_direction.y > 0.85) assert(night_moon_direction.is_equal_approx(-night_sun_direction)) assert(runtime_environment.ambient_light_energy < day_ambient_energy) @@ -339,6 +406,9 @@ func _validate_environment_presentation() -> void: assert(is_equal_approx(float( runtime_sky_material.get_shader_parameter("fog_horizon_occlusion") ), 1.0)) + assert(is_zero_approx(float( + runtime_sky_material.get_shader_parameter("star_visibility") + ))) assert(( runtime_sky_material.get_shader_parameter("fog_horizon_color") as Color ).is_equal_approx(WorldTimeVisualController.NIGHT_FOG)) @@ -425,6 +495,10 @@ func _validate_environment_presentation() -> void: assert(is_zero_approx(float( runtime_sky_material.get_shader_parameter("fog_horizon_occlusion") ))) + var dusk_star_visibility := float( + runtime_sky_material.get_shader_parameter("star_visibility") + ) + assert(dusk_star_visibility > 0.45 and dusk_star_visibility < 0.55) var dusk_horizon: Color = runtime_sky_material.get_shader_parameter( "sky_horizon_color" ) as Color @@ -442,3 +516,8 @@ func _validate_weather_clock_icon() -> void: weather_icon.set_weather(WorldWeatherService.Weather.CLOUDY) assert(weather_icon.tooltip_text == "cloudy") weather_icon.queue_free() + + +static func _wrapped_time_difference(left: float, right: float) -> float: + var difference: float = absf(left - right) + return minf(difference, WorldTimeServiceType.HOURS_PER_DAY - difference) diff --git a/tests/world_weather_validation.gd b/tests/world_weather_validation.gd index a725117..c346e93 100644 --- a/tests/world_weather_validation.gd +++ b/tests/world_weather_validation.gd @@ -26,6 +26,7 @@ func _run() -> void: _validate_weather_scheduler() _validate_authoritative_weather_override() _validate_weather_persistence() + _validate_legacy_forecast_save_migration() _validate_snapshot_bounds() _validate_fishing_weather_seams() _validate_fishing_spot_context() @@ -65,14 +66,9 @@ func _validate_weather_scheduler() -> void: func _duration_is_valid(weather: WorldWeatherServiceType) -> bool: var seconds: float = weather.get_seconds_remaining() - match weather.get_weather(): - WorldWeatherServiceType.Weather.SUNNY: - return seconds >= 480.0 and seconds <= 900.0 - WorldWeatherServiceType.Weather.CLOUDY: - return seconds >= 300.0 and seconds <= 720.0 - WorldWeatherServiceType.Weather.RAINY, WorldWeatherServiceType.Weather.FOGGY: - return seconds >= 300.0 and seconds <= 600.0 - return false + return is_equal_approx( + seconds, WorldWeatherServiceType.WEATHER_PERIOD_SECONDS + ) func _validate_authoritative_weather_override() -> void: @@ -94,7 +90,7 @@ func _validate_authoritative_weather_override() -> void: var clock := WorldTimeServiceType.new() root.add_child(clock) - clock.begin_session(WorldTimeServiceType.DAY_START_HOUR) + clock.begin_test_session(WorldTimeServiceType.DAY_START_HOUR) var schedule: Array[Dictionary] = [] for index: int in WorldWeatherServiceType.DAILY_PLAN_SEGMENT_COUNT: schedule.append({ @@ -159,8 +155,11 @@ func _validate_weather_persistence() -> void: )) weather.set_persistence_tracking_enabled(true) weather.begin_authoritative_session(20260803) - assert(weather.is_raining()) - assert(is_equal_approx(weather.get_seconds_remaining(), 187.5)) + assert(weather.get_weather() == WorldWeatherServiceType.DEFAULT_WEATHER) + assert(is_equal_approx( + weather.get_seconds_remaining(), + WorldWeatherServiceType.WEATHER_PERIOD_SECONDS, + )) assert(not weather.restore_persistent_state( WorldWeatherServiceType.Weather.RAINY, WorldWeatherServiceType.MAX_PERSISTED_SECONDS + 1.0, @@ -184,11 +183,49 @@ func _validate_snapshot_bounds() -> void: assert(not NetworkWorldWeatherServiceType.validate_snapshot({ "session_id": "session", "weather": int(WorldWeatherServiceType.Weather.RAINY), - "seconds_remaining": 1801.0, + "seconds_remaining": 3601.0, "sequence": 2, })) +func _validate_legacy_forecast_save_migration() -> void: + var legacy_schedule: Array[Dictionary] = [] + for index: int in JobCatalog.LEGACY_WEATHER_SEGMENT_COUNT: + legacy_schedule.append({ + "start_hour": fposmod( + WorldTimeService.DAY_START_HOUR + + float(index) * JobCatalog.LEGACY_WEATHER_SEGMENT_HOURS, + WorldTimeService.HOURS_PER_DAY, + ), + "weather": int(WorldWeatherService.Weather.SUNNY), + }) + var legacy_job: Dictionary = { + "id": "legacy-catch", + "title": "legacy catch", + "description": "catch one fish", + "kind": int(JobCatalog.Kind.CATCH_TOTAL), + "target": 1, + "fish_coin": 1, + "experience": 1, + } + var save_data: Dictionary = PlayerJobService.default_save_data() + save_data["host_board"] = { + "plan_id": "legacy-plan", + "cycle": 4, + "schedule_anchor_index": 0, + "jobs": [legacy_job], + "weather_schedule": legacy_schedule, + } + save_data["active_plan_id"] = "legacy-plan" + assert(PlayerJobService.validate_save_data(save_data)) + var jobs := PlayerJobService.new() + assert(jobs.restore_from_save_data(save_data)) + var migrated: Dictionary = jobs.to_save_data() + assert((migrated.get("host_board", {}) as Dictionary).is_empty()) + assert(str(migrated.get("active_plan_id", "")).is_empty()) + jobs.free() + + func _validate_fishing_weather_seams() -> void: var clear_context := FishingContextType.new() var rain_context := FishingContextType.new() @@ -255,7 +292,7 @@ func _validate_weather_presentation() -> void: world_root.add_child(sun) var clock := WorldTimeServiceType.new() world_root.add_child(clock) - clock.begin_session(14.0) + clock.begin_test_session(14.0) var weather := WorldWeatherServiceType.new() world_root.add_child(weather) weather.begin_remote_session() @@ -279,6 +316,34 @@ func _validate_weather_presentation() -> void: var runtime_sky_material := ( runtime_environment.sky.sky_material as ShaderMaterial ) + clock.set_authoritative_time(0.0) + visuals.apply_time_immediately(0.0) + assert(float( + runtime_sky_material.get_shader_parameter("star_visibility") + ) > 0.99) + visuals.apply_weather_immediately( + WorldWeatherServiceType.Weather.CLOUDY + ) + assert(is_equal_approx(float( + runtime_sky_material.get_shader_parameter("star_visibility") + ), 0.18)) + visuals.apply_weather_immediately( + WorldWeatherServiceType.Weather.RAINY + ) + assert(is_equal_approx(float( + runtime_sky_material.get_shader_parameter("star_visibility") + ), 0.03)) + visuals.apply_weather_immediately( + WorldWeatherServiceType.Weather.FOGGY + ) + assert(is_zero_approx(float( + runtime_sky_material.get_shader_parameter("star_visibility") + ))) + visuals.apply_weather_immediately( + WorldWeatherServiceType.Weather.SUNNY + ) + clock.set_authoritative_time(14.0) + visuals.apply_time_immediately(14.0) var clear_background_energy: float = ( runtime_environment.background_energy_multiplier ) diff --git a/ui/chat_ui.gd b/ui/chat_ui.gd index 601559e..54f8a75 100644 --- a/ui/chat_ui.gd +++ b/ui/chat_ui.gd @@ -193,7 +193,6 @@ func setup( _service.local_message_confirmed.connect(_on_local_message_confirmed) _service.history_replaced.connect(_on_history) _service.send_rejected.connect(_on_rejected) - _service.world_command_finished.connect(_on_world_command_finished) _session.peer_removed.connect(_on_peer_removed) _entry.text = _settings.current_settings.chat_draft _entry.caret_column = _entry.text.length() @@ -835,8 +834,6 @@ func _send() -> void: if body.strip_edges().is_empty(): close_chat() return - if _handle_chat_command(body): - return _send_pending = true _pending_send_body = NetworkChatProtocol.sanitize_body(body) _entry.editable = false @@ -852,85 +849,6 @@ func _send() -> void: _set_status("Sending…") -func _handle_chat_command(body: String) -> bool: - var command_text := body.strip_edges() - if not command_text.begins_with("/"): - return false - var parts: PackedStringArray = command_text.split(" ", false) - var command := String(parts[0]).trim_prefix("/").to_lower() - match command: - "time": - _handle_time_command(parts) - "weather": - _handle_weather_command(parts) - "": - _show_command_error("Enter a command after /.") - _: - _show_command_error("Unknown command: /%s" % command) - _entry.clear() - _flush_draft() - return true - - -func _show_command_error(message: String) -> void: - _set_status(message) - close_chat(true) - - -func _handle_time_command(parts: PackedStringArray) -> void: - if parts.size() != 2: - _show_command_error("Usage: /time [dawn, day, dusk, night]") - return - if _service == null or _world_time == null: - _show_command_error("World time is unavailable.") - return - var phase_name := String(parts[1]).to_lower() - if phase_name not in ["dawn", "day", "dusk", "night"]: - _show_command_error("Usage: /time [dawn, day, dusk, night]") - return - if not _service.request_world_time_change(phase_name): - _show_command_error( - "Only the host or an operator can change world time." - ) - return - close_chat() - - -func _handle_weather_command(parts: PackedStringArray) -> void: - if parts.size() != 2: - _show_command_error( - "Usage: /weather [clear, cloudy, rainy, foggy]" - ) - return - if _service == null or _world_weather == null: - _show_command_error("World weather is unavailable.") - return - var weather_name := String(parts[1]).to_lower() - match weather_name: - "clear", "sunny": - weather_name = "clear" - "cloudy", "rainy", "foggy": - pass - _: - _show_command_error( - "Usage: /weather [clear, cloudy, rainy, foggy]" - ) - return - if not _service.request_world_weather_change(weather_name): - _show_command_error( - "Only the host or an operator can change world weather." - ) - return - close_chat() - - -func _on_world_command_finished(success: bool, message: String) -> void: - if success: - _set_status("") - elif not message.is_empty(): - _set_status(message) - - func _on_local_message_confirmed(message: Dictionary) -> void: if ( not _send_pending diff --git a/ui/components/general_inventory_grid.gd b/ui/components/general_inventory_grid.gd new file mode 100644 index 0000000..5d6fbc5 --- /dev/null +++ b/ui/components/general_inventory_grid.gd @@ -0,0 +1,159 @@ +class_name GeneralInventoryGrid +extends Control + +signal entry_selected(kind: int, identity: StringName) +signal slot_activated(slot: GeneralInventorySlot) +signal context_requested(slot: GeneralInventorySlot) +signal context_changed(slot: GeneralInventorySlot, text: String, active: bool) + +const DEFAULT_SLOT_SIZE := Vector2(52.0, 52.0) +const DEFAULT_SLOT_SEPARATION: int = 5 +const COLUMNS: int = PlayerInventoryLayout.INVENTORY_COLUMNS + +var _layout: PlayerInventoryLayout +var _bag: PlayerBag +var _fish_inventory: FishInventory +var _hotbar: PlayerHotbar +var _item_catalog: ItemCatalog +var _container: int = PlayerInventoryLayout.InventoryContainer.INVENTORY +var _grid: GridContainer +var _slots: Array[GeneralInventorySlot] = [] +var _slot_size: Vector2 = DEFAULT_SLOT_SIZE +var _slot_separation: int = DEFAULT_SLOT_SEPARATION + + +func _ready() -> void: + _grid = GridContainer.new() + _grid.columns = COLUMNS + _apply_grid_separation() + add_child(_grid) + + +func set_slot_presentation(slot_size: Vector2, separation: int) -> void: + _slot_size = Vector2(maxf(1.0, slot_size.x), maxf(1.0, slot_size.y)) + _slot_separation = maxi(0, separation) + if _grid != null: + _apply_grid_separation() + if is_node_ready() and _layout != null: + var active_capacity := ( + _layout.get_inventory_capacity() + if _container == PlayerInventoryLayout.InventoryContainer.INVENTORY + else _layout.get_storage_capacity() + ) + _rebuild( + PlayerInventoryLayout.MAX_INVENTORY_SLOT_COUNT + if _container == PlayerInventoryLayout.InventoryContainer.INVENTORY + else PlayerInventoryLayout.MAX_STORAGE_SLOT_COUNT, + active_capacity, + ) + + +func setup( + layout: PlayerInventoryLayout, + bag: PlayerBag, + fish_inventory: FishInventory, + hotbar: PlayerHotbar, + item_catalog: ItemCatalog, + container: int, +) -> void: + _layout = layout + _bag = bag + _fish_inventory = fish_inventory + _hotbar = hotbar + _item_catalog = item_catalog + _container = container + if _layout != null and not _layout.layout_changed.is_connected(refresh): + _layout.layout_changed.connect(refresh) + refresh() + + +func refresh() -> void: + if not is_node_ready() or _layout == null: + return + var active_capacity := ( + _layout.get_inventory_capacity() + if _container == PlayerInventoryLayout.InventoryContainer.INVENTORY + else _layout.get_storage_capacity() + ) + var visible_capacity := ( + PlayerInventoryLayout.MAX_INVENTORY_SLOT_COUNT + if _container == PlayerInventoryLayout.InventoryContainer.INVENTORY + else PlayerInventoryLayout.MAX_STORAGE_SLOT_COUNT + ) + if _slots.size() != visible_capacity: + _rebuild(visible_capacity, active_capacity) + else: + for index: int in _slots.size(): + _slots[index].set_locked(index >= active_capacity) + for slot: GeneralInventorySlot in _slots: + slot.refresh() + + +func get_slots() -> Array[GeneralInventorySlot]: + var active: Array[GeneralInventorySlot] = [] + for slot: GeneralInventorySlot in _slots: + if not slot.disabled: + active.append(slot) + return active + + +func get_first_occupied_slot() -> GeneralInventorySlot: + for slot: GeneralInventorySlot in _slots: + if not slot.entry_identity.is_empty(): + return slot + return _slots.front() if not _slots.is_empty() else null + + +func _rebuild(visible_capacity: int, active_capacity: int) -> void: + for child: Node in _grid.get_children(): + child.queue_free() + _slots.clear() + for slot_index: int in visible_capacity: + var slot := GeneralInventorySlot.new() + slot.custom_minimum_size = _slot_size + slot.entry_selected.connect( + func(kind: int, identity: StringName) -> void: + entry_selected.emit(kind, identity) + ) + slot.pressed.connect(func() -> void: slot_activated.emit(slot)) + slot.context_requested.connect( + func(source: GeneralInventorySlot) -> void: + context_requested.emit(source) + ) + slot.context_changed.connect( + func( + source: GeneralInventorySlot, + text: String, + active: bool, + ) -> void: + context_changed.emit(source, text, active) + ) + _grid.add_child(slot) + slot.set_presentation_size(_slot_size) + slot.configure( + slot_index, + _container, + slot_index >= active_capacity, + _layout, + _bag, + _fish_inventory, + _hotbar, + _item_catalog, + ) + _slots.append(slot) + custom_minimum_size = Vector2( + float(COLUMNS) * _slot_size.x + + float(COLUMNS - 1) * _slot_separation, + ceilf(float(visible_capacity) / float(COLUMNS)) * _slot_size.y + + maxf( + 0.0, + ceilf(float(visible_capacity) / float(COLUMNS)) - 1.0, + ) * _slot_separation, + ) + size = custom_minimum_size + ControllerFocusNavigation.configure_spatial_neighbors(_slots) + + +func _apply_grid_separation() -> void: + _grid.add_theme_constant_override("h_separation", _slot_separation) + _grid.add_theme_constant_override("v_separation", _slot_separation) diff --git a/ui/components/general_inventory_grid.gd.uid b/ui/components/general_inventory_grid.gd.uid new file mode 100644 index 0000000..3383074 --- /dev/null +++ b/ui/components/general_inventory_grid.gd.uid @@ -0,0 +1 @@ +uid://k6h255orqr6o diff --git a/ui/components/general_inventory_slot.gd b/ui/components/general_inventory_slot.gd new file mode 100644 index 0000000..cf17734 --- /dev/null +++ b/ui/components/general_inventory_slot.gd @@ -0,0 +1,346 @@ +class_name GeneralInventorySlot +extends Button + +signal entry_selected(kind: int, identity: StringName) +signal entry_moved +signal context_requested(slot: GeneralInventorySlot) +signal context_changed(slot: GeneralInventorySlot, text: String, active: bool) + +const ItemDataType = preload("res://items/item_data.gd") +const LOCK_ICON: Texture2D = preload("res://ui/icons/pictograms/lock_light.png") + +var slot_index: int = -1 +var container: int = -1 +var entry_kind: int = -1 +var entry_identity: StringName +var _locked: bool = false + +var _layout: PlayerInventoryLayout +var _bag: PlayerBag +var _fish_inventory: FishInventory +var _hotbar: PlayerHotbar +var _item_catalog: ItemCatalog +var _icon: TextureRect +var _quantity: Label +var _presentation_size := Vector2(52.0, 52.0) +var _context_text: String = "" +var _context_hovered: bool = false +var _context_focused: bool = false +var _staged: bool = false + + +func _ready() -> void: + focus_mode = Control.FOCUS_ALL + mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + pressed.connect(_on_pressed) + focus_entered.connect(_set_context_focused.bind(true)) + focus_exited.connect(_set_context_focused.bind(false)) + mouse_entered.connect(_set_context_hovered.bind(true)) + mouse_exited.connect(_set_context_hovered.bind(false)) + gui_input.connect(_on_gui_input) + _icon = TextureRect.new() + _icon.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + _icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + _icon.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + _icon.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_icon) + _quantity = Label.new() + _quantity.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT) + _quantity.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + _quantity.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _quantity.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY + ) + _quantity.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_quantity) + _apply_presentation() + + +func set_presentation_size(presentation_size: Vector2) -> void: + _presentation_size = presentation_size + custom_minimum_size = presentation_size + if is_node_ready(): + _apply_presentation() + + +func configure( + new_slot_index: int, + new_container: int, + is_locked: bool, + layout: PlayerInventoryLayout, + bag: PlayerBag, + fish_inventory: FishInventory, + hotbar: PlayerHotbar, + item_catalog: ItemCatalog, +) -> void: + slot_index = new_slot_index + container = new_container + _locked = is_locked + _layout = layout + _bag = bag + _fish_inventory = fish_inventory + _hotbar = hotbar + _item_catalog = item_catalog + name = "InventorySlot%d" % slot_index + refresh() + + +func set_locked(is_locked: bool) -> void: + if _locked == is_locked: + return + _locked = is_locked + refresh() + + +func refresh() -> void: + call_deferred("_update_context_presence") + entry_kind = -1 + entry_identity = StringName() + _context_text = "" + _icon.texture = null + _quantity.text = "" + disabled = _locked + _apply_icon_geometry() + if _locked: + _icon.texture = LOCK_ICON + _icon.modulate = Color(UtilityPageStyle.OCEAN_DISABLED, 0.18) + var storage_slot := ( + container == PlayerInventoryLayout.InventoryContainer.STORAGE + ) + _context_text = ( + "locked storage slot" if storage_slot else "locked backpack slot" + ) + tooltip_text = "" + accessibility_name = "%s %d" % [_context_text, slot_index + 1] + return + _icon.modulate = Color.WHITE + _context_text = "empty slot" + tooltip_text = "" + accessibility_name = "empty %s slot %d" % [ + "storage" + if container == PlayerInventoryLayout.InventoryContainer.STORAGE + else "inventory", + slot_index + 1, + ] + if _layout == null: + return + var entry := _layout.get_entry_at(container, slot_index) + if entry.is_empty(): + return + entry_kind = int(entry.get("kind", -1)) + entry_identity = StringName(str(entry.get("identity", ""))) + if entry_kind == PlayerInventoryLayout.EntryKind.ITEM: + var item: ItemDataType = ( + _item_catalog.get_item_by_id(entry_identity) + if _item_catalog != null else null + ) + if item == null: + return + _icon.texture = item.icon + var quantity := _bag.get_quantity(entry_identity) if _bag != null else 0 + _quantity.text = "×%d" % quantity if quantity > 1 else "" + _context_text = _item_context_text(item, quantity) + accessibility_name = "%s, slot %d" % [item.display_name, slot_index + 1] + elif entry_kind == PlayerInventoryLayout.EntryKind.CATCH: + var fish_catch = ( + _fish_inventory.get_catch_by_id(entry_identity) + if _fish_inventory != null else null + ) + if fish_catch == null: + return + _icon.texture = fish_catch.fish.display_texture + _context_text = _catch_context_text(fish_catch) + var catch_name: String = FishQuality.qualified_name( + fish_catch.fish.display_name, fish_catch.quality + ) + accessibility_name = "%s, slot %d" % [catch_name, slot_index + 1] + + +func _on_pressed() -> void: + if _locked: + return + entry_selected.emit(entry_kind, entry_identity) + + +func _on_gui_input(event: InputEvent) -> void: + var mouse_event := event as InputEventMouseButton + if ( + mouse_event != null + and mouse_event.button_index == MOUSE_BUTTON_RIGHT + and mouse_event.pressed + and not _locked + and not entry_identity.is_empty() + ): + context_requested.emit(self) + accept_event() + + +func _set_context_hovered(active: bool) -> void: + _context_hovered = active + _update_context_presence() + + +func _set_context_focused(active: bool) -> void: + _context_focused = active + _update_context_presence() + + +func _update_context_presence() -> void: + if _locked or entry_identity.is_empty(): + context_changed.emit(self, "", false) + return + var active: bool = _context_hovered or _context_focused + context_changed.emit(self, _context_text if active else "", active) + + +func set_move_source(active: bool) -> void: + modulate = Color(1.0, 1.0, 1.0, 0.42) if active else Color.WHITE + + +func set_staged(active: bool) -> void: + if _staged == active: + return + _staged = active + _apply_style() + + +func _item_context_text(item: ItemDataType, quantity: int) -> String: + var lines: Array[String] = [item.display_name] + lines.append("%s • quantity %d" % [item.get_category_name(), quantity]) + if item.equippable: + lines.append("equippable") + elif item.usable: + lines.append("usable") + if not item.description.strip_edges().is_empty(): + lines.append(item.description.strip_edges()) + return "\n".join(lines) + + +func _catch_context_text(fish_catch: FishCatch) -> String: + var catch_name: String = FishQuality.qualified_name( + fish_catch.fish.display_name, + fish_catch.quality, + ) + return "%s\n%0.2f lb • value %d\n%s" % [ + catch_name, + fish_catch.weight_lb, + fish_catch.sale_value, + fish_catch.fish.logbook_fact, + ] + + +func _get_drag_data(_at_position: Vector2) -> Variant: + if _locked or entry_identity.is_empty() or _icon.texture == null: + return null + var preview := TextureRect.new() + preview.custom_minimum_size = Vector2(56.0, 56.0) + preview.texture = _icon.texture + preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + preview.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + preview.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + preview.mouse_filter = Control.MOUSE_FILTER_IGNORE + set_drag_preview(preview) + if entry_kind == PlayerInventoryLayout.EntryKind.CATCH: + return { + "kind": "cooler_fish", + "catch_id": String(entry_identity), + } + return { + "kind": "bag_item", + "item_id": String(entry_identity), + } + + +func _can_drop_data(_at_position: Vector2, data: Variant) -> bool: + if _locked or typeof(data) != TYPE_DICTIONARY or _layout == null: + return false + var kind := str((data as Dictionary).get("kind", "")) + return kind in ["bag_item", "cooler_fish", "hotbar_slot"] + + +func _drop_data(_at_position: Vector2, data: Variant) -> void: + if not _can_drop_data(Vector2.ZERO, data): + return + var payload := data as Dictionary + var payload_kind := str(payload.get("kind", "")) + var moved: bool = false + if payload_kind == "hotbar_slot": + moved = ( + _hotbar != null + and _hotbar.move_slot_to_container( + int(payload.get("slot_index", -1)), + container, + slot_index, + ) + ) + else: + var kind := ( + PlayerInventoryLayout.EntryKind.CATCH + if payload_kind == "cooler_fish" + else PlayerInventoryLayout.EntryKind.ITEM + ) + var identity := StringName(str( + payload.get( + "catch_id" if payload_kind == "cooler_fish" else "item_id", + "", + ) + )) + moved = _layout.move_entry(kind, identity, container, slot_index) + if moved: + entry_moved.emit() + + +func _apply_style() -> void: + var radius: int = roundi(minf(_presentation_size.x, _presentation_size.y) * 0.5) + var normal := UtilityPageStyle.rounded_style( + Color( + UtilityPageStyle.OCEAN_SELECTED + if _staged else UtilityPageStyle.OCEAN_FIELD, + 0.92 if _staged else 0.88, + ), + radius, + ) + var hover := UtilityPageStyle.rounded_style( + Color(UtilityPageStyle.OCEAN_SELECTED, 0.92), radius + ) + var locked := UtilityPageStyle.rounded_style( + Color(UtilityPageStyle.OCEAN_FIELD, 0.38), radius + ) + for state: StringName in [&"normal", &"pressed"]: + add_theme_stylebox_override(state, normal) + add_theme_stylebox_override("disabled", locked) + for state: StringName in [&"hover", &"focus"]: + add_theme_stylebox_override(state, hover) + + +func _apply_presentation() -> void: + _apply_icon_geometry() + var is_large: bool = _presentation_size.x >= 70.0 + var quantity_width: float = 42.0 if is_large else 34.0 + var quantity_height: float = 20.0 if is_large else 17.0 + _quantity.position = Vector2( + -quantity_width - (8.0 if is_large else 5.0), + -quantity_height - (6.0 if is_large else 4.0), + ) + _quantity.size = Vector2(quantity_width, quantity_height) + _quantity.add_theme_font_size_override("font_size", 14 if is_large else 11) + _apply_style() + + +func _apply_icon_geometry() -> void: + var is_large: bool = _presentation_size.x >= 70.0 + var margin: float + if _locked: + var lock_size: float = 24.0 if is_large else 18.0 + margin = maxf( + (_presentation_size.x - lock_size) * 0.5, + 0.0, + ) + else: + margin = 10.0 if is_large else 7.0 + _icon.offset_left = margin + _icon.offset_top = margin + _icon.offset_right = -margin + _icon.offset_bottom = -margin diff --git a/ui/components/general_inventory_slot.gd.uid b/ui/components/general_inventory_slot.gd.uid new file mode 100644 index 0000000..221a089 --- /dev/null +++ b/ui/components/general_inventory_slot.gd.uid @@ -0,0 +1 @@ +uid://cgaypm0vokkup diff --git a/ui/components/inventory_notepad.gd b/ui/components/inventory_notepad.gd index 16be9f9..53e24df 100644 --- a/ui/components/inventory_notepad.gd +++ b/ui/components/inventory_notepad.gd @@ -6,7 +6,10 @@ const CONTENT_MARGIN_LEFT := 18.0 const CONTENT_MARGIN_TOP := 98.0 const CONTENT_MARGIN_RIGHT := 18.0 const CONTENT_MARGIN_BOTTOM := 16.0 +# Retained for the Logbook's separate handwritten presentation. Inventory +# notepads deliberately prioritize legibility with Tuffy. const HANDWRITTEN_FONT: Font = preload("res://ui/fonts/seattle_avenue.otf") +const NOTEPAD_FONT: Font = preload("res://ui/fonts/Tuffy_Bold.otf") const NOTEPAD_TEXTURE: Texture2D = preload("res://art/ui/ui_notepad.png") const NOTEPAD_CANONICAL_OVERSCAN: float = 1.2 const NOTEPAD_ART_OFFSET := Vector2(10.0, 20.0) @@ -24,11 +27,11 @@ func _ready() -> void: static func apply_handwritten_to(root: Control) -> void: - root.add_theme_font_override("font", HANDWRITTEN_FONT) + root.add_theme_font_override("font", NOTEPAD_FONT) for descendant: Node in root.find_children("*", "Control", true, false): var control := descendant as Control control.add_theme_font_override( - "font", HANDWRITTEN_FONT + "font", NOTEPAD_FONT ) if control is Label: control.add_theme_color_override("font_color", INK_COLOR) @@ -37,7 +40,7 @@ static func apply_handwritten_to(root: Control) -> void: func _draw() -> void: draw_texture_rect(NOTEPAD_TEXTURE, get_art_rect(), false) - var font: Font = HANDWRITTEN_FONT + var font: Font = NOTEPAD_FONT draw_string( font, Vector2(20.0, 58.0), diff --git a/ui/components/shop_sale_tray_slot.gd b/ui/components/shop_sale_tray_slot.gd new file mode 100644 index 0000000..5deb62f --- /dev/null +++ b/ui/components/shop_sale_tray_slot.gd @@ -0,0 +1,71 @@ +class_name ShopSaleTraySlot +extends Button + +signal remove_requested(key: String) +signal drop_requested(payload: Dictionary) + +var entry_key: String = "" +var _icon: TextureRect +var _quantity: Label + + +func _ready() -> void: + custom_minimum_size = GeneralInventoryGrid.DEFAULT_SLOT_SIZE + focus_mode = Control.FOCUS_ALL + texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + pressed.connect(func() -> void: remove_requested.emit(entry_key)) + _icon = TextureRect.new() + _icon.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _icon.offset_left = 6.0 + _icon.offset_top = 4.0 + _icon.offset_right = -6.0 + _icon.offset_bottom = -4.0 + _icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + _icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + _icon.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + _icon.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_icon) + _quantity = Label.new() + _quantity.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT) + _quantity.position = Vector2(-34.0, -20.0) + _quantity.size = Vector2(30.0, 16.0) + _quantity.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + _quantity.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_quantity) + var normal := UtilityPageStyle.rounded_style( + Color(UtilityPageStyle.OCEAN_FIELD, 0.96), 26 + ) + var hover := UtilityPageStyle.rounded_style( + Color(UtilityPageStyle.OCEAN_SELECTED, 0.96), 26 + ) + for state: StringName in [&"normal", &"pressed", &"disabled"]: + add_theme_stylebox_override(state, normal) + for state: StringName in [&"hover", &"focus"]: + add_theme_stylebox_override(state, hover) + + +func configure( + key: String, + icon: Texture2D, + label: String, + quantity: int = 1, +) -> void: + entry_key = key + _icon.texture = icon + _quantity.text = "×%d" % quantity if quantity > 1 else "" + tooltip_text = "%s · select to remove" % label + accessibility_name = tooltip_text + + +func _can_drop_data(_at_position: Vector2, data: Variant) -> bool: + return ( + typeof(data) == TYPE_DICTIONARY + and str((data as Dictionary).get("kind", "")) in [ + "bag_item", "cooler_fish" + ] + ) + + +func _drop_data(_at_position: Vector2, data: Variant) -> void: + if _can_drop_data(Vector2.ZERO, data): + drop_requested.emit((data as Dictionary).duplicate(true)) diff --git a/ui/components/shop_sale_tray_slot.gd.uid b/ui/components/shop_sale_tray_slot.gd.uid new file mode 100644 index 0000000..d834ea3 --- /dev/null +++ b/ui/components/shop_sale_tray_slot.gd.uid @@ -0,0 +1 @@ +uid://w87v5n2xm64p diff --git a/ui/fishing_shop.gd b/ui/fishing_shop.gd index 6b6d864..ddc5d42 100644 --- a/ui/fishing_shop.gd +++ b/ui/fishing_shop.gd @@ -104,7 +104,7 @@ const SHOP_SECTION_LABELS: Array[String] = [ "Snacks", "Equipment", "Art Supplies", - "Sell Fish", + "Sell", ] const SUPPLY_ICON_GRID_COLUMNS: int = 9 const SUPPLY_ICON_TILE_SIZE := Vector2(72.0, 72.0) @@ -154,6 +154,9 @@ const ROD_PRICE_ICON_SIZE: float = 22.0 @onready var _cooler_cost: CurrencyAmount = %CoolerCost @onready var _cooler_price_bubble: PanelContainer = %CoolerPriceBubble @onready var _cooler_purchase: Button = %CoolerPurchase +@onready var _backpack_cost: CurrencyAmount = %BackpackCost +@onready var _backpack_price_bubble: PanelContainer = %BackpackPriceBubble +@onready var _backpack_purchase: Button = %BackpackPurchase var _player: PlayerType var _wallet: PlayerWalletType @@ -162,10 +165,16 @@ var _upgrades: PlayerFishingUpgradesType var _fishing_spot: FishingSpotType var _interaction: ShopInteractionType var _bag: PlayerBagType +var _inventory: FishInventory +var _hotbar: PlayerHotbar +var _inventory_layout: PlayerInventoryLayout var _item_catalog: ItemCatalogType var _cooler_capacity: PlayerCoolerCapacityType var _art_unlocks: PlayerArtUnlocksType var _network_shop: NetworkShopService +var _network_sale: NetworkSaleService +var _reservations: PlayerAssetReservationService +var _sell_inventory: ShopSellInventory var _prior_movement_enabled: bool = true var _prior_camera_enabled: bool = true var _prior_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_VISIBLE @@ -191,6 +200,7 @@ func _ready() -> void: _reel_purchase.pressed.connect(_purchase_reel_speed) _barrier_purchase.pressed.connect(_purchase_barrier_power) _cooler_purchase.pressed.connect(_purchase_cooler_capacity) + _backpack_purchase.pressed.connect(_purchase_backpack_capacity) func setup_controller_mapping( @@ -327,7 +337,9 @@ func _request_shop_cooler() -> bool: if not _is_transaction_context_valid(): _set_feedback("The fishing shop is no longer available.") return false - sell_fish_requested.emit() + activate_shop_cooler_page() + if _sell_inventory != null: + _sell_inventory.activate() return _cooler_page_active @@ -339,6 +351,7 @@ func _focus_shop_section() -> void: _reel_purchase, _barrier_purchase, _cooler_purchase, + _backpack_purchase, ]: if not upgrade_button.disabled: upgrade_button.grab_focus() @@ -403,7 +416,7 @@ func _select_shop_section(section_index: int, focus_content: bool) -> void: _update_shop_tab_selection() return if _cooler_page_active: - shop_cooler_return_requested.emit() + deactivate_shop_cooler_page() _shop_section = section_index as ShopSection var showing_upgrades: bool = _shop_section == ShopSection.UPGRADES _upgrades_content.visible = showing_upgrades @@ -494,6 +507,7 @@ func _apply_shop_styles() -> void: _reel_price_bubble, _barrier_price_bubble, _cooler_price_bubble, + _backpack_price_bubble, ]: var price_style := UtilityPageStyleType.rounded_style( UtilityPageStyleType.OCEAN_FIELD, @@ -510,6 +524,7 @@ func _apply_shop_styles() -> void: _reel_cost, _barrier_cost, _cooler_cost, + _backpack_cost, ]: var amount_label := price.get_amount_label() amount_label.add_theme_color_override( @@ -521,6 +536,7 @@ func _apply_shop_styles() -> void: _reel_purchase, _barrier_purchase, _cooler_purchase, + _backpack_purchase, ]: UtilityPageStyleType.apply_ocean_button(button) _feedback.add_theme_color_override( @@ -536,10 +552,15 @@ func setup( fishing_spot: FishingSpotType, interaction: ShopInteractionType, bag: PlayerBagType, + inventory: FishInventory, + hotbar: PlayerHotbar, + inventory_layout: PlayerInventoryLayout, item_catalog: ItemCatalogType, cooler_capacity: PlayerCoolerCapacityType, art_unlocks: PlayerArtUnlocksType, network_shop: NetworkShopService, + network_sale: NetworkSaleService, + reservations: PlayerAssetReservationService, ) -> void: _player = player _wallet = wallet @@ -548,10 +569,28 @@ func setup( _fishing_spot = fishing_spot _interaction = interaction _bag = bag + _inventory = inventory + _hotbar = hotbar + _inventory_layout = inventory_layout _item_catalog = item_catalog _cooler_capacity = cooler_capacity _art_unlocks = art_unlocks _network_shop = network_shop + _network_sale = network_sale + _reservations = reservations + _sell_inventory = ShopSellInventory.new() + _sell_inventory.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _shop_cooler_mount.add_child(_sell_inventory) + _sell_inventory.setup( + _inventory_layout, + _bag, + _inventory, + _hotbar, + _item_catalog, + _buyer, + _reservations, + _network_sale, + ) if ( _network_shop != null and not _network_shop.local_purchase_pending.is_connected( @@ -574,6 +613,12 @@ func setup( _on_cooler_capacity_changed ): _cooler_capacity.capacity_changed.connect(_on_cooler_capacity_changed) + if not _inventory_layout.backpack_capacity_changed.is_connected( + _on_backpack_capacity_changed + ): + _inventory_layout.backpack_capacity_changed.connect( + _on_backpack_capacity_changed + ) if not _art_unlocks.unlocks_changed.is_connected(_on_art_unlocks_changed): _art_unlocks.unlocks_changed.connect(_on_art_unlocks_changed) _refresh_all() @@ -611,6 +656,8 @@ func open_shop() -> bool: Input.mouse_mode = Input.MOUSE_MODE_VISIBLE _set_feedback("") deactivate_shop_cooler_page() + if _sell_inventory != null: + _sell_inventory.clear_staged() show() _shop_tab_bar.show() _controller_zone = ControllerZone.TABS @@ -780,6 +827,7 @@ func _refresh_all() -> void: _refresh_upgrades() _refresh_supplies() _refresh_cooler_capacity() + _refresh_backpack_capacity() func _refresh_wallet() -> void: @@ -1515,25 +1563,60 @@ func _refresh_cooler_capacity() -> void: ) var cooler_effect: String if cost < 0: - cooler_effect = "%d fish · maximum level" % ( + cooler_effect = "%d slots · maximum level" % ( _cooler_capacity.get_capacity() ) _cooler_price_bubble.hide() else: _cooler_price_bubble.show() - cooler_effect = "%d → %d fish" % [ + cooler_effect = "%d → %d slots" % [ _cooler_capacity.get_capacity(), next_capacity, ] _cooler_cost.set_amount(cost) _cooler_purchase.tooltip_text = _upgrade_tooltip( - "cooler capacity", + "storage capacity", level, cooler_effect, ) _cooler_purchase.accessibility_name = _cooler_purchase.tooltip_text +func _refresh_backpack_capacity() -> void: + if _inventory_layout == null: + return + var level := _inventory_layout.get_backpack_level() + var cost := _inventory_layout.get_next_backpack_cost() + var next_capacity := _inventory_layout.get_next_inventory_capacity() + _backpack_purchase.disabled = ( + cost < 0 + or _transaction_in_progress + or _closing + or _network_shop == null + or not _network_shop.can_request_backpack_purchase() + or not _inventory_layout.can_purchase_backpack(_wallet) + ) + var effect: String + if cost < 0: + effect = "%d slots · maximum level" % ( + _inventory_layout.get_inventory_capacity() + ) + _backpack_price_bubble.hide() + else: + _backpack_price_bubble.show() + effect = "%d → %d slots" % [ + _inventory_layout.get_inventory_capacity(), + next_capacity, + ] + _backpack_cost.set_amount(cost) + _backpack_purchase.tooltip_text = _upgrade_tooltip( + "backpack capacity", + level, + effect, + ) + _backpack_purchase.accessibility_name = _backpack_purchase.tooltip_text + + func _purchase_reel_speed() -> void: _purchase_upgrade(true) @@ -1555,7 +1638,7 @@ func _purchase_supply(item_id: StringName) -> void: item_id, owned, item.max_stack ) if quantity <= 0 or not _bag.can_add_item(item_id, quantity): - _set_feedback("Your Bag is full.") + _set_feedback("Your inventory is full.") return var total_cost: int = FishingShopStockType.get_purchase_cost( item_id, quantity, _bag.is_bait_unlocked(item_id) @@ -1617,6 +1700,23 @@ func _purchase_cooler_capacity() -> void: _network_shop.request_cooler_capacity_upgrade() +func _purchase_backpack_capacity() -> void: + if _transaction_in_progress or not _is_transaction_context_valid(): + _set_feedback("unable to complete purchase.") + return + var cost := _inventory_layout.get_next_backpack_cost() + if cost < 0: + _set_feedback("Upgrade is already at maximum.") + return + if not _wallet.can_afford(cost): + _set_feedback("Insufficient funds.") + return + if _network_shop == null: + _set_feedback("Purchase could not be completed.") + return + _network_shop.request_backpack_capacity_upgrade() + + func _purchase_upgrade(is_reel_speed: bool) -> void: if _transaction_in_progress or not _is_transaction_context_valid(): _set_feedback("unable to complete purchase.") @@ -1721,6 +1821,7 @@ func _on_wallet_changed(_balance: int, _delta: int) -> void: _refresh_upgrades() _refresh_supplies() _refresh_cooler_capacity() + _refresh_backpack_capacity() func _on_upgrades_changed( @@ -1738,6 +1839,10 @@ func _on_cooler_capacity_changed(_level: int, _capacity: int) -> void: _refresh_cooler_capacity() +func _on_backpack_capacity_changed(_level: int, _capacity: int) -> void: + _refresh_backpack_capacity() + + func _on_art_unlocks_changed(_unlock_mask: int) -> void: _refresh_supplies() diff --git a/ui/fishing_shop.tscn b/ui/fishing_shop.tscn index 271ad18..d3e7e66 100644 --- a/ui/fishing_shop.tscn +++ b/ui/fishing_shop.tscn @@ -1,11 +1,10 @@ -[gd_scene load_steps=9 format=3] +[gd_scene load_steps=8 format=3] [ext_resource type="Script" path="res://ui/fishing_shop.gd" id="1_script"] [ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"] [ext_resource type="Texture2D" path="res://items/icons/shop/64_speed_plus.png" id="3_reel"] [ext_resource type="Texture2D" path="res://items/icons/shop/64_power_plus.png" id="4_barrier"] [ext_resource type="Texture2D" path="res://items/icons/shop/32_currency.png" id="5_coin"] -[ext_resource type="Texture2D" path="res://items/icons/equipment/64_cooler_plus.png" id="6_cooler"] [ext_resource type="Texture2D" path="res://ui/icons/pictograms/x_light.png" id="7_close"] [ext_resource type="PackedScene" path="res://ui/components/currency_amount.tscn" id="8_currency"] @@ -269,10 +268,10 @@ mouse_filter = 2 unique_name_in_owner = true offset_right = 144.0 offset_bottom = 144.0 -tooltip_text = "cooler capacity" -icon = ExtResource("6_cooler") +tooltip_text = "storage capacity" +icon = ExtResource("7_close") expand_icon = true -icon_max_width = 128 +icon_max_width = 92 [node name="CoolerPriceBubble" type="PanelContainer" parent="ShopPanel/Margin/Layout/Body/Upgrades/UpgradeScroll/UpgradeGrid/CoolerTile"] unique_name_in_owner = true @@ -288,3 +287,32 @@ unique_name_in_owner = true layout_mode = 2 amount = 75 icon_size = 24.0 + +[node name="BackpackTile" type="Control" parent="ShopPanel/Margin/Layout/Body/Upgrades/UpgradeScroll/UpgradeGrid"] +custom_minimum_size = Vector2(144, 168) +layout_mode = 2 +mouse_filter = 2 + +[node name="BackpackPurchase" type="Button" parent="ShopPanel/Margin/Layout/Body/Upgrades/UpgradeScroll/UpgradeGrid/BackpackTile"] +unique_name_in_owner = true +offset_right = 144.0 +offset_bottom = 144.0 +tooltip_text = "backpack capacity" +icon = ExtResource("7_close") +expand_icon = true +icon_max_width = 92 + +[node name="BackpackPriceBubble" type="PanelContainer" parent="ShopPanel/Margin/Layout/Body/Upgrades/UpgradeScroll/UpgradeGrid/BackpackTile"] +unique_name_in_owner = true +z_index = 2 +offset_left = 14.0 +offset_top = 128.0 +offset_right = 130.0 +offset_bottom = 160.0 +mouse_filter = 2 + +[node name="BackpackCost" parent="ShopPanel/Margin/Layout/Body/Upgrades/UpgradeScroll/UpgradeGrid/BackpackTile/BackpackPriceBubble" instance=ExtResource("8_currency")] +unique_name_in_owner = true +layout_mode = 2 +amount = 1500 +icon_size = 24.0 diff --git a/ui/game_ui.gd b/ui/game_ui.gd index 2a1bde5..e202ec6 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -27,6 +27,10 @@ const PlayerFishingUpgradesType = preload( const ShopInteractionType = preload( "res://world/fishing_shop_interaction.gd" ) +const PlayerStorageType = preload("res://ui/player_storage.gd") +const PlayerStorageInteractionType = preload( + "res://world/player_storage_interaction.gd" +) const PlayerItemEffectsType = preload( "res://progression/player_item_effects.gd" ) @@ -134,6 +138,8 @@ const SHOP_NPC_SPEECH_COOLDOWN_MILLISECONDS: int = 5000 @onready var _pause_menu: PauseMenuType = %PauseMenu @onready var _hotbar_ui: HotbarUIType = %Hotbar @onready var _fishing_shop: FishingShopType = %FishingShop +@onready var _player_storage: PlayerStorageType = %PlayerStorage +@onready var _storage_prompt: PanelContainer = %StoragePrompt @onready var _shop_prompt: Control = %ShopPrompt @onready var _shop_prompt_bubble: PanelContainer = %ShopPromptBubble @onready var _shop_prompt_message: Label = %ShopPromptMessage @@ -174,10 +180,12 @@ var _gameplay_hud_hidden: bool = false var _fishing_spot: FishingSpotType var _system_menu_open: bool = false var _shop_open: bool = false +var _storage_open: bool = false var _chat_input_open: bool = false var _player_menu_hotbar_visible: bool = false var _main_shop_buyer: FishBuyerProfileType var _shop_interaction: ShopInteractionType +var _storage_interaction: PlayerStorageInteractionType var _surface_drawing: NetworkSurfaceDrawingService var _surface_drawing_hotbar_selected: bool = false var _experience: PlayerExperienceType @@ -294,10 +302,12 @@ func setup( fishing_spot: FishingSpotType, bag: PlayerBagType, hotbar: PlayerHotbarType, + inventory_layout: PlayerInventoryLayout, item_catalog: ItemCatalogType, main_shop_buyer: FishBuyerProfileType, fishing_upgrades: PlayerFishingUpgradesType, shop_interaction: ShopInteractionType, + storage_interaction: PlayerStorageInteractionType, item_effects: PlayerItemEffectsType, cooler_capacity: PlayerCoolerCapacityType, network_session: NetworkSessionType, @@ -398,6 +408,7 @@ func setup( fishing_spot, bag, hotbar, + inventory_layout, item_catalog, cooler_capacity, network_session, @@ -430,10 +441,28 @@ func setup( fishing_spot, shop_interaction, bag, + inventory, + hotbar, + inventory_layout, item_catalog, cooler_capacity, art_unlocks, network_shop_service, + network_sale_service, + reservations, + ) + _player_storage.setup( + player, + fishing_spot, + storage_interaction, + inventory_layout, + bag, + inventory, + hotbar, + item_catalog, + ) + _player_storage.menu_visibility_changed.connect( + _on_storage_visibility_changed ) _fishing_shop.menu_visibility_changed.connect(_on_shop_visibility_changed) _fishing_shop.menu_exit_started.connect(_on_shop_exit_started) @@ -446,6 +475,7 @@ func setup( ) _main_shop_buyer = main_shop_buyer _shop_interaction = shop_interaction + _storage_interaction = storage_interaction _surface_drawing = surface_drawing _surface_drawing_toolbar.setup(_surface_drawing, art_unlocks) set_edge_docks( @@ -505,6 +535,7 @@ func _input(event: InputEvent) -> void: and not _system_menu_open and not _player_menu_open and not _shop_open + and not _storage_open and not _chat_input_open and not _showcase_active and not _virtual_mouse_active @@ -552,6 +583,7 @@ func _can_use_character_call() -> bool: and not _system_menu_open and not _player_menu_open and not _shop_open + and not _storage_open and not _chat_input_open and not _showcase_active and not _virtual_mouse_active @@ -565,8 +597,16 @@ func _character_call_yields_to_world_interaction( ) -> bool: return ( event.is_action_pressed("interact") - and _shop_interaction != null - and _shop_interaction.is_local_player_in_range() + and ( + ( + _shop_interaction != null + and _shop_interaction.is_local_player_in_range() + ) + or ( + _storage_interaction != null + and _storage_interaction.is_local_player_in_range() + ) + ) and _fishing_spot != null and _fishing_spot.can_open_fishing_shop() and not _fishing_shop.visible @@ -614,6 +654,7 @@ func _handle_controller_chat_controls(event: InputEvent) -> bool: or _system_menu_open or _player_menu_open or _shop_open + or _storage_open ): return false var use_mapping: bool = ( @@ -825,6 +866,7 @@ func _can_start_virtual_mouse() -> bool: and not _system_menu_open and not _player_menu_open and not _shop_open + and not _storage_open and not _chat_input_open and _player != null and _fishing_spot != null @@ -1182,6 +1224,7 @@ func _can_surface_drawing_be_active() -> bool: and not _system_menu_open and not _player_menu_open and not _shop_open + and not _storage_open and not _chat_input_open and not _showcase_active and _fishing_spot != null @@ -1460,6 +1503,7 @@ func _refresh_active_bait_indicator_visibility() -> void: and not _system_menu_open and not _player_menu_open and not _shop_open + and not _storage_open ) @@ -1494,8 +1538,10 @@ func set_gameplay_ui_enabled(enabled: bool) -> void: _surface_drawing.deactivate() close_player_menu_for_session_end() _fishing_shop.close_for_session_end() + _player_storage.close_for_session_end() _fishing_panel.visible = false _shop_prompt.hide() + _storage_prompt.hide() _hotbar_ui.set_presentation_visible(false, false) _hotbar_ui.set_gameplay_input_enabled(false) else: @@ -1526,6 +1572,7 @@ func _can_toggle_gameplay_hud() -> bool: and not _system_menu_open and not _player_menu_open and not _shop_open + and not _storage_open and not _chat_input_open and not _showcase_active and not _emote_radial_menu.is_open() @@ -1540,6 +1587,7 @@ func _refresh_gameplay_hud_visibility() -> void: and not _system_menu_open and not _player_menu_open and not _shop_open + and not _storage_open ) _gameplay_transient_hud.visible = show_world_hud _experience_presentation.visible = ( @@ -1558,11 +1606,13 @@ func set_system_menu_open(is_open: bool) -> void: and not is_open and not _player_menu_open and not _shop_open + and not _storage_open ) _hotbar_ui.set_drag_enabled(_player_menu_open and not is_open) if is_open: _hotbar_ui.set_drag_enabled(false) _shop_prompt.hide() + _storage_prompt.hide() _emit_interactive_pointer_ui_changed() @@ -1570,6 +1620,53 @@ func get_fishing_shop() -> FishingShopType: return _fishing_shop +func get_player_storage() -> PlayerStorageType: + return _player_storage + + +func set_storage_prompt_visible( + requested_visible: bool, + world_anchor: Vector3 = Vector3(0.0, INF, 0.0), +) -> void: + if not _storage_prompt.has_meta(&"styled"): + _storage_prompt.set_meta(&"styled", true) + var style := UtilityPageStyle.rounded_style( + Color(UtilityPageStyle.OCEAN_PANEL_MID, 0.96), 12 + ) + style.anti_aliasing = false + _storage_prompt.add_theme_stylebox_override("panel", style) + _storage_prompt.visible = ( + requested_visible + and _gameplay_ui_enabled + and not _system_menu_open + and not _player_menu_open + and not _shop_open + and not _storage_open + ) + if _storage_prompt.visible and world_anchor.is_finite(): + _position_storage_prompt(world_anchor) + + +func _position_storage_prompt(world_anchor: Vector3) -> void: + if _player == null: + _storage_prompt.hide() + return + var camera := _player.get_gameplay_camera() + if camera == null or camera.is_position_behind(world_anchor): + _storage_prompt.hide() + return + var camera_size := camera.get_viewport().get_visible_rect().size + var ui_size := _canonical_stage.size + if camera_size.x <= 0.0 or camera_size.y <= 0.0: + _storage_prompt.hide() + return + var point := camera.unproject_position(world_anchor) * ui_size / camera_size + _storage_prompt.position = Vector2( + clampf(point.x - _storage_prompt.size.x * 0.5, 8.0, ui_size.x - _storage_prompt.size.x - 8.0), + clampf(point.y - _storage_prompt.size.y - 12.0, 8.0, ui_size.y - _storage_prompt.size.y - 8.0), + ) + + func set_shop_prompt_visible( requested_visible: bool, world_anchor: Vector3 = Vector3(0.0, INF, 0.0), @@ -1581,6 +1678,7 @@ func set_shop_prompt_visible( and not _system_menu_open and not _player_menu_open and not _shop_open + and not _storage_open ) if _shop_prompt.visible: if world_anchor.is_finite(): @@ -2216,9 +2314,30 @@ func _on_shop_visibility_changed(is_open: bool) -> void: and not is_open and not _system_menu_open and not _player_menu_open + and not _storage_open ) if is_open: _shop_prompt.hide() + _storage_prompt.hide() + _emit_interactive_pointer_ui_changed() + + +func _on_storage_visibility_changed(is_open: bool) -> void: + _storage_open = is_open + _refresh_surface_drawing_activation() + _refresh_gameplay_hud_visibility() + _refresh_chat_availability() + _refresh_hotbar_visibility() + _hotbar_ui.set_gameplay_input_enabled( + _gameplay_ui_enabled + and not is_open + and not _system_menu_open + and not _player_menu_open + and not _shop_open + ) + if is_open: + _shop_prompt.hide() + _storage_prompt.hide() _emit_interactive_pointer_ui_changed() @@ -2338,6 +2457,7 @@ func _refresh_hotbar_visibility() -> void: ) and not _system_menu_open and not _shop_open + and not _storage_open and ( not _player_menu_open or _player_menu_hotbar_visible @@ -2349,7 +2469,11 @@ func _refresh_hotbar_visibility() -> void: func _emit_interactive_pointer_ui_changed() -> void: _refresh_active_bait_indicator_visibility() interactive_pointer_ui_changed.emit( - _system_menu_open or _player_menu_open or _shop_open or _chat_input_open + _system_menu_open + or _player_menu_open + or _shop_open + or _storage_open + or _chat_input_open ) @@ -2368,6 +2492,7 @@ func _refresh_chat_availability() -> void: and not _system_menu_open and not _player_menu_open and not _shop_open + and not _storage_open ) _chat_ui.set_available(chat_available) _chat_ui.set_hud_hidden(_gameplay_hud_hidden and not _chat_input_open) diff --git a/ui/game_ui.tscn b/ui/game_ui.tscn index 53e4189..c73349f 100644 --- a/ui/game_ui.tscn +++ b/ui/game_ui.tscn @@ -13,6 +13,7 @@ [ext_resource type="PackedScene" path="res://ui/surface_drawing_toolbar.tscn" id="11_drawing_toolbar"] [ext_resource type="PackedScene" path="res://ui/quick_radial_menu.tscn" id="12_quick"] [ext_resource type="Script" path="res://ui/controller_virtual_cursor.gd" id="13_cursor"] +[ext_resource type="PackedScene" path="res://ui/player_storage.tscn" id="14_storage"] [sub_resource type="StyleBoxFlat" id="StyleBox_chase_background"] bg_color = Color(0.032, 0.118, 0.15, 1) @@ -471,6 +472,32 @@ polygon = PackedVector2Array(-8, 0, 8, 0, 0, 10) [node name="FishingShop" parent="UIRoot/CanonicalStage" instance=ExtResource("8_shop")] unique_name_in_owner = true +[node name="PlayerStorage" parent="UIRoot/CanonicalStage" instance=ExtResource("14_storage")] +unique_name_in_owner = true + +[node name="StoragePrompt" type="PanelContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD"] +unique_name_in_owner = true +visible = false +z_index = 56 +offset_right = 220.0 +offset_bottom = 50.0 +mouse_filter = 2 +theme = ExtResource("3_theme") + +[node name="Margin" type="MarginContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD/StoragePrompt"] +layout_mode = 2 +theme_override_constants/margin_left = 12 +theme_override_constants/margin_top = 7 +theme_override_constants/margin_right = 12 +theme_override_constants/margin_bottom = 7 + +[node name="Message" type="Label" parent="UIRoot/CanonicalStage/GameplayTransientHUD/StoragePrompt/Margin"] +layout_mode = 2 +text = "E open storage" +horizontal_alignment = 1 +vertical_alignment = 1 +theme_override_font_sizes/font_size = 18 + [node name="ChatUI" type="Control" parent="UIRoot"] unique_name_in_owner = true layout_mode = 1 diff --git a/ui/hotbar.gd b/ui/hotbar.gd index a32bbf3..1c41bfe 100644 --- a/ui/hotbar.gd +++ b/ui/hotbar.gd @@ -19,7 +19,9 @@ const DESKTOP_REFERENCE_SIZE := Vector2(1280.0, 720.0) const COMPACT_REFERENCE_SIZE := Vector2(640.0, 480.0) const HOTBAR_PRESENTATION_SCALE: float = 0.80 const HOTBAR_CANONICAL_POSITION := Vector2.ZERO -const HOTBAR_MENU_POSITION := Vector2(-136.0, -90.0) +# In inventory context the row intentionally straddles the panel's bottom +# edge, visually extending the storage layout into its nine quick-access slots. +const HOTBAR_MENU_POSITION := Vector2(0.0, -30.0) const HOTBAR_GAMEPLAY_Z_INDEX: int = 35 # PlayerMenu is z=30 and its authored inventory panels are relative z=50. const HOTBAR_MENU_Z_INDEX: int = 90 diff --git a/ui/logbook_catalog.gd b/ui/logbook_catalog.gd index cf0f49b..d5dc639 100644 --- a/ui/logbook_catalog.gd +++ b/ui/logbook_catalog.gd @@ -34,7 +34,7 @@ static func category_label(category: Category) -> String: Category.SHELLFISH: return "Shellfish" _: - return "Misc" + return "Insects" static func empty_state(category: Category) -> String: diff --git a/ui/mail_page.gd b/ui/mail_page.gd index 792aa81..c13b521 100644 --- a/ui/mail_page.gd +++ b/ui/mail_page.gd @@ -18,6 +18,13 @@ const FishQualityType = preload("res://fish/fish_quality.gd") const CurrencyPresentationType = preload( "res://ui/currency_presentation.gd" ) +const INBOX_ENTRY_WIDTH: float = 742.0 +const ATTACHMENT_COLUMN_X: float = 516.0 +const ATTACHMENT_COLUMN_WIDTH: float = 266.0 +const AMOUNT_FIELD_X_WITH_CURRENCY: float = 594.0 +const AMOUNT_FIELD_X_PLAIN: float = 570.0 +const AMOUNT_FIELD_WIDTH_WITH_CURRENCY: float = 130.0 +const AMOUNT_FIELD_WIDTH_PLAIN: float = 154.0 var _service: NetworkMailService var _reservations: PlayerAssetReservationService @@ -434,7 +441,7 @@ func _build_ui() -> void: root.add_child(page) _status = Label.new() _status.position = Vector2(18, 408) - _status.size = Vector2(1024, 28) + _status.size = Vector2(758, 28) _status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _status.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY @@ -449,19 +456,19 @@ func _build_inbox() -> Control: var title := Label.new() title.text = "mail" title.position = Vector2(12, 0) - title.size = Vector2(500, 42) + title.size = Vector2(300, 42) title.add_theme_font_size_override("font_size", 30) page.add_child(title) _send_mail_button = Button.new() _send_mail_button.text = "send mail" - _send_mail_button.position = Vector2(820, 0) - _send_mail_button.size = Vector2(150, 48) + _send_mail_button.position = Vector2(642, 0) + _send_mail_button.size = Vector2(140, 48) _send_mail_button.pressed.connect(_show_compose) page.add_child(_send_mail_button) _archive_view_button = Button.new() _archive_view_button.text = "archive" - _archive_view_button.position = Vector2(654, 0) - _archive_view_button.size = Vector2(154, 48) + _archive_view_button.position = Vector2(490, 0) + _archive_view_button.size = Vector2(140, 48) _archive_view_button.pressed.connect(func() -> void: _showing_archive = not _showing_archive _archive_view_button.text = "inbox" if _showing_archive else "archive" @@ -470,16 +477,16 @@ func _build_inbox() -> Control: page.add_child(_archive_view_button) var scroll := ScrollContainer.new() scroll.position = Vector2(12, 62) - scroll.size = Vector2(1036, 334) + scroll.size = Vector2(770, 334) page.add_child(scroll) _inbox_list = VBoxContainer.new() - _inbox_list.custom_minimum_size = Vector2(1008, 0) + _inbox_list.custom_minimum_size = Vector2(INBOX_ENTRY_WIDTH, 0) _inbox_list.add_theme_constant_override("separation", 8) scroll.add_child(_inbox_list) _empty_label = Label.new() _empty_label.text = "No letters yet." _empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _empty_label.custom_minimum_size = Vector2(1008, 80) + _empty_label.custom_minimum_size = Vector2(INBOX_ENTRY_WIDTH, 80) _inbox_list.add_child(_empty_label) return page @@ -494,43 +501,43 @@ func _build_compose() -> Control: page.add_child(title) _greeting = OptionButton.new() _greeting.position = Vector2(12, 48) - _greeting.size = Vector2(180, 46) + _greeting.size = Vector2(140, 46) for id: String in NetworkMailProtocol.GREETINGS: _greeting.add_item(GREETING_LABELS[id]) _greeting.set_item_metadata(_greeting.item_count - 1, id) page.add_child(_greeting) _recipient = OptionButton.new() - _recipient.position = Vector2(204, 48) - _recipient.size = Vector2(330, 46) + _recipient.position = Vector2(164, 48) + _recipient.size = Vector2(328, 46) page.add_child(_recipient) _body = TextEdit.new() _body.position = Vector2(12, 106) - _body.size = Vector2(650, 218) + _body.size = Vector2(480, 218) _body.placeholder_text = "write your letter…" _body.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY _body.text_changed.connect(_update_send_state) page.add_child(_body) _salutation = OptionButton.new() _salutation.position = Vector2(12, 336) - _salutation.size = Vector2(250, 46) + _salutation.size = Vector2(180, 46) for id: String in NetworkMailProtocol.SALUTATIONS: _salutation.add_item(SALUTATION_LABELS[id]) _salutation.set_item_metadata(_salutation.item_count - 1, id) page.add_child(_salutation) _signature = Label.new() - _signature.position = Vector2(274, 342) - _signature.size = Vector2(360, 36) + _signature.position = Vector2(204, 342) + _signature.size = Vector2(288, 36) page.add_child(_signature) _attachment_kind = OptionButton.new() - _attachment_kind.position = Vector2(688, 48) - _attachment_kind.size = Vector2(280, 46) + _attachment_kind.position = Vector2(ATTACHMENT_COLUMN_X, 48) + _attachment_kind.size = Vector2(ATTACHMENT_COLUMN_WIDTH, 46) for label: String in ["No gift", "Currency", "Fish", "Item"]: _attachment_kind.add_item(label) _attachment_kind.item_selected.connect(_refresh_attachment_choices) page.add_child(_attachment_kind) _attachment_choice = OptionButton.new() - _attachment_choice.position = Vector2(688, 106) - _attachment_choice.size = Vector2(280, 46) + _attachment_choice.position = Vector2(ATTACHMENT_COLUMN_X, 106) + _attachment_choice.size = Vector2(ATTACHMENT_COLUMN_WIDTH, 46) _attachment_choice.item_selected.connect( func(_index: int) -> void: _update_attachment_amount_limit() @@ -539,16 +546,16 @@ func _build_compose() -> Control: page.add_child(_attachment_choice) _coin_available_heading = Label.new() _coin_available_heading.text = "Available" - _coin_available_heading.position = Vector2(688, 158) + _coin_available_heading.position = Vector2(ATTACHMENT_COLUMN_X, 158) _coin_available_heading.size = Vector2(80, 28) page.add_child(_coin_available_heading) _coin_available = CurrencyPresentationType.instantiate_amount(0, 18.0) - _coin_available.position = Vector2(770, 158) - _coin_available.size = Vector2(198, 28) + _coin_available.position = Vector2(598, 158) + _coin_available.size = Vector2(184, 28) _coin_available.alignment = BoxContainer.ALIGNMENT_BEGIN page.add_child(_coin_available) _attachment_amount_currency_icon = TextureRect.new() - _attachment_amount_currency_icon.position = Vector2(746, 204) + _attachment_amount_currency_icon.position = Vector2(570, 204) _attachment_amount_currency_icon.size = Vector2(18, 18) _attachment_amount_currency_icon.texture = preload( "res://items/icons/shop/32_currency.png" @@ -563,27 +570,27 @@ func _build_compose() -> Control: _attachment_amount_currency_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE page.add_child(_attachment_amount_currency_icon) _attachment_amount = LineEdit.new() - _attachment_amount.position = Vector2(770, 190) - _attachment_amount.size = Vector2(140, 46) + _attachment_amount.position = Vector2(AMOUNT_FIELD_X_WITH_CURRENCY, 190) + _attachment_amount.size = Vector2(AMOUNT_FIELD_WIDTH_WITH_CURRENCY, 46) _attachment_amount.placeholder_text = "0" _attachment_amount.text = "0" _attachment_amount.text_changed.connect(_on_attachment_amount_changed) page.add_child(_attachment_amount) _amount_minus = Button.new() _amount_minus.text = "−" - _amount_minus.position = Vector2(688, 190) - _amount_minus.size = Vector2(48, 46) + _amount_minus.position = Vector2(ATTACHMENT_COLUMN_X, 190) + _amount_minus.size = Vector2(44, 46) _amount_minus.pressed.connect(_step_attachment_amount.bind(-1)) page.add_child(_amount_minus) _amount_plus = Button.new() _amount_plus.text = "+" - _amount_plus.position = Vector2(920, 190) + _amount_plus.position = Vector2(734, 190) _amount_plus.size = Vector2(48, 46) _amount_plus.pressed.connect(_step_attachment_amount.bind(1)) page.add_child(_amount_plus) _attachment_summary = RichTextLabel.new() - _attachment_summary.position = Vector2(688, 246) - _attachment_summary.size = Vector2(280, 78) + _attachment_summary.position = Vector2(ATTACHMENT_COLUMN_X, 246) + _attachment_summary.size = Vector2(ATTACHMENT_COLUMN_WIDTH, 78) _attachment_summary.bbcode_enabled = true _attachment_summary.fit_content = true _attachment_summary.scroll_active = false @@ -591,14 +598,14 @@ func _build_compose() -> Control: page.add_child(_attachment_summary) _compose_cancel = Button.new() _compose_cancel.text = "cancel" - _compose_cancel.position = Vector2(688, 342) - _compose_cancel.size = Vector2(126, 48) + _compose_cancel.position = Vector2(ATTACHMENT_COLUMN_X, 342) + _compose_cancel.size = Vector2(122, 48) _compose_cancel.pressed.connect(_show_inbox) page.add_child(_compose_cancel) _send_button = Button.new() _send_button.text = "send letter" - _send_button.position = Vector2(826, 342) - _send_button.size = Vector2(142, 48) + _send_button.position = Vector2(646, 342) + _send_button.size = Vector2(136, 48) _send_button.pressed.connect(_send) page.add_child(_send_button) _recipient.item_selected.connect(func(_i: int) -> void: @@ -613,12 +620,12 @@ func _build_letter() -> Control: var page := Control.new() _letter_text = Label.new() _letter_text.position = Vector2(26, 20) - _letter_text.size = Vector2(680, 350) + _letter_text.size = Vector2(500, 350) _letter_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _letter_text.vertical_alignment = VERTICAL_ALIGNMENT_TOP page.add_child(_letter_text) _letter_gift = RichTextLabel.new() - _letter_gift.position = Vector2(730, 46) + _letter_gift.position = Vector2(552, 46) _letter_gift.size = Vector2(230, 180) _letter_gift.bbcode_enabled = true _letter_gift.fit_content = true @@ -627,7 +634,7 @@ func _build_letter() -> Control: page.add_child(_letter_gift) _accept = Button.new() _accept.text = "accept gift" - _accept.position = Vector2(730, 250) + _accept.position = Vector2(552, 250) _accept.size = Vector2(230, 48) _accept.pressed.connect(func() -> void: _service.accept_gift(_current_mail_id) @@ -635,7 +642,7 @@ func _build_letter() -> Control: page.add_child(_accept) _decline = Button.new() _decline.text = "decline gift" - _decline.position = Vector2(730, 308) + _decline.position = Vector2(552, 308) _decline.size = Vector2(230, 48) _decline.pressed.connect(func() -> void: _service.decline_gift(_current_mail_id) @@ -649,13 +656,13 @@ func _build_letter() -> Control: page.add_child(_letter_close) _archive = Button.new() _archive.text = "archive" - _archive.position = Vector2(550, 370) + _archive.position = Vector2(466, 370) _archive.size = Vector2(150, 48) _archive.pressed.connect(_archive_current) page.add_child(_archive) _delete = Button.new() _delete.text = "delete" - _delete.position = Vector2(710, 370) + _delete.position = Vector2(626, 370) _delete.size = Vector2(150, 48) _delete.pressed.connect(_delete_current) page.add_child(_delete) @@ -751,7 +758,7 @@ func _refresh_inbox() -> void: empty.text = ( "No archived letters." if _showing_archive else "No letters yet." ) - empty.custom_minimum_size = Vector2(1008, 80) + empty.custom_minimum_size = Vector2(INBOX_ENTRY_WIDTH, 80) empty.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _inbox_list.add_child(empty) call_deferred("_refresh_controller_navigation") @@ -779,7 +786,7 @@ func _refresh_inbox() -> void: first_line.left(72), " · gift enclosed" if gift else "", ] - button.custom_minimum_size = Vector2(1008, 54) + button.custom_minimum_size = Vector2(INBOX_ENTRY_WIDTH, 54) button.alignment = HORIZONTAL_ALIGNMENT_LEFT button.pressed.connect(_open_letter.bind(str(letter["mail_id"]))) UtilityPageStyle.apply_ocean_button(button) @@ -826,8 +833,14 @@ func _refresh_attachment_choices(_index: int) -> void: _coin_available_heading.visible = currency_selected _coin_available.visible = currency_selected _attachment_amount_currency_icon.visible = currency_selected - _attachment_amount.position.x = 770.0 if currency_selected else 746.0 - _attachment_amount.size.x = 140.0 if currency_selected else 164.0 + _attachment_amount.position.x = ( + AMOUNT_FIELD_X_WITH_CURRENCY + if currency_selected else AMOUNT_FIELD_X_PLAIN + ) + _attachment_amount.size.x = ( + AMOUNT_FIELD_WIDTH_WITH_CURRENCY + if currency_selected else AMOUNT_FIELD_WIDTH_PLAIN + ) match _attachment_kind.selected: 0: _attachment_choice.add_item("No attachment") diff --git a/ui/player_menu.gd b/ui/player_menu.gd index de314c4..7cdedf4 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -72,11 +72,21 @@ const BagItemSpriteScene = preload( const BagStorageSlotType = preload( "res://ui/components/bubble_menu/bag_storage_slot.gd" ) +const GeneralInventoryGridType = preload( + "res://ui/components/general_inventory_grid.gd" +) +const GeneralInventorySlotType = preload( + "res://ui/components/general_inventory_slot.gd" +) const DESKTOP_REFERENCE_SIZE := Vector2(1280.0, 720.0) const NAVIGATION_PRESENTATION_SCALE: float = 0.60 -const NAVIGATION_CANONICAL_POSITION := Vector2(424.0, 44.0) +const NAVIGATION_REFERENCE_SIZE := Vector2(840.0, 100.0) +const NAVIGATION_CANONICAL_POSITION := Vector2( + (DESKTOP_REFERENCE_SIZE.x + - NAVIGATION_REFERENCE_SIZE.x * NAVIGATION_PRESENTATION_SCALE) * 0.5, + 44.0, +) const NAVIGATION_SELECTED_SCALE: float = 1.02 -const INVENTORY_TAB_LEFT_INSET: float = 96.0 const MAIN_SHOP_BUYER_ID: StringName = &"main_fishing_shop" signal menu_visibility_changed(is_open: bool) @@ -134,18 +144,25 @@ enum ControllerOwnership { PAGE_CONTENT, } -const INVENTORY_MAIN_POSITION := Vector2(54.0, 166.0) +const COOLER_MAIN_POSITION := Vector2(54.0, 166.0) +const COOLER_MAIN_SIZE := Vector2(882.0, 484.0) +const COOLER_NOTEPAD_POSITION := Vector2(952.0, 166.0) const INVENTORY_MAIN_SIZE := Vector2(882.0, 484.0) -const INVENTORY_PANEL_GAP := 16.0 -const INVENTORY_NOTEPAD_POSITION := Vector2( - INVENTORY_MAIN_POSITION.x + INVENTORY_MAIN_SIZE.x + INVENTORY_PANEL_GAP, - INVENTORY_MAIN_POSITION.y, +const INVENTORY_MAIN_POSITION := Vector2( + (DESKTOP_REFERENCE_SIZE.x - INVENTORY_MAIN_SIZE.x) * 0.5, + 166.0, ) const INVENTORY_NOTEPAD_SIZE := Vector2(278.0, 484.0) +const INVENTORY_NOTEPAD_POSITION := Vector2( + INVENTORY_MAIN_POSITION.x + + (INVENTORY_MAIN_SIZE.x - INVENTORY_NOTEPAD_SIZE.x) * 0.5, + INVENTORY_MAIN_POSITION.y, +) const INVENTORY_MAIN_CORNER_RADIUS: int = 58 const INVENTORY_INNER_CORNER_RADIUS: int = 45 -const TACKLE_GRID_COLUMNS: int = 3 -const BAG_STORAGE_COLUMNS: int = 5 +const TACKLE_GRID_COLUMNS: int = 4 +const INVENTORY_VISIBLE_TAB_WIDTH: float = 124.0 * 2.0 + 6.0 +const BAG_STORAGE_COLUMNS: int = PlayerInventoryLayout.INVENTORY_COLUMNS const BAG_STORAGE_MINIMUM_SLOTS: int = 15 const SALE_CONFIRMATION_SIZE := Vector2(520.0, 190.0) const CONTROLLER_PICKUP_HOLD_SECONDS: float = 0.42 @@ -211,10 +228,15 @@ const LIGHT_COOLER_WATER_COLOR := Color(0.037, 0.27, 0.375, 1.0) @onready var _bag_host: Control = %BagHost @onready var _bag_item_field: Control = %BagItemField @onready var _bag_empty_state: Label = %BagEmptyState +@onready var _bag_modal_blocker: Control = %BagModalBlocker @onready var _bag_detail_constellation: Control = %BagDetailConstellation @onready var _bag_sprite_detail_texture: TextureRect = %BagSpriteDetailTexture @onready var _bag_sprite_detail_name: Label = %BagSpriteDetailName @onready var _bag_sprite_detail_data: Label = %BagSpriteDetailData +@onready var _bag_detail_actions: HBoxContainer = %BagDetailActions +@onready var _bag_favorite_button: NotepadInkActionType = %BagFavoriteButton +@onready var _bag_sell_button: NotepadInkActionType = %BagSellButton +@onready var _tackle_modal_blocker: Control = %TackleModalBlocker @onready var _logbook_page: Control = %LogbookPage @onready var _catalog_logbook: LogbookPage = %CatalogLogbook @onready var _the_net_page: TheNetPage = %TheNetPage @@ -262,9 +284,10 @@ var _fishing_spot: FishingSpotType var _bag: PlayerBagType var _hotbar: PlayerHotbarType var _item_catalog: ItemCatalogType +var _inventory_layout: PlayerInventoryLayout var _cooler_capacity: PlayerCoolerCapacityType -var _current_section: Section = Section.COOLER -var _last_inventory_section: Section = Section.COOLER +var _current_section: Section = Section.BAG +var _last_inventory_section: Section = Section.BAG var _bag_view: BagView = BagView.EQUIPMENT var _selected_tackle_item_id: StringName var _tackle_item_buttons: Dictionary[StringName, Button] = {} @@ -321,6 +344,15 @@ var _cooler_slot_nodes: Array[Panel] = [] var _sorted_catches: Array[FishCatchType] = [] var _bag_item_nodes: Dictionary[StringName, BagItemSpriteType] = {} var _bag_slot_nodes: Array[BagStorageSlotType] = [] +var _general_inventory_grid: GeneralInventoryGridType +var _selected_inventory_kind: int = -1 +var _selected_inventory_identity: StringName +var _inventory_move_kind: int = -1 +var _inventory_move_identity: StringName +var _tackle_move_identity: StringName +var _context_tooltip: PanelContainer +var _context_tooltip_label: Label +var _context_tooltip_source: Control var _sorted_bag_items: Array[OwnedItemType] = [] var _bag_drag_active: bool = false var _motion_elapsed: float = 0.0 @@ -335,12 +367,37 @@ func _ready() -> void: _cooler_original_index = _cooler_page.get_index() _confirmation_original_parent = _sale_confirmation.get_parent() _confirmation_original_index = _sale_confirmation.get_index() + _cooler_sub_tab.visible = false + _items_sub_tab.visible = false + _bag_sub_tab.text = "Inventory" + _general_inventory_grid = GeneralInventoryGridType.new() + _general_inventory_grid.name = "GeneralInventoryGrid" + _general_inventory_grid.set_slot_presentation(Vector2(78.0, 78.0), 10) + _general_inventory_grid.slot_activated.connect( + _on_general_inventory_slot_activated + ) + _general_inventory_grid.context_requested.connect( + _open_general_inventory_notepad + ) + _general_inventory_grid.context_changed.connect( + _on_inventory_context_changed + ) + _bag_item_field.add_child(_general_inventory_grid) + _create_inventory_context_tooltip() _inventory_tab.pressed.connect(_show_last_inventory_section) _cooler_sub_tab.pressed.connect(_show_section.bind(Section.COOLER)) _bag_sub_tab.pressed.connect(_show_bag_view.bind(BagView.EQUIPMENT)) _items_sub_tab.pressed.connect(_show_bag_view.bind(BagView.CONSUMABLES)) _tackle_sub_tab.pressed.connect(_show_section.bind(Section.TACKLE_BOX)) _tackle_equip_button.pressed.connect(_toggle_active_tackle) + _bag_favorite_button.pressed.connect(_on_bag_favorite_pressed) + _bag_sell_button.pressed.connect(_on_bag_sell_pressed) + _bag_detail_constellation.visible = false + _tackle_detail_panel.visible = false + _bag_detail_constellation.z_index = 120 + _tackle_detail_panel.z_index = 120 + _bag_modal_blocker.z_index = 110 + _tackle_modal_blocker.z_index = 110 _logbook_tab.pressed.connect( _show_section.bind(Section.LOGBOOK) ) @@ -477,6 +534,7 @@ func setup( fishing_spot: FishingSpotType, bag: PlayerBagType, hotbar: PlayerHotbarType, + inventory_layout: PlayerInventoryLayout, item_catalog: ItemCatalogType, cooler_capacity: PlayerCoolerCapacityType, network_session: NetworkSessionType, @@ -501,6 +559,7 @@ func setup( _fishing_spot = fishing_spot _bag = bag _hotbar = hotbar + _inventory_layout = inventory_layout _item_catalog = item_catalog _cooler_capacity = cooler_capacity _network_session = network_session @@ -552,10 +611,23 @@ func setup( _player.active_lure_changed.connect(_on_active_lure_changed) if not _hotbar.slots_changed.is_connected(_on_hotbar_changed): _hotbar.slots_changed.connect(_on_hotbar_changed) + if ( + _inventory_layout != null + and not _inventory_layout.layout_changed.is_connected(_refresh_bag) + ): + _inventory_layout.layout_changed.connect(_refresh_bag) if not _cooler_capacity.capacity_changed.is_connected( _on_cooler_capacity_changed ): _cooler_capacity.capacity_changed.connect(_on_cooler_capacity_changed) + _general_inventory_grid.setup( + _inventory_layout, + _bag, + _inventory, + _hotbar, + _item_catalog, + PlayerInventoryLayout.InventoryContainer.INVENTORY, + ) _refresh_all() @@ -635,6 +707,9 @@ func _handle_controller_ownership_input(event: InputEvent) -> bool: ) ) if cancel_pressed: + if _controller_ownership == ControllerOwnership.ITEM_LIST: + if _cancel_active_item_move(): + return true return _consume_player_menu_back() if _controller_ownership == ControllerOwnership.INVENTORY_TABS: if accept_pressed: @@ -699,6 +774,17 @@ func _handle_controller_ownership_input(event: InputEvent) -> bool: if _controller_ownership == ControllerOwnership.SORT_FILTER: return false if _controller_ownership == ControllerOwnership.ITEM_LIST: + if ( + button_event != null + and button_event.pressed + and _event_matches_controller_role( + event, + ControllerMappingManagerType.ROLE_Y, + JOY_BUTTON_Y, + ) + ): + _cancel_active_item_move() + return _try_enter_notepad_controller_ownership() if ( event.is_action_pressed("ui_down") and _controller_focus_is_on_last_inventory_row() @@ -760,14 +846,18 @@ func _dispatch_active_page_controller_input(event: InputEvent) -> bool: func _activate_inventory_selection() -> void: - if _try_enter_notepad_controller_ownership(): - return - if _current_section != Section.BAG: - return var focus_owner: Control = get_viewport().gui_get_focus_owner() - var item_node := focus_owner as BagItemSpriteType - if item_node != null and not item_node.item_id.is_empty(): - _select_bag_item(item_node.item_id) + if _current_section == Section.BAG: + var slot := focus_owner as GeneralInventorySlotType + if slot != null: + _on_general_inventory_slot_activated(slot) + elif _current_section == Section.TACKLE_BOX: + if focus_owner != null and focus_owner.has_meta( + &"controller_tackle_item_id" + ): + _activate_tackle_move(StringName(str( + focus_owner.get_meta(&"controller_tackle_item_id") + ))) func _cancel_controller_accept_hold() -> void: @@ -813,18 +903,28 @@ func _try_enter_notepad_controller_ownership() -> bool: _select_tackle_item(source_identity) if _tackle_equip_button.visible and not _tackle_equip_button.disabled: actions.append(_tackle_equip_button) + elif _current_section == Section.BAG: + var inventory_slot := focus_owner as GeneralInventorySlotType + if inventory_slot == null or inventory_slot.entry_identity.is_empty(): + return false + source_identity = inventory_slot.entry_identity + _on_general_inventory_entry_selected( + inventory_slot.entry_kind, + inventory_slot.entry_identity, + ) + actions = _get_bag_notepad_actions() else: return false - if actions.is_empty(): - _restore_controller_item_focus(_current_section, source_identity) - return true + _set_inventory_notepad_visible(_current_section, true) _controller_source_section = _current_section _controller_source_identity = source_identity _controller_notepad_actions = actions _controller_ownership = ControllerOwnership.NOTEPAD_ACTIONS _apply_inventory_controller_zone_focus_modes() - _configure_controller_notepad_action_focus(actions) - actions.front().call_deferred("grab_focus") + _refresh_inventory_modal_interactivity() + if not actions.is_empty(): + _configure_controller_notepad_action_focus(actions) + actions.front().call_deferred("grab_focus") return true @@ -874,29 +974,13 @@ func _controller_focus_is_on_last_inventory_row() -> bool: and fish_index + fish_columns >= _sorted_catches.size() ) if _current_section == Section.BAG: - var item_node := focus_owner as BagItemSpriteType - if item_node == null: + var slot := focus_owner as GeneralInventorySlotType + if slot == null: return false - if _bag == null: - var legacy_index: int = _sorted_bag_items.find_custom( - func(owned: OwnedItemType) -> bool: - return owned.item_id == item_node.item_id - ) - return ( - legacy_index >= 0 - and legacy_index + BAG_STORAGE_COLUMNS - >= _sorted_bag_items.size() - ) - var item_slot: int = ( - _bag.get_storage_slot(item_node.item_id) - ) - var maximum_slot: int = -1 - for owned: OwnedItemType in _sorted_bag_items: - maximum_slot = maxi(maximum_slot, owned.storage_slot) return ( - item_slot >= 0 - and item_slot / BAG_STORAGE_COLUMNS - >= maximum_slot / BAG_STORAGE_COLUMNS + slot.slot_index / BAG_STORAGE_COLUMNS + >= (_inventory_layout.get_inventory_capacity() - 1) + / BAG_STORAGE_COLUMNS ) return false @@ -912,10 +996,10 @@ func _enter_controller_hotbar_management() -> bool: return false source_identity = fish_node.catch_id else: - var item_node := focus_owner as BagItemSpriteType - if item_node == null: + var slot := focus_owner as GeneralInventorySlotType + if slot == null: return false - source_identity = item_node.item_id + source_identity = slot.entry_identity if source_identity.is_empty(): return false _controller_source_section = _current_section @@ -946,18 +1030,20 @@ func _try_begin_controller_hotbar_placement() -> bool: assignment_kind = PlayerHotbarType.AssignmentKind.FISH identity = fish_node.catch_id elif _current_section == Section.BAG: - var item_node := focus_owner as BagItemSpriteType - if item_node == null or item_node.item_id.is_empty(): + var slot := focus_owner as GeneralInventorySlotType + if slot == null or slot.entry_identity.is_empty(): return false - var item: ItemDataType = ( - _item_catalog.get_item_by_id(item_node.item_id) - if _item_catalog != null - else null - ) - if item == null or not item.hotbar_allowed: - return true - assignment_kind = PlayerHotbarType.AssignmentKind.ITEM - identity = item_node.item_id + identity = slot.entry_identity + if slot.entry_kind == PlayerInventoryLayout.EntryKind.CATCH: + assignment_kind = PlayerHotbarType.AssignmentKind.FISH + else: + var item: ItemDataType = ( + _item_catalog.get_item_by_id(identity) + if _item_catalog != null else null + ) + if item == null or not item.hotbar_allowed: + return true + assignment_kind = PlayerHotbarType.AssignmentKind.ITEM else: return false return _begin_controller_hotbar_placement(assignment_kind, identity) @@ -1136,7 +1222,10 @@ func _release_controller_ownership( _controller_hotbar_assignment_kind = PlayerHotbarType.AssignmentKind.EMPTY _controller_hotbar_identity = StringName() _controller_storage_identity = StringName() + if prior_ownership == ControllerOwnership.NOTEPAD_ACTIONS: + _set_inventory_notepad_visible(source_section, false) _apply_inventory_controller_zone_focus_modes() + _refresh_inventory_modal_interactivity() if prior_ownership == ControllerOwnership.HOTBAR_PLACEMENT: if restore_previous_hotbar_slot and _hotbar != null: _hotbar.select_slot(_controller_previous_hotbar_slot) @@ -1167,7 +1256,11 @@ func _restore_controller_item_focus( if section == Section.COOLER: target = _fish_nodes.get(identity) as CoolerFishSpriteType elif section == Section.BAG: - target = _bag_item_nodes.get(identity) as BagItemSpriteType + if _general_inventory_grid != null: + for slot: GeneralInventorySlotType in _general_inventory_grid.get_slots(): + if slot.entry_identity == identity: + target = slot + break elif section == Section.TACKLE_BOX: target = _tackle_item_buttons.get(identity) as Button if ( @@ -1202,6 +1295,8 @@ func _handle_controller_page_switch(event: InputEvent) -> bool: ) if not (uses_left_bumper or uses_right_bumper): return false + if _controller_ownership == ControllerOwnership.NOTEPAD_ACTIONS: + return true if not button_event.pressed: return true if ( @@ -1239,28 +1334,21 @@ func _handle_controller_page_switch(event: InputEvent) -> bool: func _get_inventory_tab_index() -> int: - if _current_section == Section.COOLER: - return 0 - if _current_section == Section.BAG: - return 2 if _bag_view == BagView.CONSUMABLES else 1 - return 3 + return 1 if _current_section == Section.TACKLE_BOX else 0 func _show_inventory_tab(index: int) -> void: - match clampi(index, 0, 3): + match clampi(index, 0, 1): 0: - _show_section(Section.COOLER) - 1: - _show_bag_view(BagView.EQUIPMENT) - 2: - _show_bag_view(BagView.CONSUMABLES) + _bag_view = BagView.EQUIPMENT + _show_section(Section.BAG) _: _show_section(Section.TACKLE_BOX) func _switch_inventory_tab_direction(direction: int) -> void: var current_index: int = _get_inventory_tab_index() - var target_index: int = clampi(current_index + direction, 0, 3) + var target_index: int = clampi(current_index + direction, 0, 1) if target_index == current_index: return _show_inventory_tab(target_index) @@ -1376,6 +1464,7 @@ func _handle_direct_page_shortcut(event: InputEvent) -> bool: func _shortcut_blocked_by_modal_state() -> bool: return ( _sale_confirmation.visible + or _controller_ownership == ControllerOwnership.NOTEPAD_ACTIONS or ( _current_section == Section.MAIL and _mail_page.is_composing_letter() @@ -1451,6 +1540,10 @@ func open_menu() -> void: ): return _release_controller_ownership(false, true) + _set_inventory_notepad_visible(Section.BAG, false) + _set_inventory_notepad_visible(Section.TACKLE_BOX, false) + _cancel_active_item_move() + _hide_inventory_context_tooltip() _menu_generation += 1 _transition_generation += 1 _cancel_presentation_tween() @@ -1514,8 +1607,8 @@ func mount_shop_cooler( _page_transitioning = false _sale_buyer_override = buyer _shop_cooler_context_active = true - _current_section = Section.COOLER - _last_inventory_section = Section.COOLER + _current_section = Section.BAG + _last_inventory_section = Section.BAG _cooler_page.reparent(host, false) _sale_confirmation.reparent(host, false) _cooler_page.position = Vector2.ZERO @@ -1686,6 +1779,8 @@ func _show_section(section: Section) -> void: or get_viewport().gui_is_dragging() ): return + _cancel_active_item_move() + _hide_inventory_context_tooltip() if ( _current_section == Section.PROFILE and _profile_page.request_close_confirmation() @@ -1811,7 +1906,11 @@ func _settle_inventory_tabs_for_close() -> void: func _show_last_inventory_section() -> void: - _show_section(_last_inventory_section) + _show_section( + Section.TACKLE_BOX + if _last_inventory_section == Section.TACKLE_BOX + else Section.BAG + ) func _focus_current_section() -> void: @@ -1839,9 +1938,12 @@ func _inventory_content_focus_candidates() -> Array[Control]: if ControllerFocusNavigationType.is_focusable(fish_node): candidates.append(fish_node) Section.BAG: - for item_node: BagItemSpriteType in _bag_item_nodes.values(): - if ControllerFocusNavigationType.is_focusable(item_node): - candidates.append(item_node) + if _general_inventory_grid != null: + for slot: GeneralInventorySlotType in ( + _general_inventory_grid.get_slots() + ): + if ControllerFocusNavigationType.is_focusable(slot): + candidates.append(slot) Section.TACKLE_BOX: for tackle_button: Button in _tackle_item_buttons.values(): if ControllerFocusNavigationType.is_focusable(tackle_button): @@ -1863,10 +1965,7 @@ func _process(delta: float) -> void: >= CONTROLLER_PICKUP_HOLD_SECONDS ): _cancel_controller_accept_hold() - if _current_section == Section.BAG: - _try_begin_controller_storage_placement() - else: - _try_begin_controller_hotbar_placement() + _try_begin_controller_hotbar_placement() _motion_elapsed += delta if visible: _navigation_cluster.advance_motion(delta) @@ -1928,12 +2027,7 @@ func _set_descendant_focus_disabled(root: Node) -> void: func _reserve_visible_secondary_navigation() -> void: if not _is_inventory_section(_current_section): return - var inventory_tabs: Array[Button] = [ - _cooler_sub_tab, - _bag_sub_tab, - _items_sub_tab, - _tackle_sub_tab, - ] + var inventory_tabs: Array[Button] = [_bag_sub_tab, _tackle_sub_tab] _set_inventory_tab_focus_enabled( _content_interactive_enabled and visible @@ -1957,12 +2051,9 @@ func _reserve_visible_secondary_navigation() -> void: func _set_inventory_tab_focus_enabled(enabled: bool) -> void: - for tab: Button in [ - _cooler_sub_tab, - _bag_sub_tab, - _items_sub_tab, - _tackle_sub_tab, - ]: + _cooler_sub_tab.focus_mode = Control.FOCUS_NONE + _items_sub_tab.focus_mode = Control.FOCUS_NONE + for tab: Button in [_bag_sub_tab, _tackle_sub_tab]: tab.focus_mode = ( Control.FOCUS_ALL if enabled else Control.FOCUS_NONE ) @@ -2040,6 +2131,21 @@ func _apply_inventory_controller_zone_focus_modes() -> void: ) else Control.FOCUS_NONE ) + for action: NotepadInkActionType in [ + _bag_favorite_button, + _bag_sell_button, + ]: + action.focus_mode = ( + Control.FOCUS_ALL + if ( + notepad_active + and _current_section == Section.BAG + and _bag_detail_constellation.visible + and action.visible + and not action.disabled + ) + else Control.FOCUS_NONE + ) for item_node: BagItemSpriteType in _bag_item_nodes.values(): item_node.focus_mode = ( Control.FOCUS_ALL @@ -2058,6 +2164,13 @@ func _apply_inventory_controller_zone_focus_modes() -> void: ) else 1.0 ) + if _general_inventory_grid != null: + for slot: GeneralInventorySlotType in _general_inventory_grid.get_slots(): + slot.focus_mode = ( + Control.FOCUS_ALL + if content_active and _current_section == Section.BAG + else Control.FOCUS_NONE + ) for slot: BagStorageSlotType in _bag_slot_nodes: if not is_instance_valid(slot): continue @@ -2090,17 +2203,7 @@ func _apply_inventory_controller_zone_focus_modes() -> void: func _inventory_tab_for_section(section: Section) -> Button: - match section: - Section.COOLER: - return _cooler_sub_tab - Section.BAG: - return ( - _items_sub_tab - if _bag_view == BagView.CONSUMABLES - else _bag_sub_tab - ) - _: - return _tackle_sub_tab + return _tackle_sub_tab if section == Section.TACKLE_BOX else _bag_sub_tab func _configure_sale_confirmation_focus() -> void: @@ -2187,6 +2290,118 @@ func _apply_inventory_styles() -> void: UtilityPageStyle.apply_ocean_button(_cancel_sale_button) +func _create_inventory_context_tooltip() -> void: + _context_tooltip = PanelContainer.new() + _context_tooltip.name = "InventoryContextTooltip" + _context_tooltip.visible = false + _context_tooltip.z_index = 140 + _context_tooltip.mouse_filter = Control.MOUSE_FILTER_IGNORE + _context_tooltip.custom_minimum_size = Vector2(310.0, 0.0) + var bubble_style := UtilityPageStyle.rounded_style( + Color(UtilityPageStyle.OCEAN_PANEL_DEEP, 0.98), + 14, + ) + bubble_style.set_border_width_all(0) + bubble_style.content_margin_left = 14.0 + bubble_style.content_margin_top = 10.0 + bubble_style.content_margin_right = 14.0 + bubble_style.content_margin_bottom = 10.0 + _context_tooltip.add_theme_stylebox_override("panel", bubble_style) + _context_tooltip_label = Label.new() + _context_tooltip_label.custom_minimum_size = Vector2(282.0, 0.0) + _context_tooltip_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _context_tooltip_label.add_theme_font_override( + "font", + UtilityPageStyle.TuffyFont, + ) + _context_tooltip_label.add_theme_font_size_override("font_size", 16) + _context_tooltip_label.add_theme_color_override( + "font_color", + UtilityPageStyle.OCEAN_TEXT_PRIMARY, + ) + _context_tooltip_label.mouse_filter = Control.MOUSE_FILTER_IGNORE + _context_tooltip.add_child(_context_tooltip_label) + _presentation_scale_root.add_child(_context_tooltip) + + +func _on_inventory_context_changed( + source: GeneralInventorySlotType, + text: String, + active: bool, +) -> void: + if active: + _show_inventory_context_tooltip(source, text) + else: + _hide_inventory_context_tooltip_for(source) + + +func _show_inventory_context_tooltip(source: Control, text: String) -> void: + if ( + source == null + or text.strip_edges().is_empty() + or _controller_ownership == ControllerOwnership.NOTEPAD_ACTIONS + ): + return + _context_tooltip_source = source + _context_tooltip_label.text = text + _context_tooltip.visible = true + _context_tooltip.reset_size() + call_deferred("_position_inventory_context_tooltip", source) + + +func _position_inventory_context_tooltip(source: Control) -> void: + if ( + not _context_tooltip.visible + or source == null + or not is_instance_valid(source) + or source != _context_tooltip_source + ): + return + _context_tooltip.reset_size() + var inverse := ( + _presentation_scale_root.get_global_transform_with_canvas().affine_inverse() + ) + var source_global: Rect2 = source.get_global_rect() + var source_top_left: Vector2 = inverse * source_global.position + var source_bottom_right: Vector2 = inverse * source_global.end + var source_rect := Rect2( + source_top_left, + source_bottom_right - source_top_left, + ) + var bubble_size: Vector2 = _context_tooltip.size + var x: float = source_rect.end.x + 12.0 + if x + bubble_size.x > DESKTOP_REFERENCE_SIZE.x - 18.0: + x = source_rect.position.x - bubble_size.x - 12.0 + var y: float = source_rect.get_center().y - bubble_size.y * 0.5 + _context_tooltip.position = Vector2( + clampf(x, 18.0, DESKTOP_REFERENCE_SIZE.x - bubble_size.x - 18.0), + clampf(y, 112.0, DESKTOP_REFERENCE_SIZE.y - bubble_size.y - 24.0), + ) + + +func _hide_inventory_context_tooltip_for(source: Control) -> void: + if source != _context_tooltip_source: + return + if ( + source != null + and ( + source.has_focus() + or source.get_global_rect().has_point( + get_viewport().get_mouse_position() + ) + ) + ): + return + _hide_inventory_context_tooltip() + + +func _hide_inventory_context_tooltip() -> void: + if _context_tooltip == null: + return + _context_tooltip.visible = false + _context_tooltip_source = null + + func _show_bag_view(view: BagView) -> void: if ( _transitioning @@ -2269,15 +2484,9 @@ func _populate_tackle_column( continue var button := Button.new() button.icon = item.icon - button.tooltip_text = ( - "%s • %d/%d" % [ - item.display_name, - owned.quantity, - item.max_stack, - ] - if item.is_bait() - else item.display_name - ) + var context_text: String = _tackle_context_text(item, owned.quantity) + button.tooltip_text = "" + button.set_meta(&"inventory_context_text", context_text) button.custom_minimum_size = Vector2(72, 72) button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN button.expand_icon = item.icon != null @@ -2296,9 +2505,107 @@ func _populate_tackle_column( item.max_stack, &"QuantityBadge", ) - button.pressed.connect(_select_tackle_item.bind(owned.item_id)) + button.pressed.connect(_activate_tackle_move.bind(owned.item_id)) + button.gui_input.connect( + _on_tackle_button_gui_input.bind(owned.item_id, button) + ) + button.focus_entered.connect( + _show_inventory_context_tooltip.bind( + button, + context_text, + ) + ) + button.focus_exited.connect( + _hide_inventory_context_tooltip_for.bind(button) + ) + button.mouse_entered.connect( + _show_inventory_context_tooltip.bind(button, context_text) + ) + button.mouse_exited.connect( + _hide_inventory_context_tooltip_for.bind(button) + ) item_list.add_child(button) _tackle_item_buttons[owned.item_id] = button + _refresh_tackle_move_presentation() + + +func _tackle_context_text(item: ItemDataType, quantity: int) -> String: + var lines: Array[String] = [item.display_name] + var quantity_text := str(quantity) + if item.is_bait(): + quantity_text = "%d/%d" % [quantity, item.max_stack] + lines.append( + "%s • quantity %s" % [item.get_category_name(), quantity_text] + ) + if not item.description.strip_edges().is_empty(): + lines.append(item.description.strip_edges()) + return "\n".join(lines) + + +func _activate_tackle_move(item_id: StringName) -> void: + if _bag == null or item_id.is_empty(): + return + var target_slot: int = _bag.get_storage_slot(item_id) + if _tackle_move_identity.is_empty(): + if target_slot < 0: + return + _tackle_move_identity = item_id + _selected_tackle_item_id = item_id + _update_tackle_detail() + _refresh_tackle_move_presentation() + var button := _tackle_item_buttons.get(item_id) as Button + if button != null: + _show_inventory_context_tooltip( + button, + "moving %s\nchoose another %s • B cancels" % [ + button.accessibility_name + if not button.accessibility_name.is_empty() + else str( + button.get_meta(&"inventory_context_text", "item") + ).get_slice("\n", 0), + "bait" if _item_catalog.get_item_by_id(item_id).is_bait() + else "lure", + ], + ) + return + var source_id: StringName = _tackle_move_identity + _tackle_move_identity = StringName() + _refresh_tackle_move_presentation() + _hide_inventory_context_tooltip() + if source_id == item_id or target_slot < 0: + return + _bag.move_item_to_storage_slot(source_id, target_slot) + + +func _refresh_tackle_move_presentation() -> void: + for item_id: StringName in _tackle_item_buttons: + var button: Button = _tackle_item_buttons[item_id] + button.modulate = ( + Color(1.0, 1.0, 1.0, 0.42) + if item_id == _tackle_move_identity + else Color.WHITE + ) + + +func _on_tackle_button_gui_input( + event: InputEvent, + item_id: StringName, + button: Button, +) -> void: + var mouse_event := event as InputEventMouseButton + if ( + mouse_event == null + or mouse_event.button_index != MOUSE_BUTTON_RIGHT + or not mouse_event.pressed + ): + return + _cancel_active_item_move() + _select_tackle_item(item_id) + var actions: Array[BaseButton] = [] + if _tackle_equip_button.visible and not _tackle_equip_button.disabled: + actions.append(_tackle_equip_button) + _open_inventory_notepad(Section.TACKLE_BOX, item_id, actions) + button.accept_event() func _configure_tackle_item_focus() -> void: @@ -2465,14 +2772,19 @@ func _tackle_is_interactive() -> bool: func _apply_tackle_interactivity(interactive: bool) -> void: + var item_interactive: bool = ( + interactive + and _controller_ownership != ControllerOwnership.NOTEPAD_ACTIONS + ) for button: Button in _tackle_item_buttons.values(): button.mouse_filter = ( Control.MOUSE_FILTER_STOP - if interactive + if item_interactive else Control.MOUSE_FILTER_IGNORE ) var action_interactive: bool = ( interactive + and _tackle_detail_panel.visible and _tackle_equip_button.visible and not _tackle_equip_button.disabled ) @@ -2648,9 +2960,7 @@ func _update_shell_layout() -> void: _presentation_scale_root.scale = Vector2.ONE _presentation_scale_root.position = Vector2.ZERO _presentation_scale_root.pivot_offset = reference_size * 0.5 - var navigation_size := ( - Vector2(840.0, 75.0) if compact else Vector2(840.0, 100.0) - ) + var navigation_size := NAVIGATION_REFERENCE_SIZE _navigation_cluster.position = NAVIGATION_CANONICAL_POSITION _navigation_cluster.size = navigation_size _navigation_cluster.scale = ( @@ -2660,16 +2970,15 @@ func _update_shell_layout() -> void: _cooler_page.size = reference_size _cooler_page.position = Vector2.ZERO _cooler_rest_position = Vector2.ZERO - # The Bag shell has the widest 88 px upper-left curve. Eight more pixels - # place every tab flange behind a straight section of the shared panel. - _inventory_sub_tabs.position = ( - INVENTORY_MAIN_POSITION - + Vector2(INVENTORY_TAB_LEFT_INSET, -30.0) + _inventory_sub_tabs.position = Vector2( + (DESKTOP_REFERENCE_SIZE.x - INVENTORY_VISIBLE_TAB_WIDTH) * 0.5, + INVENTORY_MAIN_POSITION.y - 30.0, ) + _inventory_sub_tabs.size = Vector2(INVENTORY_VISIBLE_TAB_WIDTH, 38.0) _layout_cooler_fish(false) - _cooler_outer_wall.position = INVENTORY_MAIN_POSITION - _cooler_outer_wall.size = INVENTORY_MAIN_SIZE - _detail_constellation.position = INVENTORY_NOTEPAD_POSITION + _cooler_outer_wall.position = COOLER_MAIN_POSITION + _cooler_outer_wall.size = COOLER_MAIN_SIZE + _detail_constellation.position = COOLER_NOTEPAD_POSITION _detail_constellation.size = INVENTORY_NOTEPAD_SIZE _notepad_binding.position = Vector2(44.0, 1.0) if compact else Vector2(28.0, 4.0) _notepad_binding.size = Vector2(218.0, 20.0) if compact else Vector2(232.0, 27.0) @@ -2790,10 +3099,11 @@ func _update_shell_layout() -> void: _tackle_rest_position = Vector2.ZERO _bag_outer_wall.position = INVENTORY_MAIN_POSITION _bag_outer_wall.size = INVENTORY_MAIN_SIZE - _bag_host.custom_minimum_size = ( - Vector2(520.0, 152.0) if compact else Vector2(788.0, 358.0) - ) + # ScrollContainer reserves an 8 px vertical gutter even while disabled. + # Account for it so the authored 882 px outer panel stays exact. + _bag_host.custom_minimum_size = Vector2(810.0, 420.0) _bag_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + call_deferred("_layout_general_inventory_grid") _bag_detail_constellation.position = INVENTORY_NOTEPAD_POSITION _bag_detail_constellation.size = INVENTORY_NOTEPAD_SIZE _bag_sprite_detail_texture.custom_minimum_size = ( @@ -2811,6 +3121,8 @@ func _update_shell_layout() -> void: _tackle_main_panel.size = INVENTORY_MAIN_SIZE _tackle_detail_panel.position = INVENTORY_NOTEPAD_POSITION _tackle_detail_panel.size = INVENTORY_NOTEPAD_SIZE + _bait_item_list.columns = TACKLE_GRID_COLUMNS + _lure_item_list.columns = TACKLE_GRID_COLUMNS _logbook_page.size = reference_size _logbook_page.position = Vector2.ZERO _logbook_rest_position = Vector2.ZERO @@ -2855,7 +3167,6 @@ func _update_shell_layout() -> void: _profile_page.modulate.a = 1.0 _players_page.modulate.a = 1.0 _layout_cooler_fish(false) - _layout_bag_items() func _layout_cooler_detail_text(compact: bool) -> void: @@ -3028,6 +3339,10 @@ func _finish_close( _transitioning = false _page_transitioning = false _bag_drag_active = false + _cancel_active_item_move() + _set_inventory_notepad_visible(Section.BAG, false) + _set_inventory_notepad_visible(Section.TACKLE_BOX, false) + _hide_inventory_context_tooltip() _reset_page_transition_visuals() _presentation_scale_root.modulate.a = 1.0 _presentation_scale_root.scale = Vector2.ONE @@ -3180,6 +3495,10 @@ func _finish_page_transition(generation: int) -> void: func _set_shell_interactive(interactive: bool) -> void: _set_content_interactive(interactive) + var inventory_notepad_active: bool = ( + _controller_ownership == ControllerOwnership.NOTEPAD_ACTIONS + and _current_section in [Section.BAG, Section.TACKLE_BOX] + ) for bubble: BubbleButtonType in [ _inventory_tab, _logbook_tab, @@ -3189,7 +3508,11 @@ func _set_shell_interactive(interactive: bool) -> void: _players_tab, _close_button, ]: - bubble.focus_mode = Control.FOCUS_ALL if interactive else Control.FOCUS_NONE + bubble.focus_mode = ( + Control.FOCUS_ALL + if interactive and not inventory_notepad_active + else Control.FOCUS_NONE + ) bubble.mouse_filter = ( Control.MOUSE_FILTER_STOP if interactive @@ -3289,6 +3612,7 @@ func _set_content_interactive(interactive: bool) -> void: interactive and _current_section == Section.BAG and not _bag_drag_active + and _controller_ownership != ControllerOwnership.NOTEPAD_ACTIONS ) for item_node: BagItemSpriteType in _bag_item_nodes.values(): item_node.mouse_filter = ( @@ -3296,10 +3620,17 @@ func _set_content_interactive(interactive: bool) -> void: if bag_interactive else Control.MOUSE_FILTER_IGNORE ) + if _general_inventory_grid != null: + for slot: GeneralInventorySlotType in _general_inventory_grid.get_slots(): + slot.mouse_filter = ( + Control.MOUSE_FILTER_STOP + if bag_interactive else Control.MOUSE_FILTER_IGNORE + ) var tackle_interactive: bool = ( interactive and _current_section == Section.TACKLE_BOX ) _apply_tackle_interactivity(tackle_interactive) + _refresh_inventory_modal_interactivity() _apply_inventory_controller_zone_focus_modes() @@ -3392,49 +3723,275 @@ func _on_hotbar_changed() -> void: func _refresh_bag() -> void: if not is_node_ready(): return - var owned_items: Array[OwnedItemType] = [] - if _bag != null: - owned_items = _bag.get_all_items() - var filtered_items: Array[OwnedItemType] = [] - for owned: OwnedItemType in owned_items: - var item: ItemDataType = ( - _item_catalog.get_item_by_id(owned.item_id) - if _item_catalog != null - else null - ) - if item == null: - continue - var is_consumable: bool = ( - item.category == ItemDataType.Category.CONSUMABLE - ) - if ( - (_bag_view == BagView.CONSUMABLES and is_consumable) - or ( - _bag_view == BagView.EQUIPMENT - and not is_consumable - and item.category != ItemDataType.Category.BAIT - and item.category != ItemDataType.Category.LURE - ) - ): - filtered_items.append(owned) - owned_items = filtered_items - owned_items.sort_custom(_sort_bag_storage_items) - _sorted_bag_items = owned_items + for item_node: BagItemSpriteType in _bag_item_nodes.values(): + if is_instance_valid(item_node): + item_node.queue_free() + _bag_item_nodes.clear() + for slot: BagStorageSlotType in _bag_slot_nodes: + if is_instance_valid(slot): + slot.queue_free() + _bag_slot_nodes.clear() _bag_empty_state.visible = false - _bag_empty_state.text = ( - "No equipment in your Bag." - if _bag_view == BagView.EQUIPMENT - else "No items in your Bag." - ) + if _general_inventory_grid != null: + _general_inventory_grid.refresh() + call_deferred("_layout_general_inventory_grid") if ( - not _selected_bag_item_id.is_empty() - and (_bag == null or not _bag.owns_item(_selected_bag_item_id)) + not _selected_inventory_identity.is_empty() + and _inventory_layout != null + and _inventory_layout.get_container( + _selected_inventory_kind, + _selected_inventory_identity, + ) != PlayerInventoryLayout.InventoryContainer.INVENTORY ): + _selected_inventory_kind = -1 + _selected_inventory_identity = StringName() _selected_bag_item_id = StringName() - _sync_bag_item_nodes(owned_items) _update_bag_detail() +func _layout_general_inventory_grid() -> void: + if _general_inventory_grid == null or not is_instance_valid(_general_inventory_grid): + return + var available_size: Vector2 = _bag_item_field.size + if available_size.x <= 0.0 or available_size.y <= 0.0: + available_size = _bag_host.size + var grid_size: Vector2 = _general_inventory_grid.custom_minimum_size + # Center against the canonical stage rather than the ScrollContainer's + # left-aligned content host. Container margins and its reserved scrollbar + # gutter otherwise compound into a visible leftward offset. + var field_transform := _bag_item_field.get_global_transform_with_canvas() + var stage_transform := ( + _presentation_scale_root.get_global_transform_with_canvas() + ) + var field_center_global := field_transform * (available_size * 0.5) + var stage_center_global := stage_transform * Vector2( + DESKTOP_REFERENCE_SIZE.x * 0.5, + 0.0, + ) + var desired_center_in_field := field_transform.affine_inverse() * Vector2( + stage_center_global.x, + field_center_global.y, + ) + _general_inventory_grid.size = grid_size + _general_inventory_grid.position = Vector2( + maxf( + 0.0, + desired_center_in_field.x - grid_size.x * 0.5, + ), + maxf(0.0, (available_size.y - grid_size.y) * 0.5), + ) + + +func _on_general_inventory_entry_selected( + kind: int, + identity: StringName, +) -> void: + _selected_inventory_kind = kind + _selected_inventory_identity = identity + _selected_bag_item_id = ( + identity if kind == PlayerInventoryLayout.EntryKind.ITEM + else StringName() + ) + _update_bag_detail() + + +func _on_general_inventory_slot_activated( + slot: GeneralInventorySlotType, +) -> void: + if ( + slot == null + or slot.disabled + or _controller_ownership == ControllerOwnership.NOTEPAD_ACTIONS + ): + return + if _inventory_move_identity.is_empty(): + if slot.entry_identity.is_empty(): + return + _inventory_move_kind = slot.entry_kind + _inventory_move_identity = slot.entry_identity + _selected_inventory_kind = slot.entry_kind + _selected_inventory_identity = slot.entry_identity + _selected_bag_item_id = ( + slot.entry_identity + if slot.entry_kind == PlayerInventoryLayout.EntryKind.ITEM + else StringName() + ) + slot.set_move_source(true) + _show_inventory_context_tooltip( + slot, + "moving %s\nchoose a slot • B cancels" % _inventory_entry_name( + slot.entry_kind, + slot.entry_identity, + ), + ) + return + var move_kind: int = _inventory_move_kind + var move_identity: StringName = _inventory_move_identity + _cancel_inventory_move(false) + if _inventory_layout == null: + return + _inventory_layout.move_entry( + move_kind, + move_identity, + PlayerInventoryLayout.InventoryContainer.INVENTORY, + slot.slot_index, + ) + + +func _open_general_inventory_notepad( + slot: GeneralInventorySlotType, +) -> void: + if slot == null or slot.entry_identity.is_empty(): + return + _cancel_active_item_move() + _on_general_inventory_entry_selected(slot.entry_kind, slot.entry_identity) + _open_inventory_notepad( + Section.BAG, + slot.entry_identity, + _get_bag_notepad_actions(), + ) + + +func _get_bag_notepad_actions() -> Array[BaseButton]: + var actions: Array[BaseButton] = [] + if _selected_inventory_kind != PlayerInventoryLayout.EntryKind.CATCH: + return actions + if _bag_favorite_button.visible and not _bag_favorite_button.disabled: + actions.append(_bag_favorite_button) + if _bag_sell_button.visible and not _bag_sell_button.disabled: + actions.append(_bag_sell_button) + return actions + + +func _inventory_entry_name(kind: int, identity: StringName) -> String: + if kind == PlayerInventoryLayout.EntryKind.ITEM: + var item: ItemDataType = ( + _item_catalog.get_item_by_id(identity) + if _item_catalog != null else null + ) + return item.display_name if item != null else str(identity) + var fish_catch: FishCatchType = ( + _inventory.get_catch_by_id(identity) + if _inventory != null else null + ) + return ( + FishQualityType.qualified_name( + fish_catch.fish.display_name, + fish_catch.quality, + ) + if fish_catch != null else str(identity) + ) + + +func _cancel_inventory_move(hide_tooltip: bool = true) -> bool: + if _inventory_move_identity.is_empty(): + return false + _inventory_move_kind = -1 + _inventory_move_identity = StringName() + if _general_inventory_grid != null: + for slot: GeneralInventorySlotType in _general_inventory_grid.get_slots(): + slot.set_move_source(false) + if hide_tooltip: + _hide_inventory_context_tooltip() + return true + + +func _cancel_active_item_move() -> bool: + var canceled: bool = _cancel_inventory_move() + if not _tackle_move_identity.is_empty(): + _tackle_move_identity = StringName() + _refresh_tackle_move_presentation() + _hide_inventory_context_tooltip() + canceled = true + return canceled + + +func _open_inventory_notepad( + section: Section, + identity: StringName, + actions: Array[BaseButton], +) -> void: + _controller_source_section = section + _controller_source_identity = identity + _controller_notepad_actions = actions + _controller_ownership = ControllerOwnership.NOTEPAD_ACTIONS + _set_inventory_notepad_visible(section, true) + _hide_inventory_context_tooltip() + _apply_inventory_controller_zone_focus_modes() + _refresh_inventory_modal_interactivity() + if not actions.is_empty(): + _configure_controller_notepad_action_focus(actions) + actions.front().call_deferred("grab_focus") + + +func _set_inventory_notepad_visible(section: Section, shown: bool) -> void: + if section == Section.BAG: + _bag_detail_constellation.visible = shown + _bag_modal_blocker.visible = shown + elif section == Section.TACKLE_BOX: + _tackle_detail_panel.visible = shown + _tackle_modal_blocker.visible = shown + + +func _refresh_inventory_modal_interactivity() -> void: + var modal_active: bool = ( + visible + and _controller_ownership == ControllerOwnership.NOTEPAD_ACTIONS + and _current_section in [Section.BAG, Section.TACKLE_BOX] + ) + for navigation_button: BubbleButtonType in [ + _inventory_tab, + _logbook_tab, + _the_net_tab, + _mail_tab, + _profile_tab, + _players_tab, + _close_button, + ]: + navigation_button.focus_mode = ( + Control.FOCUS_NONE + if modal_active + else ( + Control.FOCUS_ALL + if _content_interactive_enabled and visible + else Control.FOCUS_NONE + ) + ) + var bag_interactive: bool = ( + _content_interactive_enabled + and visible + and _current_section == Section.BAG + and _controller_ownership != ControllerOwnership.NOTEPAD_ACTIONS + ) + if _general_inventory_grid != null: + for slot: GeneralInventorySlotType in _general_inventory_grid.get_slots(): + slot.mouse_filter = ( + Control.MOUSE_FILTER_STOP + if bag_interactive else Control.MOUSE_FILTER_IGNORE + ) + var bag_action_interactive: bool = ( + _content_interactive_enabled + and visible + and _current_section == Section.BAG + and _controller_ownership == ControllerOwnership.NOTEPAD_ACTIONS + ) + for action: NotepadInkActionType in [ + _bag_favorite_button, + _bag_sell_button, + ]: + action.mouse_filter = ( + Control.MOUSE_FILTER_STOP + if bag_action_interactive and action.visible and not action.disabled + else Control.MOUSE_FILTER_IGNORE + ) + action.refresh_ink_state() + _apply_tackle_interactivity( + _content_interactive_enabled + and visible + and _current_section == Section.TACKLE_BOX + ) + + func _sync_bag_item_nodes(owned_items: Array[OwnedItemType]) -> void: var retained: Dictionary[StringName, bool] = {} for owned: OwnedItemType in owned_items: @@ -3630,13 +4187,11 @@ func _configure_bag_item_focus() -> void: if _current_section != Section.BAG: return var active_tab: Button = _active_bag_tab() - var controls: Array[BagItemSpriteType] = [] - for owned: OwnedItemType in _sorted_bag_items: - var item_node := _bag_item_nodes.get( - owned.item_id - ) as BagItemSpriteType - if item_node != null: - controls.append(item_node) + var controls: Array[GeneralInventorySlotType] = ( + _general_inventory_grid.get_slots() + if _general_inventory_grid != null + else [] + ) if controls.is_empty(): active_tab.focus_neighbor_bottom = NodePath() return @@ -3685,11 +4240,7 @@ func _configure_bag_storage_slot_focus() -> void: func _active_bag_tab() -> Button: - return ( - _items_sub_tab - if _bag_view == BagView.CONSUMABLES - else _bag_sub_tab - ) + return _bag_sub_tab func _sort_bag_items(a: OwnedItemType, b: OwnedItemType) -> bool: @@ -3715,6 +4266,12 @@ func _sort_bag_storage_items(a: OwnedItemType, b: OwnedItemType) -> bool: func _sort_tackle_items(a: OwnedItemType, b: OwnedItemType) -> bool: + if a.storage_slot >= 0 and b.storage_slot >= 0: + return a.storage_slot < b.storage_slot + if a.storage_slot >= 0: + return true + if b.storage_slot >= 0: + return false var order_a := FishingShopStockType.get_stock_order_index(a.item_id) var order_b := FishingShopStockType.get_stock_order_index(b.item_id) if order_a >= 0 and order_b >= 0 and order_a != order_b: @@ -3737,6 +4294,38 @@ func _select_bag_item(item_id: StringName) -> void: func _update_bag_detail() -> void: + if _selected_inventory_kind == PlayerInventoryLayout.EntryKind.CATCH: + var fish_catch: FishCatchType = ( + _inventory.get_catch_by_id(_selected_inventory_identity) + if _inventory != null else null + ) + _bag_sprite_detail_texture.texture = ( + fish_catch.fish.display_texture if fish_catch != null else null + ) + _bag_sprite_detail_name.text = ( + FishQualityType.qualified_name( + fish_catch.fish.display_name, fish_catch.quality + ) + if fish_catch != null else "" + ) + _bag_sprite_detail_data.text = ( + "%0.2f %s\nvalue: %d\n%s" + % [ + fish_catch.weight_lb, + "lb", + fish_catch.sale_value, + fish_catch.fish.logbook_fact, + ] + if fish_catch != null + else "select an item for details." + ) + _update_bag_fish_actions(fish_catch) + return + _bag_detail_actions.visible = false + _bag_favorite_button.disabled = true + _bag_sell_button.disabled = true + _bag_favorite_button.refresh_ink_state() + _bag_sell_button.refresh_ink_state() var item: ItemDataType = ( _item_catalog.get_item_by_id(_selected_bag_item_id) if _item_catalog != null and not _selected_bag_item_id.is_empty() @@ -3777,9 +4366,75 @@ func _update_bag_detail() -> void: if item != null else "select an item for details." ) - _bag_detail_constellation.visible = ( - _current_section == Section.BAG + + +func _update_bag_fish_actions(fish_catch: FishCatchType) -> void: + _bag_detail_actions.visible = fish_catch != null + _bag_favorite_button.visible = fish_catch != null + _bag_sell_button.visible = fish_catch != null + if fish_catch == null: + _bag_favorite_button.disabled = true + _bag_sell_button.disabled = true + _bag_favorite_button.refresh_ink_state() + _bag_sell_button.refresh_ink_state() + return + _bag_favorite_button.disabled = false + _bag_favorite_button.text = ( + "unfavorite" if fish_catch.is_favorited else "favorite" ) + _bag_favorite_button.persistent_mark = fish_catch.is_favorited + var active_buyer: FishBuyerProfileType = _get_active_sale_buyer() + var sale_available: bool = ( + not _sale_in_progress + and _network_sale_service != null + and active_buyer != null + and _network_sale_service.can_request_sale(active_buyer.id) + and _sale_service != null + ) + if sale_available: + var preview: FishSaleResultType = _sale_service.preview_batch( + [fish_catch.catch_id], + active_buyer, + ) + sale_available = preview != null and preview.is_success() + _bag_sell_button.text = "sell fish" + _bag_sell_button.disabled = not sale_available + _bag_favorite_button.refresh_ink_state() + _bag_sell_button.refresh_ink_state() + _refresh_inventory_modal_interactivity() + + +func _on_bag_favorite_pressed() -> void: + if ( + _inventory == null + or _selected_inventory_kind + != PlayerInventoryLayout.EntryKind.CATCH + or _selected_inventory_identity.is_empty() + ): + return + var fish_catch: FishCatchType = _inventory.get_catch_by_id( + _selected_inventory_identity + ) + if fish_catch == null: + return + if not _inventory.set_catch_favorited( + fish_catch.catch_id, + not fish_catch.is_favorited, + ): + return + _update_bag_detail() + _controller_notepad_actions = _get_bag_notepad_actions() + _configure_controller_notepad_action_focus(_controller_notepad_actions) + + +func _on_bag_sell_pressed() -> void: + if ( + _selected_inventory_kind + != PlayerInventoryLayout.EntryKind.CATCH + or _selected_inventory_identity.is_empty() + ): + return + _begin_sale_confirmation([_selected_inventory_identity]) func _item_category_display_name(item: ItemDataType) -> String: @@ -3789,7 +4444,7 @@ func _item_category_display_name(item: ItemDataType) -> String: func _close_bag_detail() -> void: - _bag_detail_constellation.visible = false + _set_inventory_notepad_visible(Section.BAG, false) func _on_bag_field_gui_input(event: InputEvent) -> void: @@ -4598,11 +5253,20 @@ func _on_network_sale_finished( _payout: int, ) -> void: _sale_in_progress = false + var close_bag_notepad: bool = ( + accepted + and _current_section == Section.BAG + and _controller_ownership == ControllerOwnership.NOTEPAD_ACTIONS + and _controller_source_section == Section.BAG + and catch_ids.has(_controller_source_identity) + ) if accepted: _fish_selection.remove_ids(catch_ids) if not visible and not _shop_cooler_context_active: return _refresh_all() + if close_bag_notepad: + _release_controller_ownership(true, false) if _current_section == Section.COOLER: call_deferred("_restore_inventory_tab_focus") diff --git a/ui/player_menu.tscn b/ui/player_menu.tscn index 93fcf92..4c1a125 100644 --- a/ui/player_menu.tscn +++ b/ui/player_menu.tscn @@ -72,15 +72,16 @@ unique_name_in_owner = true visible = false z_index = 40 layout_mode = 0 -offset_left = 150.0 +offset_left = 513.0 offset_top = 136.0 -offset_right = 664.0 +offset_right = 767.0 offset_bottom = 174.0 mouse_filter = 1 theme_override_constants/separation = 6 [node name="CoolerSubTab" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/InventorySubTabs"] unique_name_in_owner = true +visible = false custom_minimum_size = Vector2(124, 38) layout_mode = 2 toggle_mode = true @@ -94,12 +95,13 @@ custom_minimum_size = Vector2(124, 38) layout_mode = 2 toggle_mode = true button_group = null -text = "Equipment" +text = "Inventory" script = ExtResource("23_organizer_tab") palette_index = 2 [node name="ItemsSubTab" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/InventorySubTabs"] unique_name_in_owner = true +visible = false custom_minimum_size = Vector2(124, 38) layout_mode = 2 toggle_mode = true @@ -494,10 +496,10 @@ mouse_filter = 1 unique_name_in_owner = true z_index = 50 layout_mode = 0 -offset_left = 122.0 -offset_top = 132.0 -offset_right = 1018.0 -offset_bottom = 574.0 +offset_left = 199.0 +offset_top = 166.0 +offset_right = 1081.0 +offset_bottom = 650.0 [node name="BagOuterMargin" type="MarginContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage/BagOuterWall"] layout_mode = 2 @@ -524,7 +526,7 @@ horizontal_scroll_mode = 0 [node name="BagHost" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage/BagOuterWall/BagOuterMargin/BagInnerLiner/BagInnerMargin/BagScroll"] unique_name_in_owner = true -custom_minimum_size = Vector2(788, 358) +custom_minimum_size = Vector2(810, 420) layout_mode = 2 mouse_filter = 1 @@ -553,14 +555,26 @@ text = "your bag is empty" horizontal_alignment = 1 vertical_alignment = 1 +[node name="BagModalBlocker" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage"] +unique_name_in_owner = true +visible = false +z_index = 110 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 0 + [node name="BagDetailConstellation" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage"] unique_name_in_owner = true visible = false layout_mode = 0 -offset_left = 984.0 -offset_top = 266.0 -offset_right = 1256.0 -offset_bottom = 570.0 +offset_left = 501.0 +offset_top = 166.0 +offset_right = 779.0 +offset_bottom = 650.0 mouse_filter = 2 [node name="BagDetailBubble" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage/BagDetailConstellation"] @@ -572,7 +586,7 @@ anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 script = ExtResource("22_inventory_notepad") -title_text = "bag notes" +title_text = "inventory notes" [node name="BagDetailLayout" type="VBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage/BagDetailConstellation/BagDetailBubble"] layout_mode = 2 @@ -601,6 +615,25 @@ theme_override_font_sizes/font_size = 15 horizontal_alignment = 1 autowrap_mode = 2 +[node name="BagDetailActions" type="HBoxContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage/BagDetailConstellation/BagDetailBubble/BagDetailLayout"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_constants/separation = 6 +alignment = 1 + +[node name="BagFavoriteButton" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage/BagDetailConstellation/BagDetailBubble/BagDetailLayout/BagDetailActions" instance=ExtResource("12_ink_action")] +unique_name_in_owner = true +custom_minimum_size = Vector2(104, 50) +layout_mode = 2 +text = "favorite" +allow_persistent_mark = true + +[node name="BagSellButton" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/BagPage/BagDetailConstellation/BagDetailBubble/BagDetailLayout/BagDetailActions" instance=ExtResource("12_ink_action")] +unique_name_in_owner = true +custom_minimum_size = Vector2(104, 50) +layout_mode = 2 +text = "sell fish" + [node name="TackleBoxPage" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot"] unique_name_in_owner = true visible = false @@ -613,10 +646,10 @@ mouse_filter = 1 unique_name_in_owner = true z_index = 50 layout_mode = 0 -offset_left = 54.0 +offset_left = 199.0 offset_top = 166.0 -offset_right = 936.0 -offset_bottom = 602.0 +offset_right = 1081.0 +offset_bottom = 650.0 [node name="Margin" type="MarginContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/TackleBoxPage/TackleMainPanel"] layout_mode = 2 @@ -667,7 +700,7 @@ layout_mode = 2 size_flags_horizontal = 3 theme_override_constants/h_separation = 28 theme_override_constants/v_separation = 12 -columns = 3 +columns = 4 [node name="BaitEmpty" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/TackleBoxPage/TackleMainPanel/Margin/Layout/TackleColumns/BaitColumn/BaitBody"] unique_name_in_owner = true @@ -720,7 +753,7 @@ layout_mode = 2 size_flags_horizontal = 3 theme_override_constants/h_separation = 28 theme_override_constants/v_separation = 12 -columns = 3 +columns = 4 [node name="LureEmpty" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/TackleBoxPage/TackleMainPanel/Margin/Layout/TackleColumns/LureColumn/LureBody"] unique_name_in_owner = true @@ -735,13 +768,26 @@ text = "No lures collected." horizontal_alignment = 1 vertical_alignment = 1 +[node name="TackleModalBlocker" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/TackleBoxPage"] +unique_name_in_owner = true +visible = false +z_index = 110 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 0 + [node name="TackleDetailPanel" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/TackleBoxPage"] unique_name_in_owner = true +visible = false layout_mode = 0 -offset_left = 952.0 +offset_left = 501.0 offset_top = 166.0 -offset_right = 1230.0 -offset_bottom = 602.0 +offset_right = 779.0 +offset_bottom = 650.0 script = ExtResource("22_inventory_notepad") title_text = "tackle box notes" @@ -861,9 +907,9 @@ custom_minimum_size = Vector2(140, 44) [node name="NavigationCluster" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot"] unique_name_in_owner = true layout_mode = 0 -offset_left = 330.0 +offset_left = 388.0 offset_top = 44.0 -offset_right = 1170.0 +offset_right = 1228.0 offset_bottom = 144.0 mouse_filter = 1 script = ExtResource("5_cluster") diff --git a/ui/player_storage.gd b/ui/player_storage.gd new file mode 100644 index 0000000..fd885d7 --- /dev/null +++ b/ui/player_storage.gd @@ -0,0 +1,317 @@ +class_name PlayerStorage +extends Control + +const INPUT_OWNER: StringName = &"player_storage" + +signal menu_visibility_changed(is_open: bool) + +var _player: Player +var _fishing_spot: FishingSpot +var _interaction: PlayerStorageInteraction +var _layout: PlayerInventoryLayout +var _inventory_grid: GeneralInventoryGrid +var _storage_grid: GeneralInventoryGrid +var _inventory_count: Label +var _storage_count: Label +var _feedback: Label +var _prior_movement_enabled: bool = true +var _prior_camera_enabled: bool = true +var _prior_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_VISIBLE + + +func _ready() -> void: + UtilityPageStyle.apply_page(self) + _build_ui() + hide() + + +func setup( + player: Player, + fishing_spot: FishingSpot, + interaction: PlayerStorageInteraction, + layout: PlayerInventoryLayout, + bag: PlayerBag, + fish_inventory: FishInventory, + hotbar: PlayerHotbar, + item_catalog: ItemCatalog, +) -> void: + _player = player + _fishing_spot = fishing_spot + _interaction = interaction + _layout = layout + _inventory_grid.setup( + layout, + bag, + fish_inventory, + hotbar, + item_catalog, + PlayerInventoryLayout.InventoryContainer.INVENTORY, + ) + _storage_grid.setup( + layout, + bag, + fish_inventory, + hotbar, + item_catalog, + PlayerInventoryLayout.InventoryContainer.STORAGE, + ) + _inventory_grid.entry_selected.connect( + _on_entry_selected.bind( + PlayerInventoryLayout.InventoryContainer.STORAGE + ) + ) + _storage_grid.entry_selected.connect( + _on_entry_selected.bind( + PlayerInventoryLayout.InventoryContainer.INVENTORY + ) + ) + if _layout != null and not _layout.layout_changed.is_connected(_refresh): + _layout.layout_changed.connect(_refresh) + _refresh() + + +func open_storage() -> bool: + if ( + visible + or _player == null + or _interaction == null + or not _interaction.is_local_player_in_range() + or _fishing_spot == null + or not _fishing_spot.can_open_fishing_shop() + ): + return false + _prior_movement_enabled = _player.is_movement_enabled() + _prior_camera_enabled = _player.is_camera_input_enabled() + _prior_mouse_mode = Input.mouse_mode + _player.set_movement_enabled(false) + _player.set_camera_input_enabled(false) + _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, true) + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + _feedback.text = "select an item to move it · drag to choose a slot" + _refresh() + show() + call_deferred("_focus_first_slot") + menu_visibility_changed.emit(true) + return true + + +func close_storage(restore_controls: bool = true) -> void: + if not visible: + return + var viewport := get_viewport() + if viewport != null: + viewport.gui_release_focus() + hide() + if _fishing_spot != null: + _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, false) + if restore_controls and _player != null: + _player.set_movement_enabled(_prior_movement_enabled) + _player.set_camera_input_enabled(_prior_camera_enabled) + Input.mouse_mode = _prior_mouse_mode + menu_visibility_changed.emit(false) + + +func close_for_range_exit() -> void: + close_storage() + + +func close_for_water_recovery() -> void: + close_storage(false) + + +func close_for_session_end() -> void: + close_storage(false) + + +func consume_escape() -> bool: + if not visible: + return false + close_storage() + return true + + +func _unhandled_input(event: InputEvent) -> void: + if not visible or not event.is_action_pressed("ui_cancel"): + return + close_storage() + get_viewport().set_input_as_handled() + + +func _on_entry_selected( + kind: int, + identity: StringName, + target_container: int, +) -> void: + if kind < 0 or identity.is_empty() or _layout == null: + return + if _layout.move_entry_to_first_free(kind, identity, target_container): + _feedback.text = ( + "moved to storage" + if target_container == PlayerInventoryLayout.InventoryContainer.STORAGE + else "moved to inventory" + ) + else: + _feedback.text = ( + "storage is full" + if target_container == PlayerInventoryLayout.InventoryContainer.STORAGE + else "inventory is full" + ) + + +func _refresh() -> void: + if _layout == null: + return + _inventory_grid.refresh() + _storage_grid.refresh() + _inventory_count.text = "%d / %d" % [ + _layout.get_inventory_count(), + _layout.get_inventory_capacity(), + ] + _storage_count.text = "%d / %d" % [ + _layout.get_storage_count(), + _layout.get_storage_capacity(), + ] + call_deferred("_connect_grid_focus") + + +func _focus_first_slot() -> void: + var slot := _inventory_grid.get_first_occupied_slot() + if slot == null: + slot = _storage_grid.get_first_occupied_slot() + if slot != null: + slot.grab_focus() + + +func _connect_grid_focus() -> void: + var inventory_slots := _inventory_grid.get_slots() + var storage_slots := _storage_grid.get_slots() + if inventory_slots.is_empty() or storage_slots.is_empty(): + return + var row_count := mini( + ceili( + float(inventory_slots.size()) + / float(PlayerInventoryLayout.INVENTORY_COLUMNS) + ), + ceili( + float(storage_slots.size()) + / float(PlayerInventoryLayout.INVENTORY_COLUMNS) + ), + ) + for row: int in row_count: + var inventory_index := mini( + row * PlayerInventoryLayout.INVENTORY_COLUMNS + + PlayerInventoryLayout.INVENTORY_COLUMNS - 1, + inventory_slots.size() - 1, + ) + var storage_index := mini( + row * PlayerInventoryLayout.INVENTORY_COLUMNS, + storage_slots.size() - 1, + ) + inventory_slots[inventory_index].focus_neighbor_right = ( + inventory_slots[inventory_index].get_path_to(storage_slots[storage_index]) + ) + storage_slots[storage_index].focus_neighbor_left = ( + storage_slots[storage_index].get_path_to(inventory_slots[inventory_index]) + ) + + +func _build_ui() -> void: + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + z_index = 82 + mouse_filter = Control.MOUSE_FILTER_STOP + var blocker := ColorRect.new() + blocker.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + blocker.color = Color(0.02, 0.08, 0.10, 0.55) + blocker.mouse_filter = Control.MOUSE_FILTER_STOP + add_child(blocker) + + var panel := PanelContainer.new() + panel.set_anchors_preset(Control.PRESET_CENTER) + panel.position = Vector2(-580.0, -310.0) + panel.size = Vector2(1160.0, 620.0) + panel.add_theme_stylebox_override( + "panel", + UtilityPageStyle.rounded_style(UtilityPageStyle.OCEAN_PANEL_DEEP, 22), + ) + add_child(panel) + var outer := MarginContainer.new() + for side: StringName in [&"margin_left", &"margin_top", &"margin_right", &"margin_bottom"]: + outer.add_theme_constant_override(side, 20) + panel.add_child(outer) + var layout := VBoxContainer.new() + layout.add_theme_constant_override("separation", 12) + outer.add_child(layout) + + var header := HBoxContainer.new() + layout.add_child(header) + var title := Label.new() + title.text = "private storage" + title.add_theme_font_size_override("font_size", 32) + title.add_theme_color_override("font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY) + title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + header.add_child(title) + var close := Button.new() + close.text = "×" + close.tooltip_text = "Close storage" + close.custom_minimum_size = Vector2(48.0, 48.0) + close.flat = true + close.add_theme_font_size_override("font_size", 30) + close.pressed.connect(close_storage) + header.add_child(close) + + var columns := HBoxContainer.new() + columns.size_flags_vertical = Control.SIZE_EXPAND_FILL + columns.add_theme_constant_override("separation", 18) + layout.add_child(columns) + var inventory_column := _build_column(columns, "inventory") + _inventory_count = inventory_column["count"] + _inventory_grid = GeneralInventoryGrid.new() + (inventory_column["scroll"] as ScrollContainer).add_child(_inventory_grid) + var storage_column := _build_column(columns, "storage") + _storage_count = storage_column["count"] + _storage_grid = GeneralInventoryGrid.new() + (storage_column["scroll"] as ScrollContainer).add_child(_storage_grid) + + _feedback = Label.new() + _feedback.text = "select an item to move it · drag to choose a slot" + _feedback.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _feedback.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY + ) + layout.add_child(_feedback) + + +func _build_column(parent: HBoxContainer, title_text: String) -> Dictionary: + var panel := PanelContainer.new() + panel.custom_minimum_size = Vector2(550.0, 0.0) + panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + panel.add_theme_stylebox_override( + "panel", + UtilityPageStyle.rounded_style(UtilityPageStyle.OCEAN_PANEL_MID, 16), + ) + parent.add_child(panel) + var margin := MarginContainer.new() + for side: StringName in [&"margin_left", &"margin_top", &"margin_right", &"margin_bottom"]: + margin.add_theme_constant_override(side, 12) + panel.add_child(margin) + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", 8) + margin.add_child(column) + var header := HBoxContainer.new() + column.add_child(header) + var title := Label.new() + title.text = title_text + title.add_theme_font_size_override("font_size", 22) + title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + header.add_child(title) + var count := Label.new() + count.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + count.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY + ) + header.add_child(count) + var scroll := ScrollContainer.new() + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + column.add_child(scroll) + return {"count": count, "scroll": scroll} diff --git a/ui/player_storage.gd.uid b/ui/player_storage.gd.uid new file mode 100644 index 0000000..2fa504a --- /dev/null +++ b/ui/player_storage.gd.uid @@ -0,0 +1 @@ +uid://d3g0sjl7sepnx diff --git a/ui/player_storage.tscn b/ui/player_storage.tscn new file mode 100644 index 0000000..febdc16 --- /dev/null +++ b/ui/player_storage.tscn @@ -0,0 +1,14 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://ui/player_storage.gd" id="1_script"] + +[node name="PlayerStorage" type="Control"] +unique_name_in_owner = true +visible = false +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_script") diff --git a/ui/players_page.gd b/ui/players_page.gd index 1a9cdc8..87f3536 100644 --- a/ui/players_page.gd +++ b/ui/players_page.gd @@ -191,7 +191,10 @@ func _build() -> void: scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED root.add_child(scroll) _list = VBoxContainer.new() - _list.custom_minimum_size = Vector2(1032, 0) + _list.custom_minimum_size = Vector2( + UtilityPageStyle.LAPTOP_CONTENT_SIZE.x, + 0.0, + ) _list.add_theme_constant_override("separation", 7) scroll.add_child(_list) _status = Label.new() @@ -207,18 +210,22 @@ func _build_host_settings(root: VBoxContainer) -> void: "panel", UtilityPageStyle.row_style(false) ) root.add_child(_host_settings_panel) - var row := HBoxContainer.new() - row.custom_minimum_size.y = 58.0 - row.add_theme_constant_override("separation", 10) - _host_settings_panel.add_child(row) + var settings := VBoxContainer.new() + settings.custom_minimum_size.y = 104.0 + settings.add_theme_constant_override("separation", 8) + _host_settings_panel.add_child(settings) + var identity_row := HBoxContainer.new() + identity_row.add_theme_constant_override("separation", 10) + settings.add_child(identity_row) var label := Label.new() label.text = "room name" label.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY ) - row.add_child(label) + identity_row.add_child(label) _room_name_edit = LineEdit.new() - _room_name_edit.custom_minimum_size = Vector2(300.0, 42.0) + _room_name_edit.custom_minimum_size = Vector2(0.0, 42.0) + _room_name_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL _room_name_edit.max_length = DiscoveryClient.MAX_ROOM_NAME_LENGTH _room_name_edit.placeholder_text = "Player Name's Server" UtilityPageStyle.apply_ocean_line_edit(_room_name_edit) @@ -226,11 +233,14 @@ func _build_host_settings(root: VBoxContainer) -> void: func(_value: String) -> void: _commit_room_name() ) _room_name_edit.focus_exited.connect(_commit_room_name) - row.add_child(_room_name_edit) - _online_toggle = _build_host_toggle("online", row) + identity_row.add_child(_room_name_edit) + var access_row := HBoxContainer.new() + access_row.add_theme_constant_override("separation", 10) + settings.add_child(access_row) + _online_toggle = _build_host_toggle("online", access_row) _online_state_label = _online_toggle.get_node("StateBadge/State") as Label _online_toggle.pressed.connect(_on_online_pressed) - _discoverable_toggle = _build_host_toggle("discovery", row) + _discoverable_toggle = _build_host_toggle("discovery", access_row) _discoverable_state_label = ( _discoverable_toggle.get_node("StateBadge/State") as Label ) @@ -244,12 +254,12 @@ func _build_host_settings(root: VBoxContainer) -> void: _host_discovery_status.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY ) - row.add_child(_host_discovery_status) + access_row.add_child(_host_discovery_status) func _build_host_toggle(label_text: String, row: HBoxContainer) -> Button: var button := Button.new() - button.custom_minimum_size = Vector2(150.0, 46.0) + button.custom_minimum_size = Vector2(140.0, 46.0) button.text = label_text button.alignment = HORIZONTAL_ALIGNMENT_CENTER button.clip_contents = false @@ -448,7 +458,8 @@ func _build_active_rows() -> void: for entry: PlayerListEntry in entries: var row := _make_row() var identity := Label.new() - identity.custom_minimum_size.x = 430 + identity.custom_minimum_size.x = 250 + identity.size_flags_horizontal = Control.SIZE_EXPAND_FILL var markers: Array[String] = [] if entry.is_host: markers.append("host") @@ -470,7 +481,7 @@ func _build_active_rows() -> void: ) row.add_child(identity) var ping := Label.new() - ping.custom_minimum_size.x = 80 + ping.custom_minimum_size.x = 60 ping.text = ( "Local" if entry.is_host else "%d ms" % entry.ping_to_host_ms @@ -484,31 +495,31 @@ func _build_active_rows() -> void: mute.text = "unmute" if entry.muted else "mute" mute.disabled = entry.is_local_player mute.pressed.connect(_toggle_mute.bind(entry)) - UtilityPageStyle.apply_ocean_button(mute) + UtilityPageStyle.apply_compact_ocean_button(mute) row.add_child(mute) var block := Button.new() block.text = "block" block.disabled = entry.is_local_player block.pressed.connect(_confirm_block.bind(entry)) - UtilityPageStyle.apply_ocean_button(block) + UtilityPageStyle.apply_compact_ocean_button(block) row.add_child(block) if entry.can_manage_operator: var operator := Button.new() operator.text = "deop" if entry.is_operator else "op" operator.pressed.connect(_confirm_operator.bind(entry)) - UtilityPageStyle.apply_ocean_button(operator) + UtilityPageStyle.apply_compact_ocean_button(operator) row.add_child(operator) var kick := Button.new() kick.text = "kick" kick.disabled = not entry.can_kick kick.pressed.connect(_confirm_kick.bind(entry)) - UtilityPageStyle.apply_ocean_button(kick) + UtilityPageStyle.apply_compact_ocean_button(kick) row.add_child(kick) var ban := Button.new() ban.text = "ban" ban.disabled = not entry.can_ban ban.pressed.connect(_confirm_ban.bind(entry)) - UtilityPageStyle.apply_ocean_button(ban) + UtilityPageStyle.apply_compact_ocean_button(ban) row.add_child(ban) @@ -516,7 +527,8 @@ func _build_session_artwork_controls() -> void: var counts: Vector2i = _service.get_session_artwork_counts() var row := _make_row() var label := Label.new() - label.custom_minimum_size.x = 830 + label.custom_minimum_size.x = 600 + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL label.text = "session artwork · %d layers · %d painted pixels" % [ counts.x, counts.y, ] @@ -534,7 +546,7 @@ func _build_session_artwork_controls() -> void: _service.reset_session_artwork, ) ) - UtilityPageStyle.apply_ocean_button(reset) + UtilityPageStyle.apply_compact_ocean_button(reset) row.add_child(reset) @@ -546,7 +558,8 @@ func _build_relationship_rows() -> void: for record: Dictionary in records: var row := _make_row() var label := Label.new() - label.custom_minimum_size.x = 690 + label.custom_minimum_size.x = 560 + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL var fingerprint := str(record["fingerprint"]) label.text = "%s · %s %s" % [ str(record.get("last_known_display_name", "Player")), @@ -564,7 +577,7 @@ func _build_relationship_rows() -> void: unblock.pressed.connect(func() -> void: _service.set_blocked(fingerprint, str(record["last_known_display_name"]), false) ) - UtilityPageStyle.apply_ocean_button(unblock) + UtilityPageStyle.apply_compact_ocean_button(unblock) row.add_child(unblock) var unmute := Button.new() unmute.text = "unmute" @@ -572,7 +585,7 @@ func _build_relationship_rows() -> void: unmute.pressed.connect(func() -> void: _service.set_muted(fingerprint, str(record["last_known_display_name"]), false) ) - UtilityPageStyle.apply_ocean_button(unmute) + UtilityPageStyle.apply_compact_ocean_button(unmute) row.add_child(unmute) @@ -585,7 +598,8 @@ func _build_ban_rows() -> void: var row := _make_row() var fingerprint := str(record["target_fingerprint"]) var label := Label.new() - label.custom_minimum_size.x = 830 + label.custom_minimum_size.x = 600 + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL label.text = "%s · %s banned %s" % [ str(record.get("last_known_display_name", "Player")), NetworkIdentityCrypto.compact_suffix(fingerprint), @@ -599,7 +613,7 @@ func _build_ban_rows() -> void: var unban := Button.new() unban.text = "unban" unban.pressed.connect(_confirm_unban.bind(fingerprint)) - UtilityPageStyle.apply_ocean_button(unban) + UtilityPageStyle.apply_compact_ocean_button(unban) row.add_child(unban) diff --git a/ui/profile_page.gd b/ui/profile_page.gd index fd70fe6..08dcb9e 100644 --- a/ui/profile_page.gd +++ b/ui/profile_page.gd @@ -3,13 +3,16 @@ extends Control const CHECK_DEBOUNCE_SECONDS: float = 0.4 const APPEARANCE_PREVIEW_INTERVAL_SECONDS: float = 0.08 -const OPTION_GRID_COLUMNS: int = 6 -const FUR_PALETTE_GRID_COLUMNS: int = 10 -const FUR_PATTERN_GRID_COLUMNS: int = 4 +const OPTION_GRID_COLUMNS: int = 3 +const FUR_PALETTE_GRID_COLUMNS: int = 6 +const FUR_PATTERN_GRID_COLUMNS: int = 2 +const FUR_CHANNEL_GRID_COLUMNS: int = 2 const FUR_PALETTE_SWATCH_SIZE: float = 38.0 const FUR_CHANNEL_SWATCH_SIZE: float = 22.0 +const FUR_PART_TAB_WIDTH: float = 58.0 const FUR_COLOR_PICKER_POPUP_SIZE: Vector2i = Vector2i(720, 560) const FUR_COLOR_PICKER_SV_SIZE: Vector2i = Vector2i(520, 300) +const VOICE_OPTION_BUTTON_SIZE: Vector2 = Vector2(68.0, 32.0) const FUR_SECTION_PATTERNS: String = "patterns" const FUR_SECTION_COLORS: String = "colors" const FEATURE_DRAWER_ANIMATION_SECONDS: float = 0.16 @@ -722,7 +725,7 @@ func _build_ui() -> void: _category_list.add_theme_constant_override("separation", 5) category_scroll.add_child(_category_list) _option_list = VBoxContainer.new() - _option_list.custom_minimum_size = Vector2(360, 0) + _option_list.custom_minimum_size = Vector2(326, 0) _option_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL _option_list.size_flags_vertical = Control.SIZE_EXPAND_FILL _option_list.add_theme_constant_override("separation", 5) @@ -732,13 +735,13 @@ func _build_ui() -> void: body_spacer.size_flags_horizontal = Control.SIZE_SHRINK_CENTER body.add_child(body_spacer) var preview_stack := VBoxContainer.new() - preview_stack.custom_minimum_size.x = 260.0 + preview_stack.custom_minimum_size.x = 240.0 preview_stack.size_flags_vertical = Control.SIZE_EXPAND_FILL preview_stack.alignment = BoxContainer.ALIGNMENT_CENTER preview_stack.add_theme_constant_override("separation", 7) body.add_child(preview_stack) var preview_frame := PanelContainer.new() - preview_frame.custom_minimum_size = Vector2(260.0, 0.0) + preview_frame.custom_minimum_size = Vector2(240.0, 0.0) preview_frame.size_flags_vertical = Control.SIZE_EXPAND_FILL preview_frame.add_theme_stylebox_override( "panel", UtilityPageStyle.rounded_style( @@ -752,7 +755,7 @@ func _build_ui() -> void: preview_layer.mouse_filter = Control.MOUSE_FILTER_PASS preview_frame.add_child(preview_layer) _preview = preload("res://ui/profile_preview.tscn").instantiate() - _preview.custom_minimum_size = Vector2(260.0, 0.0) + _preview.custom_minimum_size = Vector2(240.0, 0.0) _preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL _preview.size_flags_vertical = Control.SIZE_EXPAND_FILL preview_layer.add_child(_preview) @@ -911,7 +914,7 @@ func _build_voice_options() -> void: var settings_grid := GridContainer.new() settings_grid.name = "VoiceSettingsGrid" settings_grid.columns = 2 - settings_grid.add_theme_constant_override("h_separation", 10) + settings_grid.add_theme_constant_override("h_separation", 8) settings_grid.add_theme_constant_override("v_separation", 8) settings_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL _option_list.add_child(settings_grid) @@ -943,7 +946,7 @@ func _build_voice_options() -> void: sample_set_button.text = str(option.get("label", sample_set_id)) sample_set_button.toggle_mode = true sample_set_button.button_group = sample_set_group - sample_set_button.custom_minimum_size = Vector2(108.0, 32.0) + sample_set_button.custom_minimum_size = VOICE_OPTION_BUTTON_SIZE sample_set_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL sample_set_button.button_pressed = ( _draft_sample_set_id == sample_set_id @@ -951,7 +954,7 @@ func _build_voice_options() -> void: sample_set_button.pressed.connect( _select_sample_set_option.bind(sample_set_id) ) - UtilityPageStyle.apply_compact_ocean_button(sample_set_button) + _apply_voice_option_button(sample_set_button) sample_set_grid.add_child(sample_set_button) var pitch_title := Label.new() pitch_title.text = "pitch" @@ -976,11 +979,11 @@ func _build_voice_options() -> void: button.text = str(option.get("label", option_id)) button.toggle_mode = true button.button_group = pitch_group - button.custom_minimum_size = Vector2(108.0, 32.0) + button.custom_minimum_size = VOICE_OPTION_BUTTON_SIZE button.size_flags_horizontal = Control.SIZE_EXPAND_FILL button.button_pressed = _draft_voice_id == option_id button.pressed.connect(_select_voice_option.bind(option_id)) - UtilityPageStyle.apply_compact_ocean_button(button) + _apply_voice_option_button(button) grid.add_child(button) var speed_title := Label.new() speed_title.text = "playback speed" @@ -1008,13 +1011,13 @@ func _build_voice_options() -> void: ) speed_button.toggle_mode = true speed_button.button_group = speed_group - speed_button.custom_minimum_size = Vector2(108.0, 32.0) + speed_button.custom_minimum_size = VOICE_OPTION_BUTTON_SIZE speed_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL speed_button.button_pressed = _draft_speech_speed_id == speed_id speed_button.pressed.connect( _select_speech_speed_option.bind(speed_id) ) - UtilityPageStyle.apply_compact_ocean_button(speed_button) + _apply_voice_option_button(speed_button) speed_grid.add_child(speed_button) var call_title := Label.new() call_title.text = "call (G)" @@ -1039,14 +1042,29 @@ func _build_voice_options() -> void: call_button.text = str(option.get("label", call_id)) call_button.toggle_mode = true call_button.button_group = call_group - call_button.custom_minimum_size = Vector2(108.0, 32.0) + call_button.custom_minimum_size = VOICE_OPTION_BUTTON_SIZE call_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL call_button.button_pressed = _draft_call_id == call_id call_button.pressed.connect(_select_call_option.bind(call_id)) - UtilityPageStyle.apply_compact_ocean_button(call_button) + _apply_voice_option_button(call_button) call_grid.add_child(call_button) +func _apply_voice_option_button(button: Button) -> void: + UtilityPageStyle.apply_compact_ocean_button(button) + button.custom_minimum_size = VOICE_OPTION_BUTTON_SIZE + button.add_theme_font_size_override("font_size", 12) + for state: StringName in [ + &"normal", &"hover", &"pressed", &"focus", &"disabled", + ]: + var style := button.get_theme_stylebox(state).duplicate() as StyleBoxFlat + if style == null: + continue + style.content_margin_left = 6.0 + style.content_margin_right = 6.0 + button.add_theme_stylebox_override(state, style) + + func _select_sample_set_option(sample_set_id: String) -> void: if not VoiceProfilesType.is_valid_sample_set(sample_set_id): return @@ -1473,7 +1491,7 @@ func _build_fur_pattern_options() -> void: var part_tabs := HBoxContainer.new() part_tabs.name = "FurPatternPartTabs" part_tabs.custom_minimum_size.y = 38.0 - part_tabs.add_theme_constant_override("separation", 4) + part_tabs.add_theme_constant_override("separation", 2) part_tab_margin.add_child(part_tabs) for style_index: int in range( CharacterCustomizationCatalog.FUR_STYLE_IDS.size() @@ -1486,7 +1504,7 @@ func _build_fur_pattern_options() -> void: style_field ) part_tab.palette_index = mini(style_index, 2) - part_tab.custom_minimum_size = Vector2(90.0, 38.0) + part_tab.custom_minimum_size = Vector2(FUR_PART_TAB_WIDTH, 38.0) part_tab.focus_mode = Control.FOCUS_ALL part_tab.mouse_filter = Control.MOUSE_FILTER_STOP part_tab.pressed.connect(_select_fur_pattern_part.bind(style_field)) @@ -1586,7 +1604,7 @@ func _build_fur_color_channels(options: Array) -> void: var channel_grid := GridContainer.new() channel_grid.name = "FurColorChannelGrid" - channel_grid.columns = CharacterCustomizationCatalog.FUR_COLOR_IDS.size() + channel_grid.columns = FUR_CHANNEL_GRID_COLUMNS channel_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL channel_grid.add_theme_constant_override("h_separation", 10) channel_grid.add_theme_constant_override("v_separation", 8) diff --git a/ui/shop_sell_inventory.gd b/ui/shop_sell_inventory.gd new file mode 100644 index 0000000..f4e9a43 --- /dev/null +++ b/ui/shop_sell_inventory.gd @@ -0,0 +1,366 @@ +class_name ShopSellInventory +extends Control + +const MAIN_SHOP_BUYER_ID: StringName = &"main_fishing_shop" + +var _layout: PlayerInventoryLayout +var _bag: PlayerBag +var _fish_inventory: FishInventory +var _item_catalog: ItemCatalog +var _buyer: FishBuyerProfile +var _reservations: PlayerAssetReservationService +var _network_sale: NetworkSaleService +var _inventory_grid: GeneralInventoryGrid +var _tray_grid: GridContainer +var _total_label: Label +var _feedback: Label +var _sell_button: Button +var _staged: Dictionary[String, Dictionary] = {} + + +func _ready() -> void: + UtilityPageStyle.apply_page(self) + _build_ui() + + +func setup( + layout: PlayerInventoryLayout, + bag: PlayerBag, + fish_inventory: FishInventory, + hotbar: PlayerHotbar, + item_catalog: ItemCatalog, + buyer: FishBuyerProfile, + reservations: PlayerAssetReservationService, + network_sale: NetworkSaleService, +) -> void: + _layout = layout + _bag = bag + _fish_inventory = fish_inventory + _item_catalog = item_catalog + _buyer = buyer + _reservations = reservations + _network_sale = network_sale + _inventory_grid.setup( + layout, + bag, + fish_inventory, + hotbar, + item_catalog, + PlayerInventoryLayout.InventoryContainer.INVENTORY, + ) + _inventory_grid.entry_selected.connect(_on_inventory_entry_selected) + if _layout != null: + _layout.layout_changed.connect(_refresh) + if _network_sale != null: + _network_sale.local_sale_pending.connect(_on_sale_pending) + _network_sale.local_sale_finished.connect(_on_sale_finished) + _refresh() + + +func activate() -> void: + _feedback.text = "select or drag items into the sell tray" + _refresh() + var slot := _inventory_grid.get_first_occupied_slot() + if slot != null: + slot.grab_focus() + + +func clear_staged() -> void: + _staged.clear() + _refresh_tray() + + +func _on_inventory_entry_selected(kind: int, identity: StringName) -> void: + _stage(kind, identity) + + +func _stage(kind: int, identity: StringName) -> void: + if identity.is_empty(): + return + var key := ( + PlayerInventoryLayout.catch_key(identity) + if kind == PlayerInventoryLayout.EntryKind.CATCH + else PlayerInventoryLayout.item_key(identity) + ) + if _staged.has(key): + _staged.erase(key) + _refresh_tray() + return + if kind == PlayerInventoryLayout.EntryKind.CATCH: + var fish_catch := _fish_inventory.get_catch_by_id(identity) + if fish_catch == null: + _feedback.text = "that catch is no longer available" + return + if fish_catch.is_favorited: + _feedback.text = "favorite catches cannot be sold" + return + if _reservations != null and _reservations.is_fish_reserved(identity): + _feedback.text = "reserved in a letter" + return + _staged[key] = {"kind": kind, "identity": identity, "quantity": 1} + else: + var item := _item_catalog.get_item_by_id(identity) + if not ItemResalePolicy.is_sellable(item): + _feedback.text = "equipment cannot be sold" + return + var available := _bag.get_quantity(identity) + if _reservations != null: + available = _reservations.get_available_item_quantity(identity) + if available < 1: + _feedback.text = "that item is reserved" + return + _staged[key] = { + "kind": kind, + "identity": identity, + "quantity": available, + } + _feedback.text = "" + _refresh_tray() + + +func _on_drop_payload(payload: Dictionary) -> void: + var payload_kind := str(payload.get("kind", "")) + _stage( + PlayerInventoryLayout.EntryKind.CATCH + if payload_kind == "cooler_fish" + else PlayerInventoryLayout.EntryKind.ITEM, + StringName(str(payload.get( + "catch_id" if payload_kind == "cooler_fish" else "item_id", "" + ))), + ) + + +func _on_remove_requested(key: String) -> void: + _staged.erase(key) + _refresh_tray() + + +func _submit_sale() -> void: + if _staged.is_empty() or _network_sale == null: + return + var catch_ids: Array[StringName] = [] + var items: Dictionary[StringName, int] = {} + for record: Dictionary in _staged.values(): + var identity := StringName(str(record.get("identity", ""))) + if int(record.get("kind", -1)) == PlayerInventoryLayout.EntryKind.CATCH: + catch_ids.append(identity) + else: + items[identity] = int(record.get("quantity", 0)) + if _network_sale.request_local_mixed_sale( + catch_ids, items, MAIN_SHOP_BUYER_ID + ).is_empty(): + _feedback.text = "sale could not be started" + + +func _on_sale_pending(_request_id: String) -> void: + _sell_button.disabled = true + _feedback.text = "selling…" + + +func _on_sale_finished( + _request_id: String, + accepted: bool, + message: String, + _catch_ids: Array[StringName], + _payout: int, +) -> void: + if accepted: + _staged.clear() + _feedback.text = message.to_lower() + _refresh() + + +func _refresh() -> void: + _validate_staged() + _inventory_grid.refresh() + _refresh_tray() + + +func _validate_staged() -> void: + for key: String in _staged.keys(): + var record: Dictionary = _staged[key] + var identity := StringName(str(record.get("identity", ""))) + if int(record.get("kind", -1)) == PlayerInventoryLayout.EntryKind.CATCH: + if ( + _fish_inventory.get_catch_by_id(identity) == null + or not _layout.is_catch_in_inventory(identity) + ): + _staged.erase(key) + elif ( + _bag.get_quantity(identity) < int(record.get("quantity", 0)) + or not _layout.is_item_in_inventory(identity) + ): + _staged.erase(key) + + +func _refresh_tray() -> void: + _refresh_inventory_staged_highlights() + for child: Node in _tray_grid.get_children(): + child.queue_free() + var keys: Array[String] = [] + keys.assign(_staged.keys()) + keys.sort() + for key: String in keys: + var record: Dictionary = _staged[key] + var identity := StringName(str(record["identity"])) + var icon: Texture2D + var label: String + if int(record["kind"]) == PlayerInventoryLayout.EntryKind.CATCH: + var fish_catch := _fish_inventory.get_catch_by_id(identity) + if fish_catch == null: + continue + icon = fish_catch.fish.display_texture + label = fish_catch.fish.display_name + else: + var item := _item_catalog.get_item_by_id(identity) + if item == null: + continue + icon = item.icon + label = item.display_name + var slot := ShopSaleTraySlot.new() + _tray_grid.add_child(slot) + slot.configure(key, icon, label, int(record.get("quantity", 1))) + slot.remove_requested.connect(_on_remove_requested) + slot.drop_requested.connect(_on_drop_payload) + var drop_slot := ShopSaleTraySlot.new() + _tray_grid.add_child(drop_slot) + drop_slot.configure("", null, "drop an item here") + drop_slot.disabled = false + drop_slot.drop_requested.connect(_on_drop_payload) + var total := _calculate_total() + _total_label.text = str(total) + _sell_button.disabled = ( + _staged.is_empty() + or total < 0 + or _network_sale == null + or _network_sale.is_local_sale_pending() + ) + call_deferred("_configure_focus") + + +func _refresh_inventory_staged_highlights() -> void: + if _inventory_grid == null: + return + for slot: GeneralInventorySlot in _inventory_grid.get_slots(): + var key: String = "" + if not slot.entry_identity.is_empty(): + key = ( + PlayerInventoryLayout.catch_key(slot.entry_identity) + if slot.entry_kind == PlayerInventoryLayout.EntryKind.CATCH + else PlayerInventoryLayout.item_key(slot.entry_identity) + ) + slot.set_staged(not key.is_empty() and _staged.has(key)) + + +func _configure_focus() -> void: + var controls: Array[Control] = [] + controls.assign(_inventory_grid.get_slots()) + for child: Node in _tray_grid.get_children(): + var control := child as Control + if control != null and control.focus_mode != Control.FOCUS_NONE: + controls.append(control) + controls.append(_sell_button) + ControllerFocusNavigation.configure_spatial_neighbors(controls) + + +func _calculate_total() -> int: + var total: int = 0 + for record: Dictionary in _staged.values(): + var identity := StringName(str(record["identity"])) + if int(record["kind"]) == PlayerInventoryLayout.EntryKind.CATCH: + var fish_catch := _fish_inventory.get_catch_by_id(identity) + if fish_catch == null: + return -1 + total += _buyer.get_quality_offer( + fish_catch.fish.get_sale_value_for_weight(fish_catch.weight_lb), + fish_catch.quality, + ) + else: + var item := _item_catalog.get_item_by_id(identity) + var unit_value := ItemResalePolicy.get_unit_value(item) + if unit_value < 0: + return -1 + total += unit_value * int(record.get("quantity", 0)) + return total + + +func _build_ui() -> void: + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + var outer := MarginContainer.new() + outer.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + outer.add_theme_constant_override("margin_left", 68) + outer.add_theme_constant_override("margin_top", 92) + outer.add_theme_constant_override("margin_right", 68) + outer.add_theme_constant_override("margin_bottom", 44) + add_child(outer) + var columns := HBoxContainer.new() + columns.add_theme_constant_override("separation", 20) + outer.add_child(columns) + + var source := _build_panel(columns, "inventory") + _inventory_grid = GeneralInventoryGrid.new() + (source as VBoxContainer).add_child(_inventory_grid) + var tray := _build_panel(columns, "sell tray") + var tray_scroll := ScrollContainer.new() + tray_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + tray_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + (tray as VBoxContainer).add_child(tray_scroll) + _tray_grid = GridContainer.new() + _tray_grid.columns = PlayerInventoryLayout.INVENTORY_COLUMNS + _tray_grid.add_theme_constant_override( + "h_separation", GeneralInventoryGrid.DEFAULT_SLOT_SEPARATION + ) + _tray_grid.add_theme_constant_override( + "v_separation", GeneralInventoryGrid.DEFAULT_SLOT_SEPARATION + ) + tray_scroll.add_child(_tray_grid) + _feedback = Label.new() + _feedback.text = "select or drag items into the sell tray" + _feedback.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _feedback.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY + ) + (tray as VBoxContainer).add_child(_feedback) + var total_row := HBoxContainer.new() + (tray as VBoxContainer).add_child(total_row) + var total_title := Label.new() + total_title.text = "total" + total_title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + total_row.add_child(total_title) + var coin := TextureRect.new() + coin.custom_minimum_size = Vector2(24.0, 24.0) + coin.texture = preload("res://items/icons/shop/32_currency.png") + coin.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + coin.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + coin.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + total_row.add_child(coin) + _total_label = Label.new() + _total_label.text = "0" + total_row.add_child(_total_label) + _sell_button = Button.new() + _sell_button.text = "sell" + UtilityPageStyle.apply_ocean_button(_sell_button) + _sell_button.pressed.connect(_submit_sale) + (tray as VBoxContainer).add_child(_sell_button) + + +func _build_panel(parent: HBoxContainer, title_text: String) -> VBoxContainer: + var panel := PanelContainer.new() + panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + panel.add_theme_stylebox_override( + "panel", + UtilityPageStyle.rounded_style(UtilityPageStyle.OCEAN_PANEL_MID, 16), + ) + parent.add_child(panel) + var margin := MarginContainer.new() + for side: StringName in [&"margin_left", &"margin_top", &"margin_right", &"margin_bottom"]: + margin.add_theme_constant_override(side, 12) + panel.add_child(margin) + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", 8) + margin.add_child(column) + var title := Label.new() + title.text = title_text + title.add_theme_font_size_override("font_size", 22) + column.add_child(title) + return column diff --git a/ui/shop_sell_inventory.gd.uid b/ui/shop_sell_inventory.gd.uid new file mode 100644 index 0000000..ca1cfd5 --- /dev/null +++ b/ui/shop_sell_inventory.gd.uid @@ -0,0 +1 @@ +uid://cct5tvsc5igda diff --git a/ui/utility_page_style.gd b/ui/utility_page_style.gd index 67e96b7..edaa837 100644 --- a/ui/utility_page_style.gd +++ b/ui/utility_page_style.gd @@ -26,7 +26,8 @@ const GREEN: Color = Color("31594d") const LIGHT_TEXT: Color = Color("f5eed9") const DISABLED_TEXT: Color = Color(0.33, 0.36, 0.35, 0.72) const MOTION_TWEEN_META: StringName = &"utility_page_motion_tween" -const LAPTOP_RECT: Rect2 = Rect2(66.0, 132.0, 1148.0, 520.0) +const LAPTOP_RECT: Rect2 = Rect2(199.0, 132.0, 882.0, 520.0) +const LAPTOP_CONTENT_SIZE: Vector2 = Vector2(794.0, 442.0) const SUPPLY_BADGE_MIN_WIDTH: float = 46.0 const SUPPLY_BADGE_HEIGHT: float = 24.0 const SUPPLY_BADGE_EDGE_MARGIN: float = 3.0 diff --git a/world/digging/diggable_area_3d.gd b/world/digging/diggable_area_3d.gd new file mode 100644 index 0000000..1c34070 --- /dev/null +++ b/world/digging/diggable_area_3d.gd @@ -0,0 +1,114 @@ +@tool +class_name DiggableArea3D +extends Node3D + +@export var area_id: StringName +@export_node_path("Node3D") var terrain_source: NodePath +@export var surface_materials: Array[StringName] = [] +@export var generation_bounds := Rect2(-50.0, -50.0, 100.0, 100.0) +@export_range(-100.0, 100.0, 0.01) var minimum_global_y: float = -100.0 +@export_range(-100.0, 100.0, 0.01) var maximum_global_y: float = 100.0 +@export_range(0.0, 1.0, 0.01) var minimum_up_dot: float = 0.6 + + +func get_surface_triangles() -> Array[PackedVector3Array]: + var triangles: Array[PackedVector3Array] = [] + if area_id.is_empty() or surface_materials.is_empty(): + return triangles + var terrain_root: Node = get_node_or_null(terrain_source) + if terrain_root == null: + return triangles + for mesh_instance: MeshInstance3D in _collect_mesh_instances(terrain_root): + 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 surface_materials.has(StringName(material.resource_name)) + ): + continue + _append_surface_triangles( + triangles, + mesh_instance, + mesh.surface_get_arrays(surface_index), + ) + return triangles + + +func _append_surface_triangles( + result: Array[PackedVector3Array], + mesh_instance: MeshInstance3D, + arrays: Array, +) -> void: + if arrays.size() <= Mesh.ARRAY_INDEX: + return + var vertices := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array + var indices := arrays[Mesh.ARRAY_INDEX] as PackedInt32Array + if vertices.is_empty(): + return + if indices.is_empty(): + for vertex_index: int in range(0, vertices.size() - 2, 3): + _append_triangle( + result, + mesh_instance.to_global(vertices[vertex_index]), + mesh_instance.to_global(vertices[vertex_index + 1]), + mesh_instance.to_global(vertices[vertex_index + 2]), + ) + return + for index_offset: int in range(0, indices.size() - 2, 3): + _append_triangle( + result, + mesh_instance.to_global(vertices[indices[index_offset]]), + mesh_instance.to_global(vertices[indices[index_offset + 1]]), + mesh_instance.to_global(vertices[indices[index_offset + 2]]), + ) + + +func _append_triangle( + result: Array[PackedVector3Array], + a: Vector3, + b: Vector3, + c: Vector3, +) -> void: + if ( + a.y < minimum_global_y + or b.y < minimum_global_y + or c.y < minimum_global_y + or a.y > maximum_global_y + or b.y > maximum_global_y + or c.y > maximum_global_y + ): + return + var center := (a + b + c) / 3.0 + if not generation_bounds.has_point(Vector2(center.x, center.z)): + return + var cross := (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 _collect_mesh_instances(root: Node) -> Array[MeshInstance3D]: + var meshes: Array[MeshInstance3D] = [] + if root is MeshInstance3D: + meshes.append(root as MeshInstance3D) + for child: Node in root.get_children(): + meshes.append_array(_collect_mesh_instances(child)) + return meshes + + +func _get_configuration_warnings() -> PackedStringArray: + var warnings := PackedStringArray() + if area_id.is_empty(): + warnings.append("Diggable area ID is required.") + if get_node_or_null(terrain_source) == null: + warnings.append("Diggable area terrain source is unavailable.") + if surface_materials.is_empty(): + warnings.append("At least one terrain material is required.") + if maximum_global_y < minimum_global_y: + warnings.append("Maximum height must not be below minimum height.") + return warnings diff --git a/world/digging/diggable_area_3d.gd.uid b/world/digging/diggable_area_3d.gd.uid new file mode 100644 index 0000000..d0b6413 --- /dev/null +++ b/world/digging/diggable_area_3d.gd.uid @@ -0,0 +1 @@ +uid://d33f4164b78ww diff --git a/world/environment/netfishing_sky.gdshader b/world/environment/netfishing_sky.gdshader index 07d3c53..22af7ad 100644 --- a/world/environment/netfishing_sky.gdshader +++ b/world/environment/netfishing_sky.gdshader @@ -26,6 +26,65 @@ uniform float moon_edge_softness : hint_range(0.001, 0.03) = 0.003; uniform float moon_halo_radius : hint_range(0.02, 0.35) = 0.11; uniform float moon_halo_strength : hint_range(0.0, 1.0) = 0.12; +uniform vec4 star_color : source_color = vec4(0.72, 0.82, 0.96, 1.0); +uniform float star_visibility : hint_range(0.0, 1.0) = 0.0; +uniform float star_strength : hint_range(0.0, 1.0) = 0.72; + + +float star_hash(vec2 point) { + vec3 value = fract(vec3(point.xyx) * vec3(0.1031, 0.1030, 0.0973)); + value += dot(value, value.yzx + 33.33); + return fract((value.x + value.y) * value.z); +} + + +vec2 star_hash2(vec2 point) { + return vec2( + star_hash(point), + star_hash(point + vec2(19.19, 7.73)) + ); +} + + +float procedural_star_field(vec3 view_direction) { + // Map the upper hemisphere to one stable world-direction diamond. This + // avoids a longitude seam and keeps the stars fixed as the camera moves. + float direction_sum = max( + abs(view_direction.x) + + abs(view_direction.y) + + abs(view_direction.z), + 0.0001 + ); + vec2 star_uv = view_direction.xz / direction_sum * 0.5 + 0.5; + vec2 star_point = star_uv * 190.0; + vec2 cell = floor(star_point); + vec2 local_point = fract(star_point); + float spawn_roll = star_hash(cell + vec2(3.17, 11.83)); + vec2 point_center = mix( + vec2(0.18), + vec2(0.82), + star_hash2(cell + vec2(41.0, 73.0)) + ); + float point_radius = mix( + 0.065, + 0.13, + star_hash(cell + vec2(89.0, 17.0)) + ); + float point_shape = 1.0 - smoothstep( + point_radius, + point_radius + 0.045, + distance(local_point, point_center) + ); + float point_exists = step(0.965, spawn_roll); + float point_brightness = mix( + 0.42, + 1.0, + smoothstep(0.965, 1.0, spawn_roll) + ); + float upper_sky_fade = smoothstep(0.055, 0.22, view_direction.y); + return point_shape * point_exists * point_brightness * upper_sky_fade; +} + void sky() { vec3 view_direction = normalize(EYEDIR); @@ -50,6 +109,12 @@ void sky() { view_direction.y ) ); + float stars = procedural_star_field(view_direction); + base_color = mix( + base_color, + star_color.rgb, + stars * star_visibility * star_strength + ); float alignment = dot(view_direction, normalize(sun_direction)); float disc_outer = cos(sun_radius + sun_edge_softness); diff --git a/world/environment/netfishing_sky.tres b/world/environment/netfishing_sky.tres index f94a6e4..98fb4e0 100644 --- a/world/environment/netfishing_sky.tres +++ b/world/environment/netfishing_sky.tres @@ -31,6 +31,9 @@ shader_parameter/moon_radius = 0.045 shader_parameter/moon_edge_softness = 0.003 shader_parameter/moon_halo_radius = 0.11 shader_parameter/moon_halo_strength = 0.12 +shader_parameter/star_color = Color(0.72, 0.82, 0.96, 1) +shader_parameter/star_visibility = 0.0 +shader_parameter/star_strength = 0.72 [resource] sky_material = SubResource("ShaderMaterial_netfishing") diff --git a/world/gathering/gatherable_anchor_set_3d.gd b/world/gathering/gatherable_anchor_set_3d.gd new file mode 100644 index 0000000..012f6ee --- /dev/null +++ b/world/gathering/gatherable_anchor_set_3d.gd @@ -0,0 +1,30 @@ +@tool +class_name GatherableAnchorSet3D +extends Node3D + +@export var anchor_set_id: StringName + + +func get_spawn_positions() -> PackedVector3Array: + var positions := PackedVector3Array() + _collect_marker_positions(self, positions) + return positions + + +func _collect_marker_positions( + root_node: Node, + positions: PackedVector3Array, +) -> void: + for child: Node in root_node.get_children(): + if child is Marker3D: + positions.append((child as Marker3D).global_position) + _collect_marker_positions(child, positions) + + +func _get_configuration_warnings() -> PackedStringArray: + var warnings := PackedStringArray() + if anchor_set_id.is_empty(): + warnings.append("Gatherable anchor-set ID is required.") + if get_spawn_positions().is_empty(): + warnings.append("Add at least one Marker3D spawn anchor.") + return warnings diff --git a/world/gathering/gatherable_anchor_set_3d.gd.uid b/world/gathering/gatherable_anchor_set_3d.gd.uid new file mode 100644 index 0000000..552ecd2 --- /dev/null +++ b/world/gathering/gatherable_anchor_set_3d.gd.uid @@ -0,0 +1 @@ +uid://b6xfixi21qllm diff --git a/world/interactables/player_storage_box.tscn b/world/interactables/player_storage_box.tscn new file mode 100644 index 0000000..4bf9a8e --- /dev/null +++ b/world/interactables/player_storage_box.tscn @@ -0,0 +1,60 @@ +[gd_scene load_steps=9 format=3] + +[ext_resource type="Script" path="res://world/player_storage_interaction.gd" id="1_interaction"] + +[sub_resource type="StandardMaterial3D" id="BoxMaterial"] +shading_mode = 0 +albedo_color = Color(0.047, 0.22, 0.27, 1) +texture_filter = 0 + +[sub_resource type="StandardMaterial3D" id="LidMaterial"] +shading_mode = 0 +albedo_color = Color(0.12, 0.52, 0.58, 1) +texture_filter = 0 + +[sub_resource type="BoxMesh" id="BoxMesh"] +material = SubResource("BoxMaterial") +size = Vector3(1.5, 0.7, 0.9) + +[sub_resource type="BoxMesh" id="LidMesh"] +material = SubResource("LidMaterial") +size = Vector3(1.62, 0.16, 1.02) + +[sub_resource type="BoxShape3D" id="CollisionShape"] +size = Vector3(1.62, 0.86, 1.02) + +[sub_resource type="SphereShape3D" id="InteractionShape"] +radius = 2.3 + +[node name="PlayerStorageBox" type="Node3D"] + +[node name="Body" type="MeshInstance3D" parent="."] +position = Vector3(0, 0.35, 0) +mesh = SubResource("BoxMesh") +cast_shadow = 0 + +[node name="Lid" type="MeshInstance3D" parent="."] +position = Vector3(0, 0.76, 0) +mesh = SubResource("LidMesh") +cast_shadow = 0 + +[node name="StaticBody3D" type="StaticBody3D" parent="."] +collision_layer = 1 +collision_mask = 0 + +[node name="CollisionShape3D" type="CollisionShape3D" parent="StaticBody3D"] +position = Vector3(0, 0.43, 0) +shape = SubResource("CollisionShape") + +[node name="InteractionArea" type="Area3D" parent="."] +unique_name_in_owner = true +position = Vector3(0, 0.55, 0) +collision_layer = 0 +collision_mask = 2 +script = ExtResource("1_interaction") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="InteractionArea"] +shape = SubResource("InteractionShape") + +[node name="PromptAnchor" type="Marker3D" parent="."] +position = Vector3(0, 1.15, 0) diff --git a/world/player_storage_interaction.gd b/world/player_storage_interaction.gd new file mode 100644 index 0000000..29fe18d --- /dev/null +++ b/world/player_storage_interaction.gd @@ -0,0 +1,53 @@ +class_name PlayerStorageInteraction +extends Area3D + +signal local_player_range_changed(in_range: bool) + +@export_node_path("Marker3D") var prompt_anchor_path: NodePath = ^"../PromptAnchor" + +var _local_player: Player +var _local_player_in_range: bool = false + + +func _ready() -> void: + body_entered.connect(_on_body_entered) + body_exited.connect(_on_body_exited) + + +func setup_local_player(player: Player) -> void: + _local_player = player + _refresh_overlaps() + + +func is_local_player_in_range() -> bool: + return _local_player_in_range + + +func get_prompt_anchor_position() -> Vector3: + var anchor := get_node_or_null(prompt_anchor_path) as Marker3D + return anchor.global_position if anchor != null else global_position + Vector3.UP + + +func _refresh_overlaps() -> void: + _set_local_player_in_range( + _local_player != null + and is_instance_valid(_local_player) + and _local_player in get_overlapping_bodies() + ) + + +func _on_body_entered(body: Node3D) -> void: + if body == _local_player: + _set_local_player_in_range(true) + + +func _on_body_exited(body: Node3D) -> void: + if body == _local_player: + _set_local_player_in_range(false) + + +func _set_local_player_in_range(in_range: bool) -> void: + if _local_player_in_range == in_range: + return + _local_player_in_range = in_range + local_player_range_changed.emit(in_range) diff --git a/world/player_storage_interaction.gd.uid b/world/player_storage_interaction.gd.uid new file mode 100644 index 0000000..249863c --- /dev/null +++ b/world/player_storage_interaction.gd.uid @@ -0,0 +1 @@ +uid://7sl42teh8qgb diff --git a/world/regions/starter_island_region.gd b/world/regions/starter_island_region.gd index 477c5d2..a7dc81e 100644 --- a/world/regions/starter_island_region.gd +++ b/world/regions/starter_island_region.gd @@ -5,6 +5,9 @@ extends WorldRegion const FishingShopInteractionType = preload( "res://world/fishing_shop_interaction.gd" ) +const PlayerStorageInteractionType = preload( + "res://world/player_storage_interaction.gd" +) const FOLIAGE_WIND_SHADER: Shader = preload( "res://world/materials/foliage_wind.gdshader" ) @@ -39,6 +42,10 @@ var player_spawn_path: NodePath = ^"PlayerSpawn" var fishing_shop_path: NodePath = ( ^"Interactables/FishingShopWorld/InteractionArea" ) +@export_node_path("Area3D") +var player_storage_path: NodePath = ( + ^"Interactables/PlayerStorageBox/InteractionArea" +) @export_group("Terrain Collision") # The imported GLB is the collision authority. Rebuild once per region load so # newly authored terrain and props cannot retain a stale saved fallback shape. @@ -67,6 +74,10 @@ func get_fishing_shop() -> FishingShopInteractionType: return get_node_or_null(fishing_shop_path) as FishingShopInteractionType +func get_player_storage() -> PlayerStorageInteractionType: + return get_node_or_null(player_storage_path) as PlayerStorageInteractionType + + func get_saltwater_shoreline_mesh() -> MeshInstance3D: return get_node_or_null(^"ShorelineRibbons/Ocean") as MeshInstance3D @@ -363,4 +374,6 @@ func _get_configuration_warnings() -> PackedStringArray: warnings.append("PlayerSpawn marker is missing.") if get_node_or_null(fishing_shop_path) == null: warnings.append("Fishing Shop placement is missing.") + if get_node_or_null(player_storage_path) == null: + warnings.append("Private player storage placement is missing.") return warnings diff --git a/world/regions/starter_island_region.tscn b/world/regions/starter_island_region.tscn index 67cddc3..dfd450c 100644 --- a/world/regions/starter_island_region.tscn +++ b/world/regions/starter_island_region.tscn @@ -8,12 +8,15 @@ [ext_resource type="Script" uid="uid://dnlbnlimgxoc2" path="res://world/player_water_trigger.gd" id="5_trigger"] [ext_resource type="Script" uid="uid://df2lsduguegxh" path="res://world/safe_respawn_point.gd" id="6_safe"] [ext_resource type="PackedScene" path="res://world/interactables/fishing_shop_world.tscn" id="7_shop"] +[ext_resource type="PackedScene" path="res://world/interactables/player_storage_box.tscn" id="8_storage"] [ext_resource type="Script" uid="uid://dtcgnxl1kmdu1" path="res://world/water_body_authoring.gd" id="10_water_body"] [ext_resource type="Material" path="res://world/materials/stylized_water.tres" id="13_water"] [ext_resource type="ArrayMesh" path="res://world/generated/shorelines/starter_ocean_shoreline.tres" id="17_ocean_shoreline"] [ext_resource type="Material" path="res://world/materials/stylized_water_fresh.tres" id="19_fresh_water"] [ext_resource type="Script" path="res://world/water/shoreline_ribbon_baker.gd" id="21_shoreline_baker"] [ext_resource type="Script" path="res://world/water/shoreline_ribbon_config.gd" id="22_shoreline_config"] +[ext_resource type="Script" path="res://world/digging/diggable_area_3d.gd" id="23_diggable_area"] +[ext_resource type="Script" path="res://world/gathering/gatherable_anchor_set_3d.gd" id="24_gatherable_anchors"] [sub_resource type="ConcavePolygonShape3D" id="ConcavePolygonShape3D_4e4k5"] data = PackedVector3Array(10.033899, 3.3913999, 3.3446, 13.3786, 3.3913999, 0, 13.3786, 3.3913999, 3.3446, 10.033899, 3.3913999, 3.3446, 10.033899, 3.3913999, 0, 13.3786, 3.3913999, 0, 6.6893, 3.3913999, 3.3446, 10.033899, 3.3913999, 0, 10.033899, 3.3913999, 3.3446, 6.6893, 3.3913999, 3.3446, 6.6893, 3.3913999, 0, 10.033899, 3.3913999, 0, 6.6893, 3.3913999, 0, 10.033899, 3.3913999, -3.3446, 10.033899, 3.3913999, 0, 6.6893, 3.3913999, 6.6893, 6.6893, 3.3913999, 3.3446, 10.033899, 3.3913999, 3.3446, 6.6893, 3.3913999, 6.6893, 10.033899, 3.3913999, 3.3446, 10.033899, 3.3913999, 6.6893, 3.3446, 3.3913999, 3.3446, 6.6893, 3.3913999, 0, 6.6893, 3.3913999, 3.3446, 6.6893, 3.3913999, 10.033899, 6.6893, 3.3913999, 6.6893, 10.033899, 3.3913999, 6.6893, 6.6893, 3.3913999, 10.033899, 10.033899, 3.3913999, 6.6893, 10.033899, 3.3913999, 10.033899, 3.3446, 3.3913999, 6.6893, 6.6893, 3.3913999, 3.3446, 6.6893, 3.3913999, 6.6893, 3.3446, 3.3913999, 6.6893, 3.3446, 3.3913999, 3.3446, 6.6893, 3.3913999, 3.3446, 3.3446, 3.3913999, 10.033899, 6.6893, 3.3913999, 6.6893, 6.6893, 3.3913999, 10.033899, 3.3446, 3.3913999, 10.033899, 3.3446, 3.3913999, 6.6893, 6.6893, 3.3913999, 6.6893, 0, 3.3913999, 6.6893, 3.3446, 3.3913999, 3.3446, 3.3446, 3.3913999, 6.6893, 0, 3.3913999, 6.6893, 0, 3.3913999, 3.3446, 3.3446, 3.3913999, 3.3446, 0, 3.3913999, 10.033899, 0, 3.3913999, 6.6893, 3.3446, 3.3913999, 6.6893, 0, 3.3913999, 10.033899, 3.3446, 3.3913999, 6.6893, 3.3446, 3.3913999, 10.033899, -3.3446, 3.3913999, 13.3786, -3.3446, 3.3913999, 10.033899, 0, 3.3913999, 10.033899, -3.3446, 3.3913999, 13.3786, 0, 3.3913999, 10.033899, 0, 3.3913999, 13.3786, 0, 3.3913999, 13.3786, 0, 3.3913999, 10.033899, 3.3446, 3.3913999, 10.033899, 0, 3.3913999, 13.3786, 3.3446, 3.3913999, 10.033899, 3.3446, 3.3913999, 13.3786, 3.3446, 3.3913999, 13.3786, 3.3446, 3.3913999, 10.033899, 6.6893, 3.3913999, 10.033899, 3.3446, 3.3913999, 13.3786, 6.6893, 3.3913999, 10.033899, 6.6893, 3.3913999, 13.3786, 3.9148, 3.3913999, 16.514599, 3.3446, 3.3913999, 13.3786, 6.6893, 3.3913999, 13.3786, 3.3446, 3.3913999, 13.3786, 3.9148, 3.3913999, 16.514599, 3.6399, 3.3913999, 16.2405, 3.9148, 3.3913999, 16.514599, 6.6893, 3.3913999, 13.3786, 6.6893, 3.3913999, 16.7232, 6.6893, 3.3913999, 16.7232, 6.6949, 3.3913999, 18.9914, 6.6893, 3.3913999, 18.9861, 6.6949, 3.3913999, 18.9914, 6.6893, 3.3913999, 16.7232, 10.3743, 3.3913999, 16.7232, 6.6949, 3.3913999, 18.9914, 10.3743, 3.3913999, 16.7232, 9.1275, 3.3913999, 18.830599, 10.033899, 3.3913999, -10.033899, 13.3786, 3.3913999, -13.3786, 13.3786, 3.3913999, -10.033899, 13.3786, 3.3913999, -13.3786, 16.7232, 3.3913999, -10.033899, 13.3786, 3.3913999, -10.033899, 16.7232, 3.3913999, -10.033899, 19.798899, 3.3913999, -13.1096, 19.798899, 3.3913999, -10.033899, 16.7232, 3.3913999, -10.033899, 16.7232, 3.3913999, -13.1096, 19.798899, 3.3913999, -13.1096, 16.7232, 3.3913999, -10.033899, 16.4543, 3.3913999, -13.3786, 16.7232, 3.3913999, -13.1096, 16.7232, 3.3913999, -10.033899, 13.3786, 3.3913999, -13.3786, 16.4543, 3.3913999, -13.3786, 13.3786, 3.3913999, -6.6893, 13.3786, 3.3913999, -10.033899, 16.7232, 3.3913999, -10.033899, 13.3786, 3.3913999, -6.6893, 16.7232, 3.3913999, -10.033899, 16.7232, 3.3913999, -6.6893, 10.033899, 3.3913999, -6.6893, 13.3786, 3.3913999, -10.033899, 13.3786, 3.3913999, -6.6893, 10.033899, 3.3913999, -6.6893, 10.033899, 3.3913999, -10.033899, 13.3786, 3.3913999, -10.033899, 16.4543, 3.3913999, -13.3786, 16.495699, 3.3913999, -16.459, 16.495699, 3.3913999, -13.42, 16.4543, 3.3913999, -13.3786, 12.738999, 3.3913999, -15.894899, 16.495699, 3.3913999, -16.459, 12.738999, 3.3913999, -15.894899, 16.4543, 3.3913999, -13.3786, 13.3786, 3.3913999, -13.3786, 13.3786, 3.3913999, -13.3786, 12.460899, 3.3913999, -16.1674, 12.738999, 3.3913999, -15.894899, 13.3786, 3.3913999, -13.3786, 10.033899, 3.3913999, -16.7232, 12.460899, 3.3913999, -16.1674, 10.033899, 3.3913999, -10.033899, 10.033899, 3.3913999, -13.3786, 13.3786, 3.3913999, -13.3786, 10.033899, 3.3913999, -16.7232, 13.3786, 3.3913999, -13.3786, 10.033899, 3.3913999, -13.3786, 6.6893, 3.3913999, -13.3786, 10.033899, 3.3913999, -16.7232, 10.033899, 3.3913999, -13.3786, 6.6893, 3.3913999, -13.3786, 6.6893, 3.3913999, -16.7232, 10.033899, 3.3913999, -16.7232, 6.6893, 3.3913999, -10.033899, 10.033899, 3.3913999, -13.3786, 10.033899, 3.3913999, -10.033899, 6.6893, 3.3913999, -10.033899, 6.6893, 3.3913999, -13.3786, 10.033899, 3.3913999, -13.3786, 6.6893, 3.3913999, -6.6893, 6.6893, 3.3913999, -10.033899, 10.033899, 3.3913999, -10.033899, 6.6893, 3.3913999, -6.6893, 10.033899, 3.3913999, -10.033899, 10.033899, 3.3913999, -6.6893, 4.0225, 3.3913999, -8.7143, 6.6893, 3.3913999, -13.3786, 6.6893, 3.3913999, -10.033899, 4.6821, 3.3913999, -13.3786, 6.6893, 3.3913999, -16.7232, 6.6893, 3.3913999, -13.3786, 4.0225, 3.3913999, -8.7143, 4.6821, 3.3913999, -13.3786, 6.6893, 3.3913999, -13.3786, 4.6821, 3.3913999, -13.3786, 3.8388, 3.3913999, -16.7232, 6.6893, 3.3913999, -16.7232, 4.6821, 3.3913999, -13.3786, 4.0225, 3.3913999, -8.7143, 4.6821, 3.3913999, -13.3786, 4.6821, 3.3913999, -13.3786, 4.6821, 3.3913999, -13.3786, 4.4131, 3.2091, -13.357699, 4.6821, 3.3913999, -13.3786, 4.4131, 3.2091, -13.357699, 4.4131, 3.2091, -13.357699, 3.3446, 3.3913999, -6.6893, 4.0225, 3.3913999, -8.7143, 6.6893, 3.3913999, -10.033899, 3.3446, 3.3913999, -6.6893, 6.6893, 3.3913999, -10.033899, 6.6893, 3.3913999, -6.6893, 0, 3.3913999, -6.6893, 4.0225, 3.3913999, -8.7143, 3.3446, 3.3913999, -6.6893, 0, 3.3913999, -6.6893, 0, 3.3913999, -8.7143, 4.0225, 3.3913999, -8.7143, 3.3446, 3.3913999, -3.3446, 3.3446, 3.3913999, -6.6893, 6.6893, 3.3913999, -6.6893, 0, 3.3913999, -6.6893, -3.9101999, 3.3913999, -9.8592, 0, 3.3913999, -8.7143, 0, 3.3913999, -8.7143, -3.9101999, 3.3913999, -9.8592, 0, 3.3913999, -8.7143, 0, 3.3913999, -3.3446, 0, 3.3913999, -6.6893, 3.3446, 3.3913999, -6.6893, 0, 3.3913999, -3.3446, 3.3446, 3.3913999, -6.6893, 3.3446, 3.3913999, -3.3446, -3.6378, 3.3913999, -2.0792, 0, 3.3913999, -6.6893, 0, 3.3913999, -3.3446, -2.9566998, 3.3913999, 0, -3.6378, 3.3913999, -2.0792, 0, 3.3913999, -3.3446, -3.6378, 3.3913999, -2.0792, -3.3446, 3.3913999, -6.6893, 0, 3.3913999, -6.6893, -3.9101999, 3.3913999, -9.8592, 0, 3.3913999, -6.6893, -3.3446, 3.3913999, -6.6893, -6.6893, 3.3913999, -3.7326, -3.3446, 3.3913999, -6.6893, -3.6378, 3.3913999, -2.0792, -2.9566998, 3.3913999, 0, 0, 3.3913999, -3.3446, 0, 3.3913999, 0, 0, 3.3913999, 0, 0, 3.3913999, -3.3446, 3.3446, 3.3913999, -3.3446, -2.9566998, 3.3913999, 3.3446, -2.9566998, 3.3913999, 0, 0, 3.3913999, 0, -2.9566998, 3.3913999, 3.3446, 0, 3.3913999, 0, 0, 3.3913999, 3.3446, -6.6893, 3.3913999, -3.7326, -6.6893, 3.3913999, -6.6893, -3.3446, 3.3913999, -6.6893, -6.6893, 3.3913999, -6.6893, -3.9101999, 3.3913999, -9.8592, -3.3446, 3.3913999, -6.6893, -10.033899, 3.3913999, -3.7326, -6.6893, 3.3913999, -6.6893, -6.6893, 3.3913999, -3.7326, 0, 3.3913999, 3.3446, 0, 3.3913999, 0, 3.3446, 3.3913999, 0, 0, 3.3913999, 0, 3.3446, 3.3913999, -3.3446, 3.3446, 3.3913999, 0, 0, 3.3913999, 3.3446, 3.3446, 3.3913999, 0, 3.3446, 3.3913999, 3.3446, 3.3446, 3.3913999, 3.3446, 3.3446, 3.3913999, 0, 6.6893, 3.3913999, 0, 3.3446, 3.3913999, 0, 3.3446, 3.3913999, -3.3446, 6.6893, 3.3913999, -3.3446, 3.3446, 3.3913999, 0, 6.6893, 3.3913999, -3.3446, 6.6893, 3.3913999, 0, 3.3446, 3.3913999, -3.3446, 6.6893, 3.3913999, -6.6893, 6.6893, 3.3913999, -3.3446, 6.6893, 3.3913999, 0, 6.6893, 3.3913999, -3.3446, 10.033899, 3.3913999, -3.3446, 6.6893, 3.3913999, -3.3446, 6.6893, 3.3913999, -6.6893, 10.033899, 3.3913999, -6.6893, 6.6893, 3.3913999, -3.3446, 10.033899, 3.3913999, -6.6893, 10.033899, 3.3913999, -3.3446, -6.6893, 3.3913999, -6.6893, -6.6893, 3.3913999, -10.033899, -3.9101999, 3.3913999, -9.8592, -6.6893, 3.3913999, -10.033899, -3.9101999, 3.3913999, -12.538199, -3.9101999, 3.3913999, -9.8592, -10.033899, 3.3913999, -3.7326, -10.033899, 3.3913999, -6.6893, -6.6893, 3.3913999, -6.6893, -10.033899, 3.3913999, -6.6893, -6.6893, 3.3913999, -10.033899, -6.6893, 3.3913999, -6.6893, -13.3786, 3.3913999, -3.7326, -10.033899, 3.3913999, -6.6893, -10.033899, 3.3913999, -3.7326, -6.6893, 3.3913999, -10.033899, -6.6893, 3.3913999, -12.538199, -3.9101999, 3.3913999, -12.538199, -13.3786, 3.3913999, -3.7326, -13.3786, 3.3913999, -6.6893, -10.033899, 3.3913999, -6.6893, -16.191599, 3.3913999, -3.0971, -13.3786, 3.3913999, -6.6893, -13.3786, 3.3913999, -3.7326, -10.033899, 3.3913999, -6.6893, -10.033899, 3.3913999, -10.033899, -6.6893, 3.3913999, -10.033899, -13.3786, 3.3913999, -6.6893, -10.033899, 3.3913999, -10.033899, -10.033899, 3.3913999, -6.6893, -16.191599, 3.3913999, -3.0971, -16.7232, 3.3913999, -6.6893, -13.3786, 3.3913999, -6.6893, -13.3786, 3.3913999, -6.6893, -13.3786, 3.3913999, -10.033899, -10.033899, 3.3913999, -10.033899, -19.7545, 3.3913999, -2.7851999, -16.7232, 3.3913999, -6.6893, -16.191599, 3.3913999, -3.0971, -19.7545, 3.3913999, -2.7851999, -19.280699, 3.3913999, -6.6893, -16.7232, 3.3913999, -6.6893, -17.1112, 3.3913999, 0, -19.7545, 3.3913999, -2.7851999, -16.191599, 3.3913999, -3.0971, -17.1112, 3.3913999, 0, -19.9511, 3.3913999, -2.5834, -19.7545, 3.3913999, -2.7851999, -19.9511, 3.3913999, -2.5834, -17.1112, 3.3913999, 0, -20.0679, 3.3913999, 0, -20.0679, 3.3913999, 3.3446, -20.0679, 3.3913999, 0, -17.1112, 3.3913999, 0, -20.0679, 3.3913999, 3.3446, -17.1112, 3.3913999, 0, -17.1112, 3.3913999, 3.3044999, -13.3786, 3.3913999, -10.033899, -10.699599, 3.3913999, -13.175799, -10.033899, 3.3913999, -10.033899, -10.699599, 3.3913999, -13.175799, -6.6893, 3.3913999, -10.033899, -10.033899, 3.3913999, -10.033899, -13.3786, 3.3913999, -10.033899, -13.3786, 3.3913999, -13.3786, -10.699599, 3.3913999, -13.175799, -6.6893, 3.3913999, -10.033899, -10.699599, 3.3913999, -13.175799, -6.6893, 3.3913999, -12.538199, -13.3786, 3.3913999, -13.3786, -10.699599, 3.3913999, -15.835, -10.699599, 3.3913999, -13.175799, -13.3786, 3.3913999, -13.3786, -13.3786, 3.3913999, -15.4154, -10.699599, 3.3913999, -15.835, -6.6893, 3.3913999, -12.538199, -10.699599, 3.3913999, -13.175799, -6.6893, 3.3913999, -12.538199, -6.6893, 3.3913999, -12.538199, -6.6893, 3.3913999, -12.538199, -6.6893, 3.175, -12.867, -6.6893, 3.3913999, -12.538199, -6.6893, 3.175, -12.867, -6.6893, 3.175, -12.867, -20.0679, 3.3913999, 6.6893, -20.0679, 3.3913999, 3.3446, -17.1112, 3.3913999, 3.3044999, -22.5541, 3.3913999, 9.2058, -22.3151, 3.3913999, 6.6893, -20.0679, 3.3913999, 6.6893, -22.5541, 3.3913999, 9.2058, -20.0679, 3.3913999, 6.6893, -20.0679, 3.3913999, 9.167399, -20.0679, 3.3913999, 9.167399, -20.0679, 3.3913999, 6.6893, -16.7232, 3.3913999, 6.6893, -20.0679, 3.3913999, 6.6893, -17.1112, 3.3913999, 3.3044999, -16.7232, 3.3913999, 6.6893, -20.0679, 3.3913999, 9.167399, -16.7232, 3.3913999, 6.6893, -16.285099, 3.3913999, 9.5865, -16.7232, 3.3913999, 6.6893, -16.053999, 3.3913999, 9.8082, -16.285099, 3.3913999, 9.5865, -16.053999, 3.3913999, 9.8082, -16.7232, 3.3913999, 6.6893, -13.1355, 3.3913999, 6.7549996, -17.1112, 3.3913999, 3.3044999, -13.1355, 3.3913999, 6.7549996, -16.7232, 3.3913999, 6.6893, -16.053999, 3.3913999, 9.8082, -13.1355, 3.3913999, 6.7549996, -13.3786, 3.3913999, 10.033899, -13.1355, 3.3913999, 6.7549996, -17.1112, 3.3913999, 3.3044999, -14.877199, 3.3913999, 4.6657, -14.877199, 3.3913999, 4.6657, -17.1112, 3.3913999, 3.3044999, -15.582299, 3.3913999, 4.0366, -13.1355, 3.3913999, 6.7549996, -14.877199, 3.3913999, 4.6657, -14.0948, 3.3913999, 5.5073, -13.3786, 3.3913999, 10.033899, -13.1355, 3.3913999, 6.7549996, -10.033899, 3.3913999, 7.0772, -13.3786, 3.3913999, 10.033899, -10.033899, 3.3913999, 7.0772, -10.033899, 3.3913999, 10.033899, -10.033899, 3.3913999, 10.033899, -10.033899, 3.3913999, 7.0772, -6.6893, 3.3913999, 7.0772, -10.033899, 3.3913999, 10.033899, -6.6893, 3.3913999, 7.0772, -6.6893, 3.3913999, 10.033899, -10.033899, 3.0033998, 6.6893, -13.1355, 3.3913999, 6.7549996, -12.747499, 3.0033998, 6.3671, -10.033899, 3.0033998, 6.6893, -10.033899, 3.3913999, 7.0772, -13.1355, 3.3913999, 6.7549996, -2.5674999, 2.4164, 7.6215, -6.6893, 3.3913999, 7.0772, -6.6893, 3.0033998, 6.6893, -2.5674999, 2.4164, 7.6215, -2.1794999, 2.8042998, 8.009399, -6.6893, 3.3913999, 7.0772, -12.747499, 3.0033998, 6.3671, -14.0948, 3.3913999, 5.5073, -13.6942, 3.0033998, 5.1798, -12.747499, 3.0033998, 6.3671, -13.1355, 3.3913999, 6.7549996, -14.0948, 3.3913999, 5.5073, -6.6893, 3.0033998, 6.6893, -10.033899, 3.3913999, 7.0772, -10.033899, 3.0033998, 6.6893, -6.6893, 3.0033998, 6.6893, -6.6893, 3.3913999, 7.0772, -10.033899, 3.3913999, 7.0772, -16.7232, 3.0033998, 0, -16.191599, 3.3913999, -3.0971, -15.8036995, 3.0033998, -2.7091, -16.7232, 3.0033998, 0, -17.1112, 3.3913999, 0, -16.191599, 3.3913999, -3.0971, -13.3786, 3.3913999, -3.7326, -15.8036995, 3.0033998, -2.7091, -16.191599, 3.3913999, -3.0971, -13.3786, 3.3913999, -3.7326, -13.3786, 3.0033998, -3.3446, -15.8036995, 3.0033998, -2.7091, -2.9566998, 3.3913999, 0, -4.0257998, 3.0033998, -1.6911999, -3.6378, 3.3913999, -2.0792, -2.9566998, 3.3913999, 0, -3.3446, 3.0033998, 0, -4.0257998, 3.0033998, -1.6911999, -3.6378, 3.3913999, -2.0792, -6.6893, 3.0033998, -3.3446, -6.6893, 3.3913999, -3.7326, -3.6378, 3.3913999, -2.0792, -4.0257998, 3.0033998, -1.6911999, -6.6893, 3.0033998, -3.3446, -10.033899, 3.3913999, -3.7326, -13.3786, 3.0033998, -3.3446, -13.3786, 3.3913999, -3.7326, -10.033899, 3.3913999, -3.7326, -10.033899, 3.0033998, -3.3446, -13.3786, 3.0033998, -3.3446, -6.6893, 3.3913999, -3.7326, -10.033899, 3.0033998, -3.3446, -10.033899, 3.3913999, -3.7326, -6.6893, 3.3913999, -3.7326, -6.6893, 3.0033998, -3.3446, -10.033899, 3.0033998, -3.3446, -16.7232, 3.0033998, 2.9166, -17.1112, 3.3913999, 0, -16.7232, 3.0033998, 0, -16.7232, 3.0033998, 2.9166, -17.1112, 3.3913999, 3.3044999, -17.1112, 3.3913999, 0, -14.4892, 3.0033998, 4.2778, -15.582299, 3.3913999, 4.0366, -15.372, 3.0033998, 3.6132998, -14.4892, 3.0033998, 4.2778, -14.877199, 3.3913999, 4.6657, -15.582299, 3.3913999, 4.0366, -2.9566998, 3.3913999, 3.3446, -3.3446, 3.0033998, 0, -2.9566998, 3.3913999, 0, -2.9566998, 3.3913999, 3.3446, -3.3446, 3.0033998, 3.3446, -3.3446, 3.0033998, 0, -2.1794999, 2.8042998, 8.009399, -2.1397, 3.0033998, 5.1795, -1.7832999, 3.3913999, 5.2162, -2.1794999, 2.8042998, 8.009399, -2.5674999, 2.4164, 7.6215, -2.1397, 3.0033998, 5.1795, -6.6893, 3.3913999, 10.033899, -2.1794999, 2.8042998, 8.009399, -3.3446, 3.3913999, 10.033899, -6.6893, 3.3913999, 10.033899, -6.6893, 3.3913999, 7.0772, -2.1794999, 2.8042998, 8.009399, -1.7832999, 3.3913999, 5.2162, 0, 3.3913999, 6.6893, -2.1794999, 2.8042998, 8.009399, 0, 3.3913999, 6.6893, -1.7832999, 3.3913999, 5.2162, -2.9566998, 3.3913999, 3.3446, 0, 3.3913999, 6.6893, -2.9566998, 3.3913999, 3.3446, 0, 3.3913999, 3.3446, -3.3446, 3.3913999, 10.033899, 0, 3.3913999, 6.6893, 0, 3.3913999, 10.033899, -3.3446, 3.3913999, 10.033899, -2.1794999, 2.8042998, 8.009399, 0, 3.3913999, 6.6893, -2.1397, 3.0033998, 5.1795, -2.9566998, 3.3913999, 3.3446, -1.7832999, 3.3913999, 5.2162, -2.1397, 3.0033998, 5.1795, -3.3446, 3.0033998, 3.3446, -2.9566998, 3.3913999, 3.3446, -14.0948, 3.3913999, 5.5073, -14.4892, 3.0033998, 4.2778, -13.6942, 3.0033998, 5.1798, -14.0948, 3.3913999, 5.5073, -14.877199, 3.3913999, 4.6657, -14.4892, 3.0033998, 4.2778, -15.582299, 3.3913999, 4.0366, -16.7232, 3.0033998, 2.9166, -15.372, 3.0033998, 3.6132998, -15.582299, 3.3913999, 4.0366, -17.1112, 3.3913999, 3.3044999, -16.7232, 3.0033998, 2.9166, 20.198, 3.1490998, -10.033899, 19.798899, 3.3913999, -13.1096, 20.1539, 3.1299, -13.399799, 20.198, 3.1490998, -10.033899, 19.798899, 3.3913999, -10.033899, 19.798899, 3.3913999, -13.1096, 20.1539, 3.1299, -13.399799, 16.7232, 3.3913999, -13.1096, 16.8204, 3.138, -13.447599, 20.1539, 3.1299, -13.399799, 19.798899, 3.3913999, -13.1096, 16.7232, 3.3913999, -13.1096, 16.756899, 3.1650999, -16.7286, 12.738999, 3.3913999, -15.894899, 12.7471, 3.1766999, -16.1988, 16.756899, 3.1650999, -16.7286, 16.495699, 3.3913999, -16.459, 12.738999, 3.3913999, -15.894899, 16.8051, 3.1778, -13.479, 16.495699, 3.3913999, -16.459, 16.756899, 3.1650999, -16.7286, 16.8051, 3.1778, -13.479, 16.495699, 3.3913999, -13.42, 16.495699, 3.3913999, -16.459, 16.4543, 3.3913999, -13.3786, 16.8204, 3.138, -13.447599, 16.7232, 3.3913999, -13.1096, 16.8204, 3.138, -13.447599, 16.4543, 3.3913999, -13.3786, 16.495699, 3.3913999, -13.42, 16.8204, 3.138, -13.447599, 16.495699, 3.3913999, -13.42, 16.8051, 3.1778, -13.479, 10.033899, 3.0523999, -19.0294, 6.6893, 3.2782998, -18.727999, 6.6893, 3.0440998, -19.036999, 10.033899, 3.0523999, -19.0294, 10.033899, 3.2784998, -18.6997, 6.6893, 3.2782998, -18.727999, 12.723599, 2.9213, -19.2902, 10.033899, 3.2784998, -18.6997, 10.033899, 3.0523999, -19.0294, 12.723599, 2.9213, -19.2902, 12.446099, 3.1699998, -18.9401, 10.033899, 3.2784998, -18.6997, 12.446099, 3.1699998, -18.9401, 12.747, 3.1771, -16.1991, 12.460899, 3.3913999, -16.1674, 12.446099, 3.1699998, -18.9401, 12.723599, 2.9213, -19.2902, 12.747, 3.1771, -16.1991, 12.460899, 3.3913999, -16.1674, 10.033899, 3.2784998, -18.6997, 12.446099, 3.1699998, -18.9401, 10.033899, 3.3913999, -16.7232, 10.033899, 3.2784998, -18.6997, 12.460899, 3.3913999, -16.1674, 10.033899, 3.3913999, -16.7232, 12.460899, 3.3913999, -16.1674, 12.460899, 3.3913999, -16.1674, 6.6893, 3.3913999, -16.7232, 10.033899, 3.2784998, -18.6997, 10.033899, 3.3913999, -16.7232, 6.6893, 3.3913999, -16.7232, 6.6893, 3.2782998, -18.727999, 10.033899, 3.2784998, -18.6997, 12.738999, 3.3913999, -15.894899, 12.747, 3.1771, -16.1991, 12.7471, 3.1766999, -16.1988, 12.738999, 3.3913999, -15.894899, 12.460899, 3.3913999, -16.1674, 12.747, 3.1771, -16.1991, 12.460899, 3.3913999, -16.1674, 12.738999, 3.3913999, -15.894899, 12.460899, 3.3913999, -16.1674, 3.8388, 3.3913999, -16.7232, 4.4783998, 3.1067, -18.2665, 4.6443, 3.2897, -17.9799, 3.8388, 3.3913999, -16.7232, 3.5895, 3.2033, -16.7232, 4.4783998, 3.1067, -18.2665, 4.6821, 3.3913999, -13.3786, 3.5895, 3.2033, -16.7232, 3.8388, 3.3913999, -16.7232, 4.6821, 3.3913999, -13.3786, 4.4131, 3.2091, -13.357699, 3.5895, 3.2033, -16.7232, 6.6640997, 3.0702, -19.018099, 4.6443, 3.2897, -17.9799, 4.4783998, 3.1067, -18.2665, 6.6640997, 3.0702, -19.018099, 6.6705, 3.2772, -18.741, 4.6443, 3.2897, -17.9799, 4.6443, 3.2897, -17.9799, 6.6893, 3.3913999, -16.7232, 3.8388, 3.3913999, -16.7232, 6.6893, 3.3913999, -16.7232, 4.6443, 3.2897, -17.9799, 6.6705, 3.2772, -18.741, 6.6893, 3.3913999, -16.7232, 6.6705, 3.2772, -18.741, 6.6893, 3.2782998, -18.727999, 6.6893, 3.2782998, -18.727999, 6.6640997, 3.0702, -19.018099, 6.6893, 3.0440998, -19.036999, 6.6893, 3.2782998, -18.727999, 6.6705, 3.2772, -18.741, 6.6640997, 3.0702, -19.018099, 3.7529, 3.1712, -9.0494995, 0, 3.3913999, -8.7143, 0, 3.179, -9.0652, 3.7529, 3.1712, -9.0494995, 4.0225, 3.3913999, -8.7143, 0, 3.3913999, -8.7143, 4.0225, 3.3913999, -8.7143, 4.4131, 3.2091, -13.357699, 4.6821, 3.3913999, -13.3786, 4.0225, 3.3913999, -8.7143, 3.7529, 3.1712, -9.0494995, 4.4131, 3.2091, -13.357699, -3.6538, 3.1585999, -12.813399, -6.6893, 3.3913999, -12.538199, -6.6893, 3.175, -12.867, -3.6538, 3.1585999, -12.813399, -3.9101999, 3.3913999, -12.538199, -6.6893, 3.3913999, -12.538199, -3.6076, 3.1676, -10.0696, -3.9101999, 3.3913999, -12.538199, -3.6538, 3.1585999, -12.813399, -3.6076, 3.1676, -10.0696, -3.9101999, 3.3913999, -9.8592, -3.9101999, 3.3913999, -12.538199, -0.0081, 3.1864998, -9.063499, -3.9101999, 3.3913999, -9.8592, -3.6076, 3.1676, -10.0696, -0.0081, 3.1864998, -9.063499, 0, 3.3913999, -8.7143, -3.9101999, 3.3913999, -9.8592, 0, 3.3913999, -8.7143, -0.0081, 3.1864998, -9.063499, 0, 3.179, -9.0652, 0, 3.3913999, -8.7143, 0, 3.3913999, -8.7143, -0.0081, 3.1864998, -9.063499, -10.4043, 3.1608999, -13.4295, -10.699599, 3.3913999, -15.835, -10.4319, 3.1557999, -16.1556, -10.4043, 3.1608999, -13.4295, -10.699599, 3.3913999, -13.175799, -10.699599, 3.3913999, -15.835, -10.4319, 3.1557999, -16.1556, -13.3786, 3.3913999, -15.4154, -13.3786, 3.1771998, -15.7625, -10.4319, 3.1557999, -16.1556, -10.699599, 3.3913999, -15.835, -13.3786, 3.3913999, -15.4154, -10.699599, 3.3913999, -13.175799, -6.6893, 3.175, -12.867, -6.6893, 3.3913999, -12.538199, -10.699599, 3.3913999, -13.175799, -10.4043, 3.1608999, -13.4295, -6.6893, 3.175, -12.867, -16.130499, 3.6227, -13.623799, -16.0243, 3.3509998, -15.752, -15.7941, 3.5591, -15.4641, -16.130499, 3.6227, -13.623799, -16.3753, 3.4225, -13.6277, -16.0243, 3.3509998, -15.752, -13.3911, 3.1892, -15.757, -15.7941, 3.5591, -15.4641, -16.0243, 3.3509998, -15.752, -13.3911, 3.1892, -15.757, -13.396, 3.3925, -15.4333, -15.7941, 3.5591, -15.4641, -15.7941, 3.5591, -15.4641, -13.3786, 3.3913999, -13.3786, -16.130499, 3.6227, -13.623799, -13.3786, 3.3913999, -13.3786, -15.7941, 3.5591, -15.4641, -13.396, 3.3925, -15.4333, -13.3786, 3.3913999, -13.3786, -13.396, 3.3925, -15.4333, -13.3786, 3.3913999, -15.4154, -13.3786, 3.3913999, -15.4154, -13.3911, 3.1892, -15.757, -13.3786, 3.1771998, -15.7625, -13.3786, 3.3913999, -15.4154, -13.396, 3.3925, -15.4333, -13.3911, 3.1892, -15.757, -16.3753, 3.4225, -13.6277, -16.1241, 3.3832998, -9.5097, -16.4131, 3.1706, -9.788899, -16.3753, 3.4225, -13.6277, -16.130499, 3.6227, -13.623799, -16.1241, 3.3832998, -9.5097, -16.7232, 3.3913999, -6.6893, -13.3786, 3.3913999, -10.033899, -13.3786, 3.3913999, -6.6893, -16.7232, 3.3913999, -6.6893, -16.1241, 3.3832998, -9.5097, -13.3786, 3.3913999, -10.033899, -16.1241, 3.3832998, -9.5097, -13.3786, 3.3913999, -13.3786, -13.3786, 3.3913999, -10.033899, -16.1241, 3.3832998, -9.5097, -16.130499, 3.6227, -13.623799, -13.3786, 3.3913999, -13.3786, -16.130499, 3.6227, -13.623799, -16.1241, 3.3832998, -9.5097, -16.130499, 3.6227, -13.623799, -16.130499, 3.6227, -13.623799, -16.3753, 3.4225, -13.6277, -16.3753, 3.4225, -13.6277, -16.130499, 3.6227, -13.623799, -16.130499, 3.6227, -13.623799, -16.3753, 3.4225, -13.6277, -19.7545, 3.3913999, -2.7851999, -19.515, 3.2122, -6.6893, -19.280699, 3.3913999, -6.6893, -19.7545, 3.3913999, -2.7851999, -19.9489, 3.2063, -2.8082, -19.515, 3.2122, -6.6893, -19.280699, 3.3913999, -6.6893, -19.6161, 3.2075999, -9.7116995, -19.3965, 3.3909, -9.5092, -19.280699, 3.3913999, -6.6893, -19.515, 3.2122, -6.6893, -19.6161, 3.2075999, -9.7116995, -19.280699, 3.3913999, -6.6893, -16.1241, 3.3832998, -9.5097, -16.7232, 3.3913999, -6.6893, -19.280699, 3.3913999, -6.6893, -19.3965, 3.3909, -9.5092, -16.1241, 3.3832998, -9.5097, -16.1241, 3.3832998, -9.5097, -19.6237, 3.1655998, -9.757899, -16.4131, 3.1706, -9.788899, -19.6237, 3.1655998, -9.757899, -16.1241, 3.3832998, -9.5097, -19.3965, 3.3909, -9.5092, -19.6237, 3.1655998, -9.757899, -19.3965, 3.3909, -9.5092, -19.6161, 3.2075999, -9.7116995, -22.8657, 3.4937, 3.3613, -23.3676, 3.4459999, 0.1372, -23.170399, 3.6251, 0.122999996, -22.8657, 3.4937, 3.3613, -23.0809, 3.3083, 3.3615, -23.3676, 3.4459999, 0.1372, -23.170399, 3.6251, 0.122999996, -23.390299, 3.3349998, -2.6938999, -23.192099, 3.5300999, -2.4668999, -23.170399, 3.6251, 0.122999996, -23.3676, 3.4459999, 0.1372, -23.390299, 3.3349998, -2.6938999, -23.192099, 3.5300999, -2.4668999, -19.9489, 3.2063, -2.8082, -19.9511, 3.3913999, -2.5834, -23.192099, 3.5300999, -2.4668999, -23.390299, 3.3349998, -2.6938999, -19.9489, 3.2063, -2.8082, -23.192099, 3.5300999, -2.4668999, -20.0679, 3.3913999, 0, -23.170399, 3.6251, 0.122999996, -20.0679, 3.3913999, 0, -23.192099, 3.5300999, -2.4668999, -19.9511, 3.3913999, -2.5834, -19.9511, 3.3913999, -2.5834, -23.192099, 3.5300999, -2.4668999, -19.9511, 3.3913999, -2.5834, -22.8657, 3.4937, 3.3613, -20.0679, 3.3913999, 0, -20.0679, 3.3913999, 3.3446, -22.8657, 3.4937, 3.3613, -23.170399, 3.6251, 0.122999996, -20.0679, 3.3913999, 0, -19.7545, 3.3913999, -2.7851999, -19.9489, 3.2063, -2.8082, -19.9489, 3.2063, -2.8082, -19.7545, 3.3913999, -2.7851999, -19.9511, 3.3913999, -2.5834, -19.9489, 3.2063, -2.8082, -19.9511, 3.3913999, -2.5834, -19.7545, 3.3913999, -2.7851999, -19.9511, 3.3913999, -2.5834, -23.0777, 3.3197, 3.3734, -22.3151, 3.3913999, 6.6893, -22.563799, 3.2219, 6.6893, -23.0777, 3.3197, 3.3734, -22.8764, 3.4937, 3.3741, -22.3151, 3.3913999, 6.6893, -22.8764, 3.4937, 3.3741, -20.0679, 3.3913999, 6.6893, -22.3151, 3.3913999, 6.6893, -20.0679, 3.3913999, 6.6893, -22.8764, 3.4937, 3.3741, -22.8657, 3.4937, 3.3613, -20.0679, 3.3913999, 6.6893, -22.8657, 3.4937, 3.3613, -20.0679, 3.3913999, 3.3446, -22.8657, 3.4937, 3.3613, -23.0777, 3.3197, 3.3734, -23.0809, 3.3083, 3.3615, -22.8657, 3.4937, 3.3613, -22.8764, 3.4937, 3.3741, -23.0777, 3.3197, 3.3734, -16.285099, 3.3913999, 9.5865, -20.0679, 3.1943, 9.407499, -20.0679, 3.3913999, 9.167399, -16.285099, 3.3913999, 9.5865, -16.2818, 3.1913998, 9.8056, -20.0679, 3.1943, 9.407499, -22.791399, 3.2124999, 9.4432, -20.0679, 3.3913999, 9.167399, -20.0679, 3.1943, 9.407499, -22.791399, 3.2124999, 9.4432, -22.5541, 3.3913999, 9.2058, -20.0679, 3.3913999, 9.167399, -22.5541, 3.3913999, 9.2058, -22.563799, 3.2219, 6.6893, -22.3151, 3.3913999, 6.6893, -22.5541, 3.3913999, 9.2058, -22.791399, 3.2124999, 9.4432, -22.563799, 3.2219, 6.6893, -22.791399, 3.2124999, 9.4432, -22.5541, 3.3913999, 9.2058, -22.791399, 3.2124999, 9.4432, -13.3786, 3.3922, 12.571899, -16.076399, 3.04, 12.735, -15.847199, 3.2614, 12.4973, -13.3786, 3.3922, 12.571899, -13.3786, 3.194, 12.8089, -16.076399, 3.04, 12.735, -15.847199, 3.2614, 12.4973, -16.2818, 3.1913998, 9.8056, -16.053999, 3.3913999, 9.8082, -15.847199, 3.2614, 12.4973, -16.076399, 3.04, 12.735, -16.2818, 3.1913998, 9.8056, -15.847199, 3.2614, 12.4973, -13.3786, 3.3913999, 10.033899, -13.3786, 3.3922, 12.571899, -15.847199, 3.2614, 12.4973, -16.053999, 3.3913999, 9.8082, -13.3786, 3.3913999, 10.033899, -16.053999, 3.3913999, 9.8082, -15.847199, 3.2614, 12.4973, -16.053999, 3.3913999, 9.8082, -16.285099, 3.3913999, 9.5865, -16.2818, 3.1913998, 9.8056, -16.2818, 3.1913998, 9.8056, -16.285099, 3.3913999, 9.5865, -16.053999, 3.3913999, 9.8082, -16.2818, 3.1913998, 9.8056, -16.053999, 3.3913999, 9.8082, -16.285099, 3.3913999, 9.5865, -16.053999, 3.3913999, 9.8082, -5.9742, 3.3994, 12.8817, -10.033899, 2.9384, 12.4537, -10.033899, 3.2142, 12.1061, -5.9742, 3.3994, 12.8817, -6.2748, 3.126, 13.122, -10.033899, 2.9384, 12.4537, -10.033899, 3.2142, 12.1061, -13.3786, 3.194, 12.8089, -13.3786, 3.3922, 12.571899, -10.033899, 3.2142, 12.1061, -10.033899, 2.9384, 12.4537, -13.3786, 3.194, 12.8089, -10.033899, 3.2142, 12.1061, -6.6893, 3.3913999, 10.033899, -5.9742, 3.3994, 12.8817, -10.033899, 3.2142, 12.1061, -10.033899, 3.3913999, 10.033899, -6.6893, 3.3913999, 10.033899, -13.3786, 3.3922, 12.571899, -10.033899, 3.3913999, 10.033899, -10.033899, 3.2142, 12.1061, -13.3786, 3.3922, 12.571899, -13.3786, 3.3913999, 10.033899, -10.033899, 3.3913999, 10.033899, -13.3786, 3.3913999, 10.033899, -13.3786, 3.3922, 12.571899, -13.3786, 3.3922, 12.571899, -5.9742, 3.3994, 12.8817, -3.3446, 3.3913999, 10.033899, -3.3446, 3.3913999, 13.3786, -5.9742, 3.3994, 12.8817, -6.6893, 3.3913999, 10.033899, -3.3446, 3.3913999, 10.033899, -13.3786, 3.3922, 12.571899, -13.3786, 3.194, 12.8089, -13.3786, 3.194, 12.8089, -13.3786, 3.3922, 12.571899, -13.3786, 3.3922, 12.571899, -13.3786, 3.194, 12.8089, 3.6399, 3.3913999, 16.2405, 0, 2.9636998, 16.0179, 0, 3.2243998, 15.7093, 3.6399, 3.3913999, 16.2405, 3.6425998, 3.1413999, 16.5127, 0, 2.9636998, 16.0179, 0, 3.2243998, 15.7093, -3.3446, 3.1497998, 15.914599, -3.3446, 3.3913999, 15.6011, 0, 3.2243998, 15.7093, 0, 2.9636998, 16.0179, -3.3446, 3.1497998, 15.914599, -6.2558, 3.1427999, 16.4098, -3.3446, 3.3913999, 15.6011, -3.3446, 3.1497998, 15.914599, -6.2558, 3.1427999, 16.4098, -5.9737997, 3.392, 16.087599, -3.3446, 3.3913999, 15.6011, -3.3446, 3.3913999, 15.6011, 0, 3.3913999, 13.3786, 0, 3.2243998, 15.7093, -3.3446, 3.3913999, 15.6011, -3.3446, 3.3913999, 13.3786, 0, 3.3913999, 13.3786, 0, 3.2243998, 15.7093, 3.3446, 3.3913999, 13.3786, 3.6399, 3.3913999, 16.2405, 0, 3.2243998, 15.7093, 0, 3.3913999, 13.3786, 3.3446, 3.3913999, 13.3786, -5.9737997, 3.392, 16.087599, -3.3446, 3.3913999, 13.3786, -3.3446, 3.3913999, 15.6011, -5.9737997, 3.392, 16.087599, -5.9742, 3.3994, 12.8817, -3.3446, 3.3913999, 13.3786, -6.2748, 3.1260998, 16.4145, -5.9737997, 3.392, 16.087599, -6.2558, 3.1427999, 16.4098, -5.9737997, 3.392, 16.087599, -6.2748, 3.1260998, 16.4145, -6.2748, 3.126, 13.122, -5.9737997, 3.392, 16.087599, -6.2748, 3.126, 13.122, -5.9742, 3.3994, 12.8817, 6.6893, 3.3913999, 18.9861, 3.9266999, 3.5967, 19.6223, 4.1273, 3.7335, 19.3804, 6.6893, 3.3913999, 18.9861, 6.6893, 3.2050998, 19.228699, 3.9266999, 3.5967, 19.6223, 4.1273, 3.7335, 19.3804, 3.6536, 3.2078998, 16.5669, 3.8651, 3.3981998, 16.5647, 4.1273, 3.7335, 19.3804, 3.9266999, 3.5967, 19.6223, 3.6536, 3.2078998, 16.5669, 3.8651, 3.3981998, 16.5647, 6.6893, 3.3913999, 18.9861, 4.1273, 3.7335, 19.3804, 6.6893, 3.3913999, 18.9861, 3.8651, 3.3981998, 16.5647, 3.9148, 3.3913999, 16.514599, 6.6893, 3.3913999, 18.9861, 3.9148, 3.3913999, 16.514599, 6.6893, 3.3913999, 16.7232, 3.9148, 3.3913999, 16.514599, 3.6425998, 3.1413999, 16.5127, 3.6399, 3.3913999, 16.2405, 3.6425998, 3.1413999, 16.5127, 3.9148, 3.3913999, 16.514599, 3.8651, 3.3981998, 16.5647, 3.6425998, 3.1413999, 16.5127, 3.8651, 3.3981998, 16.5647, 3.6536, 3.2078998, 16.5669, 9.3066, 3.2186, 19.0653, 10.3743, 3.3913999, 16.7232, 10.5913, 3.1985998, 16.7232, 9.3066, 3.2186, 19.0653, 9.1275, 3.3913999, 18.830599, 10.3743, 3.3913999, 16.7232, 9.1275, 3.3913999, 18.830599, 6.6991997, 3.2148, 19.2255, 6.6949, 3.3913999, 18.9914, 9.1275, 3.3913999, 18.830599, 9.3066, 3.2186, 19.0653, 6.6991997, 3.2148, 19.2255, 6.6893, 3.3913999, 18.9861, 6.6991997, 3.2148, 19.2255, 6.6893, 3.2050998, 19.228699, 6.6893, 3.3913999, 18.9861, 6.6949, 3.3913999, 18.9914, 6.6991997, 3.2148, 19.2255, 9.8131, 3.3546, 13.1979, 10.5893, 3.2117, 16.7106, 10.403999, 3.3911998, 16.6938, 9.8131, 3.3546, 13.1979, 10.0546, 3.1885, 13.4071, 10.5893, 3.2117, 16.7106, 6.6893, 3.3913999, 13.3786, 10.3743, 3.3913999, 16.7232, 6.6893, 3.3913999, 16.7232, 10.3743, 3.3913999, 16.7232, 6.6893, 3.3913999, 13.3786, 9.8131, 3.3546, 13.1979, 10.3743, 3.3913999, 16.7232, 9.8131, 3.3546, 13.1979, 10.403999, 3.3911998, 16.6938, 6.6893, 3.3913999, 13.3786, 10.033899, 3.3913999, 10.033899, 9.8131, 3.3546, 13.1979, 6.6893, 3.3913999, 13.3786, 6.6893, 3.3913999, 10.033899, 10.033899, 3.3913999, 10.033899, 10.3743, 3.3913999, 16.7232, 10.5893, 3.2117, 16.7106, 10.5913, 3.1985998, 16.7232, 10.3743, 3.3913999, 16.7232, 10.403999, 3.3911998, 16.6938, 10.5893, 3.2117, 16.7106, 13.1839, 3.2744, 6.6893, 13.5511, 3.1797, 6.6893, 13.4036, 3.0749998, 6.6893, 13.1839, 3.2744, 6.6893, 13.3786, 3.2744, 6.496, 13.5511, 3.1797, 6.6893, 12.9758, 3.2036, 10.033899, 13.1839, 3.2744, 6.6893, 13.4036, 3.0749998, 6.6893, 12.9758, 3.2036, 10.033899, 12.7325, 3.3913999, 10.033899, 13.1839, 3.2744, 6.6893, 13.2631, 3.5611, 13.4043, 12.7325, 3.3913999, 10.033899, 12.9758, 3.2036, 10.033899, 13.2631, 3.5611, 13.4043, 13.025499, 3.6999, 13.1985, 12.7325, 3.3913999, 10.033899, 10.033899, 3.3913999, 10.033899, 13.1839, 3.2744, 6.6893, 12.7325, 3.3913999, 10.033899, 10.033899, 3.3913999, 10.033899, 10.033899, 3.3913999, 6.6893, 13.1839, 3.2744, 6.6893, 9.8131, 3.3546, 13.1979, 12.7325, 3.3913999, 10.033899, 13.025499, 3.6999, 13.1985, 9.8131, 3.3546, 13.1979, 10.033899, 3.3913999, 10.033899, 12.7325, 3.3913999, 10.033899, 10.033899, 3.3913999, 3.3446, 13.1839, 3.2744, 6.6893, 10.033899, 3.3913999, 6.6893, 13.1839, 3.2744, 6.6893, 10.033899, 3.3913999, 3.3446, 13.3786, 3.3913999, 3.3446, 13.1839, 3.2744, 6.6893, 13.3786, 3.3913999, 3.3446, 13.3786, 3.2744, 6.496, 13.025499, 3.6999, 13.1985, 10.0546, 3.1885, 13.4071, 9.8131, 3.3546, 13.1979, 13.025499, 3.6999, 13.1985, 13.2631, 3.5611, 13.4043, 10.0546, 3.1885, 13.4071, 13.2631, 3.5611, 13.4043, 13.2631, 3.5611, 13.4043, 13.025499, 3.6999, 13.1985, 14.0489, 3.0042, -3.3446, 13.1206, 3.3028998, -3.3446, 13.3786, 3.3028998, -3.6027, 14.0489, 3.0042, -3.3446, 13.3786, 3.3028998, -3.0865998, 13.1206, 3.3028998, -3.3446, 16.7232, 3.1513, -3.602, 14.0489, 3.0042, -3.3446, 13.3786, 3.3028998, -3.6027, 16.7232, 3.1513, -3.602, 16.7127, 2.8734, -3.3446, 14.0489, 3.0042, -3.3446, 13.3786, 3.3028998, -3.6027, 16.7232, 3.3913999, -6.6893, 16.7232, 3.1513, -3.602, 13.3786, 3.3028998, -3.6027, 13.3786, 3.3913999, -6.6893, 16.7232, 3.3913999, -6.6893, 10.033899, 3.3913999, -6.6893, 13.1206, 3.3028998, -3.3446, 10.033899, 3.3913999, -3.3446, 13.1206, 3.3028998, -3.3446, 10.033899, 3.3913999, -6.6893, 13.3786, 3.3913999, -6.6893, 13.1206, 3.3028998, -3.3446, 13.3786, 3.3913999, -6.6893, 13.3786, 3.3028998, -3.6027, 20.0679, 0, 0, 14.0489, 3.0042, -3.3446, 20.961199, 0, -3.3446, 20.0679, 0, 3.3446, 14.0489, 3.0042, -3.3446, 20.0679, 0, 0, 14.0489, 3.0042, -3.3446, 13.3786, 3.3913999, 0, 13.3786, 3.3028998, -3.0865998, 14.0489, 3.0042, -3.3446, 13.3786, 3.2744, 6.496, 13.3786, 3.3913999, 0, 14.0489, 3.0042, -3.3446, 20.0679, 0, 3.3446, 13.3786, 3.2744, 6.496, 13.3786, 3.3913999, 0, 13.3786, 3.2744, 6.496, 13.3786, 3.3913999, 3.3446, 13.3786, 3.2744, 6.496, 20.0679, 0, 3.3446, 16.7232, 1.5715, 6.6893, 16.7232, 1.5715, 6.6893, 20.0679, 0, 3.3446, 20.0679, -0.124199994, 6.6893, 13.3786, 3.2744, 6.496, 16.7232, 1.5715, 6.6893, 13.5511, 3.1797, 6.6893, 10.033899, 3.3913999, -3.3446, 13.3786, 3.3913999, 0, 10.033899, 3.3913999, 0, 13.3786, 3.3913999, 0, 10.033899, 3.3913999, -3.3446, 13.1206, 3.3028998, -3.3446, 13.3786, 3.3913999, 0, 13.1206, 3.3028998, -3.3446, 13.3786, 3.3028998, -3.0865998, 19.831999, 3.3902998, -10.0008, 20.147, 3.0588999, -6.6893, 19.8322, 3.2891998, -6.6893, 19.831999, 3.3902998, -10.0008, 20.1796, 3.182, -9.995999, 20.147, 3.0588999, -6.6893, 19.8322, 3.2891998, -6.6893, 20.1349, 2.7974, -3.3783998, 19.8322, 3.0491, -3.602, 19.8322, 3.2891998, -6.6893, 20.147, 3.0588999, -6.6893, 20.1349, 2.7974, -3.3783998, 16.7232, 3.1513, -3.602, 19.8322, 3.2891998, -6.6893, 19.8322, 3.0491, -3.602, 16.7232, 3.1513, -3.602, 16.7232, 3.3913999, -6.6893, 19.8322, 3.2891998, -6.6893, 16.7232, 3.3913999, -10.033899, 19.8322, 3.2891998, -6.6893, 16.7232, 3.3913999, -6.6893, 19.8322, 3.2891998, -6.6893, 16.7232, 3.3913999, -10.033899, 19.798899, 3.3913999, -10.033899, 19.8322, 3.2891998, -6.6893, 19.798899, 3.3913999, -10.033899, 19.831999, 3.3902998, -10.0008, 20.198, 3.1490998, -10.033899, 19.831999, 3.3902998, -10.0008, 19.798899, 3.3913999, -10.033899, 20.198, 3.1490998, -10.033899, 20.1796, 3.182, -9.995999, 19.831999, 3.3902998, -10.0008, 20.144999, 2.7605999, -3.3446, 19.8322, 3.0491, -3.602, 20.1349, 2.7974, -3.3783998, 19.8322, 3.0491, -3.602, 20.144999, 2.7605999, -3.3446, 16.7127, 2.8734, -3.3446, 19.8322, 3.0491, -3.602, 16.7127, 2.8734, -3.3446, 16.7232, 3.1513, -3.602, 15.0508995, 0, 6.6893, 16.7232, 1.5715, 6.6893, 16.7232, 0, 6.6893, 16.7232, 0, 6.6893, 16.7232, 1.5715, 6.6893, 20.0679, -0.124199994, 6.6893, 15.0508995, 0, 6.6893, 13.5511, 3.1797, 6.6893, 16.7232, 1.5715, 6.6893, 15.0508995, 0, 6.6893, 13.4036, 3.0749998, 6.6893, 13.5511, 3.1797, 6.6893, 13.4036, 3.0749998, 6.6893, 15.0508995, 0, 6.6893, 13.8046, 0, 6.6893, -16.7232, 0.6137, 0, -15.7546, 0.6137, 3.5372, -16.7232, 0.6137, 2.9166, -15.7546, 0.6137, 3.5372, -16.7232, 0.6137, 0, -13.3786, 0.6137, 0, -16.7232, 0.6137, 0, -15.8036995, 0.6137, -2.7091, -13.3786, 0.6137, -3.3446, -16.7232, 0.6137, 0, -13.3786, 0.6137, -3.3446, -13.3786, 0.6137, 0, -13.3786, 0.6137, 0, -13.3786, 0.6137, -3.3446, -10.033899, 0.6137, -3.3446, -15.7546, 0.6137, 3.5372, -13.3786, 0.6137, 0, -14.4892, 0.6137, 4.2778, -13.3786, 0.6137, 0, -10.033899, 0.6137, -3.3446, -10.033899, 0.6137, 0, -14.4892, 0.6137, 4.2778, -13.3786, 0.6137, 0, -10.033899, 0.6137, 0, -10.033899, 0.6137, 0, -10.033899, 0.6137, -3.3446, -6.6893, 0.6137, -3.3446, -10.033899, 0.6137, 0, -6.6893, 0.6137, -3.3446, -6.6893, 0.6137, 0, -6.6893, 0.6137, 0, -6.6893, 0.6137, -3.3446, -4.0257998, 0.6137, -1.6911999, -6.6893, 0.6137, 0, -4.0257998, 0.6137, -1.6911999, -3.3446, 0.6137, 0, -10.033899, 0.6137, 3.3446, -10.033899, 0.6137, 0, -6.6893, 0.6137, 0, -14.4892, 0.6137, 4.2778, -10.033899, 0.6137, 0, -10.033899, 0.6137, 3.3446, -6.6893, 0.6137, 3.3446, -6.6893, 0.6137, 0, -3.3446, 0.6137, 0, -10.033899, 0.6137, 3.3446, -6.6893, 0.6137, 0, -6.6893, 0.6137, 3.3446, -6.6893, 0.6137, 3.3446, -3.3446, 0.6137, 0, -3.3446, 0.6137, 3.3446, -10.033899, 0.6137, 6.6893, -14.4892, 0.6137, 4.2778, -10.033899, 0.6137, 3.3446, -10.033899, 0.6137, 6.6893, -10.033899, 0.6137, 3.3446, -6.6893, 0.6137, 3.3446, -10.033899, 0.6137, 6.6893, -6.6893, 0.6137, 3.3446, -6.6893, 0.6137, 6.6893, -10.033899, 0.6137, 6.6893, -13.677099, 0.6137, 5.2616, -14.4892, 0.6137, 4.2778, -13.677099, 0.6137, 5.2616, -10.033899, 0.6137, 6.6893, -12.747499, 0.6137, 6.3671, -6.6893, 0.6137, 3.3446, -2.5674999, 0.0266, 7.6215, -6.6893, 0.6137, 6.6893, -2.5674999, 0.0266, 7.6215, -6.6893, 0.6137, 3.3446, -3.3446, 0.6137, 3.3446, -2.5674999, 0.0266, 7.6215, -3.3446, 0.6137, 3.3446, -2.1193, 0.6137, 5.2736998, -16.7232, 3.0033998, 2.9166, -15.7546, 0.6137, 3.5372, -15.372, 3.0033998, 3.6132998, -16.7232, 3.0033998, 2.9166, -16.7232, 0.6137, 2.9166, -15.7546, 0.6137, 3.5372, -14.4892, 3.0033998, 4.2778, -13.677099, 0.6137, 5.2616, -13.6942, 3.0033998, 5.1798, -14.4892, 3.0033998, 4.2778, -14.4892, 0.6137, 4.2778, -13.677099, 0.6137, 5.2616, -15.8036995, 3.0033998, -2.7091, -16.7232, 0.6137, 0, -16.7232, 3.0033998, 0, -15.8036995, 3.0033998, -2.7091, -15.8036995, 0.6137, -2.7091, -16.7232, 0.6137, 0, -16.7232, 3.0033998, 0, -16.7232, 0.6137, 2.9166, -16.7232, 3.0033998, 2.9166, -16.7232, 3.0033998, 0, -16.7232, 0.6137, 0, -16.7232, 0.6137, 2.9166, -3.3446, 3.0033998, 0, -4.0257998, 0.6137, -1.6911999, -4.0257998, 3.0033998, -1.6911999, -3.3446, 3.0033998, 0, -3.3446, 0.6137, 0, -4.0257998, 0.6137, -1.6911999, -3.3446, 3.0033998, 3.3446, -3.3446, 0.6137, 0, -3.3446, 3.0033998, 0, -3.3446, 3.0033998, 3.3446, -3.3446, 0.6137, 3.3446, -3.3446, 0.6137, 0, -12.747499, 3.0033998, 6.3671, -10.033899, 0.6137, 6.6893, -10.033899, 3.0033998, 6.6893, -12.747499, 3.0033998, 6.3671, -12.747499, 0.6137, 6.3671, -10.033899, 0.6137, 6.6893, -10.033899, 3.0033998, -3.3446, -13.3786, 0.6137, -3.3446, -13.3786, 3.0033998, -3.3446, -10.033899, 3.0033998, -3.3446, -10.033899, 0.6137, -3.3446, -13.3786, 0.6137, -3.3446, -6.6893, 3.0033998, -3.3446, -10.033899, 0.6137, -3.3446, -10.033899, 3.0033998, -3.3446, -6.6893, 3.0033998, -3.3446, -6.6893, 0.6137, -3.3446, -10.033899, 0.6137, -3.3446, -13.3786, 3.0033998, -3.3446, -15.8036995, 0.6137, -2.7091, -15.8036995, 3.0033998, -2.7091, -13.3786, 3.0033998, -3.3446, -13.3786, 0.6137, -3.3446, -15.8036995, 0.6137, -2.7091, -4.0257998, 3.0033998, -1.6911999, -6.6893, 0.6137, -3.3446, -6.6893, 3.0033998, -3.3446, -4.0257998, 3.0033998, -1.6911999, -4.0257998, 0.6137, -1.6911999, -6.6893, 0.6137, -3.3446, -6.6893, 3.0033998, 6.6893, -2.5674999, 0.0266, 7.6215, -2.5674999, 2.4164, 7.6215, -6.6893, 3.0033998, 6.6893, -6.6893, 0.6137, 6.6893, -2.5674999, 0.0266, 7.6215, -10.033899, 3.0033998, 6.6893, -6.6893, 0.6137, 6.6893, -6.6893, 3.0033998, 6.6893, -10.033899, 3.0033998, 6.6893, -10.033899, 0.6137, 6.6893, -6.6893, 0.6137, 6.6893, -2.5674999, 2.4164, 7.6215, -2.1193, 0.6137, 5.2736998, -2.1397, 3.0033998, 5.1795, -2.5674999, 2.4164, 7.6215, -2.5674999, 0.0266, 7.6215, -2.1193, 0.6137, 5.2736998, -2.1193, 0.6137, 5.2736998, -3.3446, 3.0033998, 3.3446, -2.1397, 3.0033998, 5.1795, -2.1193, 0.6137, 5.2736998, -3.3446, 0.6137, 3.3446, -3.3446, 3.0033998, 3.3446, -13.677099, 0.6137, 5.2616, -12.747499, 3.0033998, 6.3671, -13.6942, 3.0033998, 5.1798, -13.677099, 0.6137, 5.2616, -12.747499, 0.6137, 6.3671, -12.747499, 3.0033998, 6.3671, -15.7546, 0.6137, 3.5372, -14.4892, 3.0033998, 4.2778, -15.372, 3.0033998, 3.6132998, -15.7546, 0.6137, 3.5372, -14.4892, 0.6137, 4.2778, -14.4892, 3.0033998, 4.2778, 21.1831, 0, -13.6544, 16.8204, 3.138, -13.447599, 18.0241, 0, -14.3019, 21.1831, 0, -13.6544, 20.1539, 3.1299, -13.399799, 16.8204, 3.138, -13.447599, 21.8888, 0, -10.033899, 20.1539, 3.1299, -13.399799, 21.1831, 0, -13.6544, 21.8888, 0, -10.033899, 20.198, 3.1490998, -10.033899, 20.1539, 3.1299, -13.399799, 17.2285, 0, -16.8036, 12.7471, 3.1766999, -16.1988, 13.7014, 0, -17.416399, 17.2285, 0, -16.8036, 16.756899, 3.1650999, -16.7286, 12.7471, 3.1766999, -16.1988, 16.8204, 3.138, -13.447599, 17.2285, 0, -16.8036, 18.0241, 0, -14.3019, 17.2285, 0, -16.8036, 16.8204, 3.138, -13.447599, 16.8051, 3.1778, -13.479, 17.2285, 0, -16.8036, 16.8051, 3.1778, -13.479, 16.756899, 3.1650999, -16.7286, 12.7471, 3.1766999, -16.1988, 13.3786, 0, -20.5992, 13.7014, 0, -17.416399, 13.3786, 0, -20.5992, 12.7471, 3.1766999, -16.1988, 12.747, 3.1771, -16.1991, 13.3786, 0, -20.5992, 12.747, 3.1771, -16.1991, 12.723599, 2.9213, -19.2902, 13.3786, 0, -20.5992, 10.033899, 3.0523999, -19.0294, 10.033899, 0, -20.467, 13.3786, 0, -20.5992, 12.723599, 2.9213, -19.2902, 10.033899, 3.0523999, -19.0294, 10.033899, 0, -20.467, 6.6893, 3.0440998, -19.036999, 6.6893, 0, -20.1318, 10.033899, 0, -20.467, 10.033899, 3.0523999, -19.0294, 6.6893, 3.0440998, -19.036999, 3.0140998, 0, -16.7232, 4.4131, 3.2091, -13.357699, 3.3446, -0.3989, -12.9443, 3.0140998, 0, -16.7232, 3.5895, 3.2033, -16.7232, 4.4131, 3.2091, -13.357699, 6.6893, 3.0440998, -19.036999, 3.3446, 0, -20.5992, 6.6893, 0, -20.1318, 3.3446, 0, -20.5992, 6.6893, 3.0440998, -19.036999, 6.6640997, 3.0702, -19.018099, 3.3446, 0, -20.5992, 6.6640997, 3.0702, -19.018099, 4.4783998, 3.1067, -18.2665, 3.3446, 0, -20.5992, 3.5895, 3.2033, -16.7232, 3.0140998, 0, -16.7232, 3.3446, 0, -20.5992, 4.4783998, 3.1067, -18.2665, 3.5895, 3.2033, -16.7232, 2.8241, 0, -10.4594, 0, 3.179, -9.0652, 0, 0, -10.7654, 2.8241, 0, -10.4594, 3.7529, 3.1712, -9.0494995, 0, 3.179, -9.0652, 3.3446, -0.3989, -12.9443, 3.7529, 3.1712, -9.0494995, 2.8241, 0, -10.4594, 3.3446, -0.3989, -12.9443, 4.4131, 3.2091, -13.357699, 3.7529, 3.1712, -9.0494995, 4.4131, 3.2091, -13.357699, 3.3446, -0.3989, -12.9443, 4.4131, 3.2091, -13.357699, -2.6297, 0, -10.5745, -3.6538, 3.1585999, -12.813399, -3.3446, 0, -13.3786, -2.6297, 0, -10.5745, -3.6076, 3.1676, -10.0696, -3.6538, 3.1585999, -12.813399, -3.3446, 0, -13.3786, -6.6893, 3.175, -12.867, -6.6893, 0, -14.2637, -3.3446, 0, -13.3786, -3.6538, 3.1585999, -12.813399, -6.6893, 3.175, -12.867, 0, 3.179, -9.0652, -2.6297, 0, -10.5745, 0, 0, -10.7654, -2.6297, 0, -10.5745, 0, 3.179, -9.0652, -0.0081, 3.1864998, -9.063499, -2.6297, 0, -10.5745, -0.0081, 3.1864998, -9.063499, -3.6076, 3.1676, -10.0696, -6.6893, 0, -14.2637, -10.4043, 3.1608999, -13.4295, -9.6168995, 0, -14.127899, -6.6893, 0, -14.2637, -6.6893, 3.175, -12.867, -10.4043, 3.1608999, -13.4295, -6.6893, 3.175, -12.867, -6.6893, 0, -14.2637, -6.6893, 3.175, -12.867, -9.6168995, 0, -14.127899, -10.4319, 3.1557999, -16.1556, -10.033899, 0, -16.7232, -9.6168995, 0, -14.127899, -10.4043, 3.1608999, -13.4295, -10.4319, 3.1557999, -16.1556, -10.033899, 0, -16.7232, -13.3786, 3.1771998, -15.7625, -13.3786, 0, -17.3364, -10.033899, 0, -16.7232, -10.4319, 3.1557999, -16.1556, -13.3786, 3.1771998, -15.7625, -16.7232, 0, -16.7232, -16.3753, 3.4225, -13.6277, -16.7232, 0, -13.3786, -16.7232, 0, -16.7232, -16.0243, 3.3509998, -15.752, -16.3753, 3.4225, -13.6277, -13.3786, 3.1771998, -15.7625, -16.7232, 0, -16.7232, -13.3786, 0, -17.3364, -16.7232, 0, -16.7232, -13.3786, 3.1771998, -15.7625, -13.3911, 3.1892, -15.757, -16.7232, 0, -16.7232, -13.3911, 3.1892, -15.757, -16.0243, 3.3509998, -15.752, -17.275, 0, -10.5169, -19.6237, 3.1655998, -9.757899, -20.0679, 0, -10.033899, -17.275, 0, -10.5169, -16.4131, 3.1706, -9.788899, -19.6237, 3.1655998, -9.757899, -16.7232, 0, -13.3786, -16.4131, 3.1706, -9.788899, -17.275, 0, -10.5169, -16.7232, 0, -13.3786, -16.3753, 3.4225, -13.6277, -16.4131, 3.1706, -9.788899, -16.3753, 3.4225, -13.6277, -16.7232, 0, -13.3786, -16.3753, 3.4225, -13.6277, -20.3764, 0, -6.6893, -19.9489, 3.2063, -2.8082, -20.0679, -0.3484, -3.5669, -20.3764, 0, -6.6893, -19.515, 3.2122, -6.6893, -19.9489, 3.2063, -2.8082, -19.6237, 3.1655998, -9.757899, -20.3764, 0, -6.6893, -20.0679, 0, -10.033899, -20.3764, 0, -6.6893, -19.6237, 3.1655998, -9.757899, -19.6161, 3.2075999, -9.7116995, -20.3764, 0, -6.6893, -19.6161, 3.2075999, -9.7116995, -19.515, 3.2122, -6.6893, -23.412498, -0.2913, 0.26119998, -23.0809, 3.3083, 3.3615, -23.412498, 0, 3.3446, -23.412498, -0.2913, 0.26119998, -23.3676, 3.4459999, 0.1372, -23.0809, 3.3083, 3.3615, -23.412498, 0, -3.3446, -23.3676, 3.4459999, 0.1372, -23.412498, -0.2913, 0.26119998, -23.412498, 0, -3.3446, -23.390299, 3.3349998, -2.6938999, -23.3676, 3.4459999, 0.1372, -20.0679, -0.3484, -3.5669, -23.390299, 3.3349998, -2.6938999, -23.412498, 0, -3.3446, -20.0679, -0.3484, -3.5669, -19.9489, 3.2063, -2.8082, -23.390299, 3.3349998, -2.6938999, -19.9489, 3.2063, -2.8082, -20.0679, -0.3484, -3.5669, -19.9489, 3.2063, -2.8082, -23.0809, 3.3083, 3.3615, -23.8078, 0, 6.6893, -23.412498, 0, 3.3446, -23.8078, 0, 6.6893, -23.0809, 3.3083, 3.3615, -23.0777, 3.3197, 3.3734, -23.8078, 0, 6.6893, -23.0777, 3.3197, 3.3734, -22.563799, 3.2219, 6.6893, -23.8078, 0, 6.6893, -22.791399, 3.2124999, 9.4432, -23.412498, 0, 10.033899, -23.8078, 0, 6.6893, -22.563799, 3.2219, 6.6893, -22.791399, 3.2124999, 9.4432, -23.412498, 0, 10.033899, -20.0679, 3.1943, 9.407499, -20.0679, 0, 10.033899, -23.412498, 0, 10.033899, -22.791399, 3.2124999, 9.4432, -20.0679, 3.1943, 9.407499, -22.791399, 3.2124999, 9.4432, -23.412498, 0, 10.033899, -22.791399, 3.2124999, 9.4432, -20.0679, 0, 10.033899, -16.2818, 3.1913998, 9.8056, -16.7232, 0, 10.033899, -20.0679, 0, 10.033899, -20.0679, 3.1943, 9.407499, -16.2818, 3.1913998, 9.8056, -16.7232, 0, 13.3786, -13.3786, 3.194, 12.8089, -13.3786, 0, 13.3786, -16.7232, 0, 13.3786, -16.076399, 3.04, 12.735, -13.3786, 3.194, 12.8089, -16.7232, 0, 10.033899, -16.076399, 3.04, 12.735, -16.7232, 0, 13.3786, -16.7232, 0, 10.033899, -16.2818, 3.1913998, 9.8056, -16.076399, 3.04, 12.735, -16.2818, 3.1913998, 9.8056, -16.7232, 0, 10.033899, -16.2818, 3.1913998, 9.8056, -13.3786, 0, 13.3786, -10.033899, 2.9384, 12.4537, -10.033899, 0, 13.3786, -13.3786, 0, 13.3786, -13.3786, 3.194, 12.8089, -10.033899, 2.9384, 12.4537, -13.3786, 3.194, 12.8089, -13.3786, 0, 13.3786, -13.3786, 3.194, 12.8089, -10.033899, 0, 13.3786, -6.2748, 3.126, 13.122, -6.6893, 0, 13.3786, -10.033899, 0, 13.3786, -10.033899, 2.9384, 12.4537, -6.2748, 3.126, 13.122, -6.6893, 0, 13.3786, -6.2748, 3.1260998, 16.4145, -6.6893, 0, 16.7232, -6.6893, 0, 13.3786, -6.2748, 3.126, 13.122, -6.2748, 3.1260998, 16.4145, -3.3446, 0, 16.7232, 0, 2.9636998, 16.0179, 0, 0, 16.7232, -3.3446, 0, 16.7232, -3.3446, 3.1497998, 15.914599, 0, 2.9636998, 16.0179, 0, 0, 16.7232, 3.6425998, 3.1413999, 16.5127, 3.3446, 0, 16.7232, 0, 0, 16.7232, 0, 2.9636998, 16.0179, 3.6425998, 3.1413999, 16.5127, -6.2748, 3.1260998, 16.4145, -3.3446, 0, 16.7232, -6.6893, 0, 16.7232, -3.3446, 0, 16.7232, -6.2748, 3.1260998, 16.4145, -6.2558, 3.1427999, 16.4098, -3.3446, 0, 16.7232, -6.2558, 3.1427999, 16.4098, -3.3446, 3.1497998, 15.914599, 3.3446, 0, 20.0679, 6.6893, 3.2050998, 19.228699, 6.6893, 0, 20.0679, 3.3446, 0, 20.0679, 3.9266999, 3.5967, 19.6223, 6.6893, 3.2050998, 19.228699, 3.6425998, 3.1413999, 16.5127, 3.3446, 0, 20.0679, 3.3446, 0, 16.7232, 3.3446, 0, 20.0679, 3.6425998, 3.1413999, 16.5127, 3.6536, 3.2078998, 16.5669, 3.3446, 0, 20.0679, 3.6536, 3.2078998, 16.5669, 3.9266999, 3.5967, 19.6223, 6.6893, 3.2050998, 19.228699, 10.572599, 0, 20.0679, 6.6893, 0, 20.0679, 10.572599, 0, 20.0679, 6.6893, 3.2050998, 19.228699, 6.6991997, 3.2148, 19.2255, 10.572599, 0, 20.0679, 6.6991997, 3.2148, 19.2255, 9.3066, 3.2186, 19.0653, 10.572599, 0, 20.0679, 10.5913, 3.1985998, 16.7232, 10.572599, 0, 16.7232, 10.572599, 0, 20.0679, 9.3066, 3.2186, 19.0653, 10.5913, 3.1985998, 16.7232, 10.5913, 3.1985998, 16.7232, 10.572599, 0, 13.918799, 10.572599, 0, 16.7232, 10.572599, 0, 13.918799, 10.5913, 3.1985998, 16.7232, 10.5893, 3.2117, 16.7106, 10.572599, 0, 13.918799, 10.5893, 3.2117, 16.7106, 10.0546, 3.1885, 13.4071, 10.572599, 0, 13.918799, 13.2631, 3.5611, 13.4043, 13.8046, 0, 13.918799, 10.572599, 0, 13.918799, 10.0546, 3.1885, 13.4071, 13.2631, 3.5611, 13.4043, 13.8046, 0, 13.918799, 12.9758, 3.2036, 10.033899, 13.8046, 0, 10.033899, 13.8046, 0, 13.918799, 13.2631, 3.5611, 13.4043, 12.9758, 3.2036, 10.033899, 13.8046, 0, 13.918799, 13.2631, 3.5611, 13.4043, 13.2631, 3.5611, 13.4043, 13.8046, 0, 10.033899, 13.4036, 3.0749998, 6.6893, 13.8046, 0, 6.6893, 13.8046, 0, 10.033899, 12.9758, 3.2036, 10.033899, 13.4036, 3.0749998, 6.6893, 20.961199, 0, -3.3446, 16.7127, 2.8734, -3.3446, 20.144999, 2.7605999, -3.3446, 20.961199, 0, -3.3446, 14.0489, 3.0042, -3.3446, 16.7127, 2.8734, -3.3446, 20.144999, 2.7605999, -3.3446, 21.2349, 0, -6.6893, 20.961199, 0, -3.3446, 21.2349, 0, -6.6893, 20.144999, 2.7605999, -3.3446, 20.1349, 2.7974, -3.3783998, 21.2349, 0, -6.6893, 20.1349, 2.7974, -3.3783998, 20.147, 3.0588999, -6.6893, 20.147, 3.0588999, -6.6893, 21.8888, 0, -10.033899, 21.2349, 0, -6.6893, 21.8888, 0, -10.033899, 20.147, 3.0588999, -6.6893, 20.1796, 3.182, -9.995999, 21.8888, 0, -10.033899, 20.1796, 3.182, -9.995999, 20.198, 3.1490998, -10.033899, 23.412498, 0, -26.7572, 26.7572, -0.2593, -23.412498, 23.412498, 0, -23.412498, 26.7572, -0.2593, -23.412498, 23.412498, 0, -26.7572, 26.7572, -0.2593, -25.084799, 26.7572, -0.2593, -25.084799, 23.412498, 0, -26.7572, 26.7572, -0.2593, -26.7572, 26.7572, -0.2593, -21.7402, 23.412498, 0, -23.412498, 26.7572, -0.2593, -23.412498, 26.7572, -0.2593, -20.0679, 23.412498, 0, -23.412498, 26.7572, -0.2593, -21.7402, 23.412498, 0, -23.412498, 26.7572, -0.2593, -20.0679, 23.412498, 0, -20.0679, 26.7572, -0.2593, -18.3955, 23.412498, 0, -20.0679, 26.7572, -0.2593, -20.0679, 26.7572, -0.2593, -16.7232, 23.412498, 0, -20.0679, 26.7572, -0.2593, -18.3955, 23.412498, 0, -20.0679, 26.7572, -0.2593, -16.7232, 23.412498, 0, -16.7232, 26.7572, -0.2593, -15.0508995, 23.412498, 0, -16.7232, 26.7572, -0.2593, -16.7232, 26.7572, -0.2593, -13.3786, 23.412498, 0, -16.7232, 26.7572, -0.2593, -15.0508995, 23.412498, 0, -16.7232, 26.7572, -0.2593, -13.3786, 23.412498, 0, -13.3786, 26.7572, -0.2593, -11.7063, 23.412498, 0, -13.3786, 26.7572, -0.2593, -13.3786, 26.7572, -0.2593, -10.033899, 23.412498, 0, -13.3786, 26.7572, -0.2593, -11.7063, 23.412498, 0, -13.3786, 26.7572, -0.2593, -10.033899, 23.412498, 0, -10.033899, 26.7572, -0.2593, -8.3616, 23.412498, 0, -10.033899, 26.7572, -0.2593, -10.033899, 26.7572, -0.2593, -6.6893, 23.412498, 0, -10.033899, 26.7572, -0.2593, -8.3616, 23.412498, 0, -10.033899, 26.7572, -0.2593, -6.6893, 23.412498, 0, -6.6893, 26.7572, -0.2593, -5.0169997, 23.412498, 0, -6.6893, 26.7572, -0.2593, -6.6893, 26.7572, -0.2593, -3.3446, 23.412498, 0, -6.6893, 26.7572, -0.2593, -5.0169997, 23.412498, 0, -6.6893, 26.7572, -0.2593, -3.3446, 23.412498, 0, -3.3446, 26.7572, -0.2593, -1.6723, 23.412498, 0, -3.3446, 26.7572, -0.2593, -3.3446, 26.7572, -0.2593, 0, 23.412498, 0, -3.3446, 26.7572, -0.2593, -1.6723, 23.412498, 0, -3.3446, 26.7572, -0.2593, 0, 23.412498, 0, 0, 26.7572, -0.2593, 1.6723, 23.412498, 0, 0, 26.7572, -0.2593, 0, 26.7572, -0.2593, 3.3446, 23.412498, 0, 0, 26.7572, -0.2593, 1.6723, 23.412498, 0, 0, 26.7572, -0.2593, 3.3446, 23.412498, 0, 3.3446, 26.7572, -0.2593, 5.0169997, 23.412498, 0, 3.3446, 26.7572, -0.2593, 3.3446, 26.7572, -0.2593, 6.6893, 23.412498, 0, 3.3446, 26.7572, -0.2593, 5.0169997, 23.412498, 0, 3.3446, 26.7572, -0.2593, 6.6893, 23.412498, 0, 6.6893, 26.7572, -0.2593, 8.3616, 23.412498, 0, 6.6893, 26.7572, -0.2593, 6.6893, 26.7572, -0.2593, 10.033899, 23.412498, 0, 6.6893, 26.7572, -0.2593, 8.3616, 23.412498, 0, 6.6893, 26.7572, -0.2593, 10.033899, 23.412498, 0, 10.033899, 26.7572, -0.2593, 11.7063, 23.412498, 0, 10.033899, 26.7572, -0.2593, 10.033899, 26.7572, -0.2593, 13.3786, 23.412498, 0, 10.033899, 26.7572, -0.2593, 11.7063, 23.412498, 0, 10.033899, 26.7572, -0.2593, 13.3786, 23.412498, 0, 13.3786, 26.7572, -0.2593, 15.0508995, 23.412498, 0, 13.3786, 26.7572, -0.2593, 13.3786, 26.7572, -0.2593, 16.7232, 23.412498, 0, 13.3786, 26.7572, -0.2593, 15.0508995, 23.412498, 0, 13.3786, 26.7572, -0.2593, 16.7232, 23.412498, 0, 16.7232, 26.7572, -0.2593, 18.3955, 23.412498, 0, 16.7232, 26.7572, -0.2593, 16.7232, 26.7572, -0.2593, 20.0679, 23.412498, 0, 16.7232, 26.7572, -0.2593, 18.3955, 23.412498, 0, 16.7232, 26.7572, -0.2593, 20.0679, 23.412498, 0, 20.0679, 26.7572, -0.2593, 21.7402, 23.412498, 0, 20.0679, 26.7572, -0.2593, 20.0679, 26.7572, -0.2593, 23.412498, 23.412498, 0, 20.0679, 26.7572, -0.2593, 21.7402, 23.412498, 0, 20.0679, 26.7572, -0.2593, 23.412498, 23.412498, 0, 23.412498, 26.7572, -0.2593, 25.084799, 23.412498, 0, 23.412498, 26.7572, -0.2593, 23.412498, 26.7572, -0.2593, 26.7572, 23.412498, 0, 23.412498, 26.7572, -0.2593, 25.084799, 23.412498, 0, 23.412498, 26.7572, -0.2593, 26.7572, 23.412498, 0, 26.7572, -3.3446, 0, -23.412498, 0, 0, -26.7572, 0, 0, -23.412498, 0, 0, -23.412498, 0, 0, -26.7572, 3.3446, 0, -26.7572, -3.3446, 0, -23.412498, -3.3446, 0, -26.7572, 0, 0, -26.7572, 0, 0, -23.412498, 3.3446, 0, -26.7572, 3.3446, 0, -23.412498, 3.3446, 0, -23.412498, 3.3446, 0, -26.7572, 6.6893, 0, -26.7572, -3.3446, 0, -20.0679, -3.3446, 0, -23.412498, 0, 0, -23.412498, 3.3446, 0, -23.412498, 6.6893, 0, -26.7572, 6.6893, 0, -23.412498, 6.6893, 0, -23.412498, 6.6893, 0, -26.7572, 10.033899, 0, -26.7572, 0, 0, -20.0679, 0, 0, -23.412498, 3.3446, 0, -23.412498, -3.3446, 0, -20.0679, 0, 0, -23.412498, 0, 0, -20.0679, 3.3446, 0, -20.5992, 3.3446, 0, -23.412498, 6.6893, 0, -23.412498, 0, 0, -20.0679, 3.3446, 0, -23.412498, 3.3446, 0, -20.5992, 3.3446, 0, -20.5992, 6.6893, 0, -23.412498, 6.6893, 0, -20.1318, 6.6893, 0, -23.412498, 10.033899, 0, -26.7572, 10.033899, 0, -23.412498, 6.6893, 0, -20.1318, 6.6893, 0, -23.412498, 10.033899, 0, -23.412498, 10.033899, 0, -23.412498, 10.033899, 0, -26.7572, 13.3786, 0, -26.7572, 6.6893, 0, -20.1318, 10.033899, 0, -23.412498, 10.033899, 0, -20.467, 10.033899, 0, -23.412498, 13.3786, 0, -26.7572, 13.3786, 0, -23.412498, 10.033899, 0, -20.467, 10.033899, 0, -23.412498, 13.3786, 0, -23.412498, 13.3786, 0, -23.412498, 13.3786, 0, -26.7572, 16.7232, 0, -26.7572, 10.033899, 0, -20.467, 13.3786, 0, -23.412498, 13.3786, 0, -20.5992, 13.3786, 0, -23.412498, 16.7232, 0, -26.7572, 16.7232, 0, -23.412498, 13.3786, 0, -20.5992, 13.3786, 0, -23.412498, 16.7232, 0, -23.412498, 16.7232, 0, -23.412498, 16.7232, 0, -26.7572, 20.0679, 0, -26.7572, 13.3786, 0, -20.5992, 16.7232, 0, -23.412498, 16.7232, 0, -20.0679, 13.7014, 0, -17.416399, 13.3786, 0, -20.5992, 16.7232, 0, -20.0679, 13.7014, 0, -17.416399, 16.7232, 0, -20.0679, 17.2285, 0, -16.8036, 16.7232, 0, -23.412498, 20.0679, 0, -26.7572, 20.0679, 0, -23.412498, 16.7232, 0, -20.0679, 16.7232, 0, -23.412498, 20.0679, 0, -23.412498, 20.0679, 0, -23.412498, 20.0679, 0, -26.7572, 23.412498, 0, -26.7572, 20.0679, 0, -23.412498, 23.412498, 0, -26.7572, 23.412498, 0, -23.412498, 16.7232, 0, -20.0679, 20.0679, 0, -23.412498, 20.0679, 0, -20.0679, 20.0679, 0, -20.0679, 20.0679, 0, -23.412498, 23.412498, 0, -23.412498, 17.2285, 0, -16.8036, 16.7232, 0, -20.0679, 20.0679, 0, -20.0679, 20.0679, 0, -20.0679, 23.412498, 0, -23.412498, 23.412498, 0, -20.0679, 17.2285, 0, -16.8036, 20.0679, 0, -20.0679, 20.0679, 0, -16.7232, 20.0679, 0, -16.7232, 20.0679, 0, -20.0679, 23.412498, 0, -20.0679, 18.0241, 0, -14.3019, 17.2285, 0, -16.8036, 20.0679, 0, -16.7232, 20.0679, 0, -16.7232, 23.412498, 0, -20.0679, 23.412498, 0, -16.7232, 18.0241, 0, -14.3019, 20.0679, 0, -16.7232, 21.1831, 0, -13.6544, 21.1831, 0, -13.6544, 20.0679, 0, -16.7232, 23.412498, 0, -16.7232, 21.1831, 0, -13.6544, 23.412498, 0, -16.7232, 23.412498, 0, -13.3786, 21.8888, 0, -10.033899, 21.1831, 0, -13.6544, 23.412498, 0, -13.3786, 21.8888, 0, -10.033899, 23.412498, 0, -13.3786, 23.412498, 0, -10.033899, 21.2349, 0, -6.6893, 21.8888, 0, -10.033899, 23.412498, 0, -10.033899, 21.2349, 0, -6.6893, 23.412498, 0, -10.033899, 23.412498, 0, -6.6893, 20.961199, 0, -3.3446, 21.2349, 0, -6.6893, 23.412498, 0, -6.6893, 20.961199, 0, -3.3446, 23.412498, 0, -6.6893, 23.412498, 0, -3.3446, 20.0679, 0, 0, 20.961199, 0, -3.3446, 23.412498, 0, -3.3446, 20.0679, 0, 0, 23.412498, 0, -3.3446, 23.412498, 0, 0, 20.0679, 0, 3.3446, 20.0679, 0, 0, 23.412498, 0, 0, 20.0679, 0, 3.3446, 23.412498, 0, 0, 23.412498, 0, 3.3446, 10.572599, 0, 16.7232, 13.8046, 0, 13.918799, 13.3786, 0, 16.7232, 10.572599, 0, 16.7232, 10.572599, 0, 13.918799, 13.8046, 0, 13.918799, 10.572599, 0, 20.0679, 10.572599, 0, 16.7232, 13.3786, 0, 16.7232, 13.3786, 0, 16.7232, 13.8046, 0, 13.918799, 15.0508995, 0, 13.3786, 10.572599, 0, 20.0679, 13.3786, 0, 16.7232, 13.3786, 0, 20.0679, 13.8046, 0, 13.918799, 15.0508995, 0, 10.033899, 15.0508995, 0, 13.3786, 13.8046, 0, 13.918799, 13.8046, 0, 10.033899, 15.0508995, 0, 10.033899, 13.8046, 0, 10.033899, 15.0508995, 0, 6.6893, 15.0508995, 0, 10.033899, 13.8046, 0, 10.033899, 13.8046, 0, 6.6893, 15.0508995, 0, 6.6893, 15.0508995, 0, 10.033899, 15.0508995, 0, 6.6893, 16.7232, 0, 6.6893, 15.0508995, 0, 10.033899, 16.7232, 0, 6.6893, 16.7232, 0, 10.033899, 15.0508995, 0, 13.3786, 15.0508995, 0, 10.033899, 16.7232, 0, 10.033899, 13.3786, 0, 16.7232, 15.0508995, 0, 13.3786, 15.0508995, 0, 16.7232, 13.3786, 0, 20.0679, 13.3786, 0, 16.7232, 15.0508995, 0, 16.7232, 15.0508995, 0, 13.3786, 16.7232, 0, 10.033899, 16.7232, 0, 13.3786, 15.0508995, 0, 16.7232, 15.0508995, 0, 13.3786, 16.7232, 0, 13.3786, 16.7232, 0, 13.3786, 16.7232, 0, 10.033899, 20.0679, 0, 10.033899, 16.7232, 0, 13.3786, 20.0679, 0, 10.033899, 20.0679, 0, 13.3786, 20.0679, 0, 13.3786, 20.0679, 0, 10.033899, 23.412498, 0, 10.033899, 20.0679, 0, 13.3786, 23.412498, 0, 10.033899, 23.412498, 0, 13.3786, 16.7232, 0, 16.7232, 16.7232, 0, 13.3786, 20.0679, 0, 13.3786, 15.0508995, 0, 16.7232, 16.7232, 0, 13.3786, 16.7232, 0, 16.7232, 20.0679, 0, 16.7232, 20.0679, 0, 13.3786, 23.412498, 0, 13.3786, 16.7232, 0, 16.7232, 20.0679, 0, 13.3786, 20.0679, 0, 16.7232, 20.0679, 0, 16.7232, 23.412498, 0, 13.3786, 23.412498, 0, 16.7232, 15.0508995, 0, 20.0679, 15.0508995, 0, 16.7232, 16.7232, 0, 16.7232, 13.3786, 0, 20.0679, 15.0508995, 0, 16.7232, 15.0508995, 0, 20.0679, 16.7232, 0, 20.0679, 16.7232, 0, 16.7232, 20.0679, 0, 16.7232, 15.0508995, 0, 20.0679, 16.7232, 0, 16.7232, 16.7232, 0, 20.0679, 20.0679, 0, 20.0679, 20.0679, 0, 16.7232, 23.412498, 0, 16.7232, 16.7232, 0, 20.0679, 20.0679, 0, 16.7232, 20.0679, 0, 20.0679, 20.0679, 0, 20.0679, 23.412498, 0, 16.7232, 23.412498, 0, 20.0679, 20.0679, 0, 23.412498, 20.0679, 0, 20.0679, 23.412498, 0, 20.0679, 20.0679, 0, 23.412498, 23.412498, 0, 20.0679, 23.412498, 0, 23.412498, 20.0679, 0, 26.7572, 20.0679, 0, 23.412498, 23.412498, 0, 23.412498, 20.0679, 0, 26.7572, 23.412498, 0, 23.412498, 23.412498, 0, 26.7572, 16.7232, 0, 26.7572, 20.0679, 0, 23.412498, 20.0679, 0, 26.7572, 16.7232, 0, 23.412498, 20.0679, 0, 20.0679, 20.0679, 0, 23.412498, 16.7232, 0, 26.7572, 16.7232, 0, 23.412498, 20.0679, 0, 23.412498, 16.7232, 0, 23.412498, 16.7232, 0, 20.0679, 20.0679, 0, 20.0679, 15.0508995, 0, 26.7572, 16.7232, 0, 23.412498, 16.7232, 0, 26.7572, 15.0508995, 0, 23.412498, 16.7232, 0, 20.0679, 16.7232, 0, 23.412498, 15.0508995, 0, 26.7572, 15.0508995, 0, 23.412498, 16.7232, 0, 23.412498, 15.0508995, 0, 23.412498, 15.0508995, 0, 20.0679, 16.7232, 0, 20.0679, 13.3786, 0, 26.7572, 15.0508995, 0, 23.412498, 15.0508995, 0, 26.7572, 13.3786, 0, 23.412498, 15.0508995, 0, 20.0679, 15.0508995, 0, 23.412498, 13.3786, 0, 26.7572, 13.3786, 0, 23.412498, 15.0508995, 0, 23.412498, 13.3786, 0, 23.412498, 13.3786, 0, 20.0679, 15.0508995, 0, 20.0679, 10.033899, 0, 26.7572, 13.3786, 0, 23.412498, 13.3786, 0, 26.7572, 10.033899, 0, 23.412498, 13.3786, 0, 20.0679, 13.3786, 0, 23.412498, 10.033899, 0, 26.7572, 10.033899, 0, 23.412498, 13.3786, 0, 23.412498, 10.033899, 0, 23.412498, 10.572599, 0, 20.0679, 13.3786, 0, 20.0679, 6.6893, 0, 26.7572, 10.033899, 0, 23.412498, 10.033899, 0, 26.7572, 6.6893, 0, 23.412498, 10.572599, 0, 20.0679, 10.033899, 0, 23.412498, 6.6893, 0, 26.7572, 6.6893, 0, 23.412498, 10.033899, 0, 23.412498, 6.6893, 0, 23.412498, 6.6893, 0, 20.0679, 10.572599, 0, 20.0679, 3.3446, 0, 26.7572, 6.6893, 0, 23.412498, 6.6893, 0, 26.7572, 3.3446, 0, 23.412498, 6.6893, 0, 20.0679, 6.6893, 0, 23.412498, 3.3446, 0, 26.7572, 3.3446, 0, 23.412498, 6.6893, 0, 23.412498, 3.3446, 0, 23.412498, 3.3446, 0, 20.0679, 6.6893, 0, 20.0679, 0, 0, 26.7572, 3.3446, 0, 23.412498, 3.3446, 0, 26.7572, 0, 0, 26.7572, 0, 0, 23.412498, 3.3446, 0, 23.412498, 0, 0, 23.412498, 3.3446, 0, 20.0679, 3.3446, 0, 23.412498, -3.3446, 0, 26.7572, 0, 0, 23.412498, 0, 0, 26.7572, 0, 0, 23.412498, 0, 0, 20.0679, 3.3446, 0, 20.0679, 0, 0, 20.0679, 3.3446, 0, 16.7232, 3.3446, 0, 20.0679, 0, 0, 20.0679, 0, 0, 16.7232, 3.3446, 0, 16.7232, -3.3446, 0, 26.7572, -3.3446, 0, 23.412498, 0, 0, 23.412498, -3.3446, 0, 23.412498, 0, 0, 20.0679, 0, 0, 23.412498, -6.6893, 0, 26.7572, -3.3446, 0, 23.412498, -3.3446, 0, 26.7572, -3.3446, 0, 20.0679, 0, 0, 16.7232, 0, 0, 20.0679, -3.3446, 0, 23.412498, -3.3446, 0, 20.0679, 0, 0, 20.0679, -3.3446, 0, 20.0679, -3.3446, 0, 16.7232, 0, 0, 16.7232, -6.6893, 0, 26.7572, -6.6893, 0, 23.412498, -3.3446, 0, 23.412498, -6.6893, 0, 23.412498, -3.3446, 0, 20.0679, -3.3446, 0, 23.412498, -10.033899, 0, 26.7572, -6.6893, 0, 23.412498, -6.6893, 0, 26.7572, -6.6893, 0, 20.0679, -3.3446, 0, 16.7232, -3.3446, 0, 20.0679, -6.6893, 0, 23.412498, -6.6893, 0, 20.0679, -3.3446, 0, 20.0679, -6.6893, 0, 20.0679, -6.6893, 0, 16.7232, -3.3446, 0, 16.7232, -10.033899, 0, 26.7572, -10.033899, 0, 23.412498, -6.6893, 0, 23.412498, -10.033899, 0, 23.412498, -6.6893, 0, 20.0679, -6.6893, 0, 23.412498, -13.3786, 0, 26.7572, -10.033899, 0, 23.412498, -10.033899, 0, 26.7572, -10.033899, 0, 20.0679, -6.6893, 0, 16.7232, -6.6893, 0, 20.0679, -10.033899, 0, 23.412498, -10.033899, 0, 20.0679, -6.6893, 0, 20.0679, -13.3786, 0, 26.7572, -13.3786, 0, 23.412498, -10.033899, 0, 23.412498, -13.3786, 0, 23.412498, -10.033899, 0, 20.0679, -10.033899, 0, 23.412498, -16.7232, 0, 26.7572, -13.3786, 0, 23.412498, -13.3786, 0, 26.7572, -10.033899, 0, 20.0679, -10.033899, 0, 16.7232, -6.6893, 0, 16.7232, -10.033899, 0, 16.7232, -6.6893, 0, 13.3786, -6.6893, 0, 16.7232, -10.033899, 0, 16.7232, -10.033899, 0, 13.3786, -6.6893, 0, 13.3786, -13.3786, 0, 20.0679, -10.033899, 0, 16.7232, -10.033899, 0, 20.0679, -13.3786, 0, 23.412498, -13.3786, 0, 20.0679, -10.033899, 0, 20.0679, -13.3786, 0, 16.7232, -10.033899, 0, 13.3786, -10.033899, 0, 16.7232, -13.3786, 0, 20.0679, -13.3786, 0, 16.7232, -10.033899, 0, 16.7232, -13.3786, 0, 16.7232, -13.3786, 0, 13.3786, -10.033899, 0, 13.3786, -16.7232, 0, 26.7572, -16.7232, 0, 23.412498, -13.3786, 0, 23.412498, -16.7232, 0, 23.412498, -13.3786, 0, 20.0679, -13.3786, 0, 23.412498, -20.0679, 0, 26.7572, -16.7232, 0, 23.412498, -16.7232, 0, 26.7572, -16.7232, 0, 20.0679, -13.3786, 0, 16.7232, -13.3786, 0, 20.0679, -16.7232, 0, 23.412498, -16.7232, 0, 20.0679, -13.3786, 0, 20.0679, -16.7232, 0, 16.7232, -13.3786, 0, 13.3786, -13.3786, 0, 16.7232, -16.7232, 0, 20.0679, -16.7232, 0, 16.7232, -13.3786, 0, 16.7232, -16.7232, 0, 16.7232, -16.7232, 0, 13.3786, -13.3786, 0, 13.3786, -20.0679, 0, 26.7572, -20.0679, 0, 23.412498, -16.7232, 0, 23.412498, -20.0679, 0, 23.412498, -16.7232, 0, 20.0679, -16.7232, 0, 23.412498, -23.412498, 0, 26.7572, -20.0679, 0, 23.412498, -20.0679, 0, 26.7572, -20.0679, 0, 20.0679, -16.7232, 0, 16.7232, -16.7232, 0, 20.0679, -20.0679, 0, 23.412498, -20.0679, 0, 20.0679, -16.7232, 0, 20.0679, -23.412498, 0, 26.7572, -23.412498, 0, 23.412498, -20.0679, 0, 23.412498, -23.412498, 0, 23.412498, -20.0679, 0, 20.0679, -20.0679, 0, 23.412498, -26.7572, 0, 26.7572, -23.412498, 0, 23.412498, -23.412498, 0, 26.7572, -26.7572, 0, 26.7572, -26.7572, 0, 23.412498, -23.412498, 0, 23.412498, -26.7572, 0, 23.412498, -23.412498, 0, 20.0679, -23.412498, 0, 23.412498, -23.412498, 0, 23.412498, -23.412498, 0, 20.0679, -20.0679, 0, 20.0679, -26.7572, 0, 23.412498, -26.7572, 0, 20.0679, -23.412498, 0, 20.0679, -20.0679, 0, 20.0679, -20.0679, 0, 16.7232, -16.7232, 0, 16.7232, -23.412498, 0, 20.0679, -20.0679, 0, 16.7232, -20.0679, 0, 20.0679, -20.0679, 0, 16.7232, -16.7232, 0, 13.3786, -16.7232, 0, 16.7232, -26.7572, 0, 20.0679, -23.412498, 0, 16.7232, -23.412498, 0, 20.0679, -23.412498, 0, 20.0679, -23.412498, 0, 16.7232, -20.0679, 0, 16.7232, -26.7572, 0, 20.0679, -26.7572, 0, 16.7232, -23.412498, 0, 16.7232, -20.0679, 0, 16.7232, -20.0679, 0, 13.3786, -16.7232, 0, 13.3786, -23.412498, 0, 16.7232, -20.0679, 0, 13.3786, -20.0679, 0, 16.7232, -20.0679, 0, 13.3786, -16.7232, 0, 10.033899, -16.7232, 0, 13.3786, -20.0679, 0, 13.3786, -20.0679, 0, 10.033899, -16.7232, 0, 10.033899, -23.412498, 0, 16.7232, -23.412498, 0, 13.3786, -20.0679, 0, 13.3786, -23.412498, 0, 13.3786, -20.0679, 0, 10.033899, -20.0679, 0, 13.3786, -26.7572, 0, 16.7232, -23.412498, 0, 13.3786, -23.412498, 0, 16.7232, -23.412498, 0, 13.3786, -23.412498, 0, 10.033899, -20.0679, 0, 10.033899, -26.7572, 0, 16.7232, -26.7572, 0, 13.3786, -23.412498, 0, 13.3786, -26.7572, 0, 13.3786, -23.412498, 0, 10.033899, -23.412498, 0, 13.3786, -26.7572, 0, 13.3786, -26.7572, 0, 10.033899, -23.412498, 0, 10.033899, -26.7572, 0, 10.033899, -23.8078, 0, 6.6893, -23.412498, 0, 10.033899, -26.7572, 0, 10.033899, -26.7572, 0, 6.6893, -23.8078, 0, 6.6893, -26.7572, 0, 6.6893, -23.412498, 0, 3.3446, -23.8078, 0, 6.6893, -26.7572, 0, 6.6893, -26.7572, 0, 3.3446, -23.412498, 0, 3.3446, -16.7232, 0, -23.412498, -13.3786, 0, -26.7572, -13.3786, 0, -23.412498, -13.3786, 0, -23.412498, -13.3786, 0, -26.7572, -10.033899, 0, -26.7572, -16.7232, 0, -23.412498, -16.7232, 0, -26.7572, -13.3786, 0, -26.7572, -13.3786, 0, -23.412498, -10.033899, 0, -26.7572, -10.033899, 0, -23.412498, -10.033899, 0, -23.412498, -10.033899, 0, -26.7572, -6.6893, 0, -26.7572, -16.7232, 0, -20.0679, -16.7232, 0, -23.412498, -13.3786, 0, -23.412498, -10.033899, 0, -23.412498, -6.6893, 0, -26.7572, -6.6893, 0, -23.412498, -6.6893, 0, -23.412498, -6.6893, 0, -26.7572, -3.3446, 0, -26.7572, -6.6893, 0, -23.412498, -3.3446, 0, -26.7572, -3.3446, 0, -23.412498, -13.3786, 0, -20.0679, -13.3786, 0, -23.412498, -10.033899, 0, -23.412498, -16.7232, 0, -20.0679, -13.3786, 0, -23.412498, -13.3786, 0, -20.0679, -10.033899, 0, -20.0679, -10.033899, 0, -23.412498, -6.6893, 0, -23.412498, -13.3786, 0, -20.0679, -10.033899, 0, -23.412498, -10.033899, 0, -20.0679, -6.6893, 0, -20.0679, -6.6893, 0, -23.412498, -3.3446, 0, -23.412498, -10.033899, 0, -20.0679, -6.6893, 0, -23.412498, -6.6893, 0, -20.0679, -6.6893, 0, -20.0679, -3.3446, 0, -23.412498, -3.3446, 0, -20.0679, -13.3786, 0, -17.3364, -13.3786, 0, -20.0679, -10.033899, 0, -20.0679, -13.3786, 0, -17.3364, -10.033899, 0, -20.0679, -10.033899, 0, -16.7232, -10.033899, 0, -16.7232, -10.033899, 0, -20.0679, -6.6893, 0, -20.0679, -16.7232, 0, -16.7232, -13.3786, 0, -20.0679, -13.3786, 0, -17.3364, -16.7232, 0, -16.7232, -16.7232, 0, -20.0679, -13.3786, 0, -20.0679, -10.033899, 0, -16.7232, -6.6893, 0, -20.0679, -6.6893, 0, -16.7232, -6.6893, 0, -16.7232, -6.6893, 0, -20.0679, -3.3446, 0, -20.0679, -9.6168995, 0, -14.127899, -10.033899, 0, -16.7232, -6.6893, 0, -16.7232, -9.6168995, 0, -14.127899, -6.6893, 0, -16.7232, -6.6893, 0, -14.2637, -6.6893, 0, -16.7232, -3.3446, 0, -20.0679, -3.3446, 0, -16.7232, -6.6893, 0, -14.2637, -6.6893, 0, -16.7232, -3.3446, 0, -16.7232, -3.3446, 0, -16.7232, -3.3446, 0, -20.0679, 0, 0, -20.0679, -6.6893, 0, -14.2637, -3.3446, 0, -16.7232, -3.3446, 0, -13.3786, -3.3446, 0, -16.7232, 0, 0, -20.0679, 0, 0, -16.7232, -3.3446, 0, -13.3786, -3.3446, 0, -16.7232, 0, 0, -16.7232, 0, 0, -16.7232, 0, 0, -20.0679, 3.3446, 0, -20.5992, 0, 0, -16.7232, 3.3446, 0, -20.5992, 3.0140998, 0, -16.7232, -3.3446, 0, -13.3786, 0, 0, -16.7232, 0, 0, -13.3786, -2.6297, 0, -10.5745, -3.3446, 0, -13.3786, 0, 0, -13.3786, -2.6297, 0, -10.5745, 0, 0, -13.3786, 0, 0, -10.7654, -20.0679, 0, -16.7232, -16.7232, 0, -20.0679, -16.7232, 0, -16.7232, -20.0679, 0, -16.7232, -20.0679, 0, -20.0679, -16.7232, 0, -20.0679, -20.0679, 0, -20.0679, -16.7232, 0, -23.412498, -16.7232, 0, -20.0679, -20.0679, 0, -13.3786, -20.0679, 0, -16.7232, -16.7232, 0, -16.7232, -20.0679, 0, -13.3786, -16.7232, 0, -16.7232, -16.7232, 0, -13.3786, -23.412498, 0, -16.7232, -20.0679, 0, -20.0679, -20.0679, 0, -16.7232, -20.0679, 0, -10.033899, -20.0679, 0, -13.3786, -16.7232, 0, -13.3786, -20.0679, 0, -10.033899, -16.7232, 0, -13.3786, -17.275, 0, -10.5169, -23.412498, 0, -13.3786, -20.0679, 0, -16.7232, -20.0679, 0, -13.3786, -23.412498, 0, -13.3786, -23.412498, 0, -16.7232, -20.0679, 0, -16.7232, -23.412498, 0, -10.033899, -20.0679, 0, -13.3786, -20.0679, 0, -10.033899, -23.412498, 0, -10.033899, -23.412498, 0, -13.3786, -20.0679, 0, -13.3786, -26.7572, 0, -10.033899, -23.412498, 0, -13.3786, -23.412498, 0, -10.033899, -26.7572, 0, -10.033899, -26.7572, 0, -13.3786, -23.412498, 0, -13.3786, -26.7572, 0, -13.3786, -23.412498, 0, -16.7232, -23.412498, 0, -13.3786, -26.7572, 0, -6.6893, -26.7572, 0, -10.033899, -23.412498, 0, -10.033899, -26.7572, 0, -13.3786, -26.7572, 0, -16.7232, -23.412498, 0, -16.7232, -26.7572, 0, -6.6893, -23.412498, 0, -10.033899, -23.412498, 0, -6.6893, -23.412498, 0, -6.6893, -23.412498, 0, -10.033899, -20.0679, 0, -10.033899, -23.412498, 0, -6.6893, -20.0679, 0, -10.033899, -20.3764, 0, -6.6893, -26.7572, 0, -3.3446, -26.7572, 0, -6.6893, -23.412498, 0, -6.6893, -26.7572, 0, -3.3446, -23.412498, 0, -6.6893, -23.412498, 0, -3.3446, -26.7572, 0, -16.7232, -23.412498, 0, -20.0679, -23.412498, 0, -16.7232, -23.412498, 0, -16.7232, -23.412498, 0, -20.0679, -20.0679, 0, -20.0679, -26.7572, 0, -16.7232, -26.7572, 0, -20.0679, -23.412498, 0, -20.0679, -23.412498, 0, -20.0679, -20.0679, 0, -23.412498, -20.0679, 0, -20.0679, -20.0679, 0, -20.0679, -20.0679, 0, -23.412498, -16.7232, 0, -23.412498, -20.0679, 0, -23.412498, -16.7232, 0, -26.7572, -16.7232, 0, -23.412498, -20.0679, 0, -23.412498, -20.0679, 0, -26.7572, -16.7232, 0, -26.7572, -23.412498, 0, -20.0679, -23.412498, 0, -23.412498, -20.0679, 0, -23.412498, -23.412498, 0, -23.412498, -20.0679, 0, -26.7572, -20.0679, 0, -23.412498, -26.7572, 0, -20.0679, -23.412498, 0, -23.412498, -23.412498, 0, -20.0679, -23.412498, 0, -23.412498, -23.412498, 0, -26.7572, -20.0679, 0, -26.7572, -26.7572, 0, -20.0679, -26.7572, 0, -23.412498, -23.412498, 0, -23.412498, -26.7572, 0, -23.412498, -23.412498, 0, -26.7572, -23.412498, 0, -23.412498, -26.7572, 0, -23.412498, -26.7572, 0, -26.7572, -23.412498, 0, -26.7572, -23.412498, 0, -3.3446, -20.3764, 0, -6.6893, -20.0679, -0.3484, -3.5669, -23.412498, 0, -3.3446, -23.412498, 0, -6.6893, -20.3764, 0, -6.6893, 16.7232, 0, 10.033899, 20.0679, -0.124199994, 6.6893, 20.0679, 0, 10.033899, 16.7232, 0, 10.033899, 16.7232, 0, 6.6893, 20.0679, -0.124199994, 6.6893, 20.0679, 0, 10.033899, 23.412498, 0, 6.6893, 23.412498, 0, 10.033899, 20.0679, 0, 10.033899, 20.0679, -0.124199994, 6.6893, 23.412498, 0, 6.6893, -26.7572, 0, 3.3446, -23.412498, -0.2913, 0.26119998, -23.412498, 0, 3.3446, -26.7572, 0, 3.3446, -26.7572, 0, 0, -23.412498, -0.2913, 0.26119998, -26.7572, 0, 0, -23.412498, 0, -3.3446, -23.412498, -0.2913, 0.26119998, -26.7572, 0, 0, -26.7572, 0, -3.3446, -23.412498, 0, -3.3446, 0, 0, -13.3786, 3.0140998, 0, -16.7232, 3.3446, -0.3989, -12.9443, 0, 0, -13.3786, 0, 0, -16.7232, 3.0140998, 0, -16.7232, 0, 0, -10.7654, 3.3446, -0.3989, -12.9443, 2.8241, 0, -10.4594, 0, 0, -10.7654, 0, 0, -13.3786, 3.3446, -0.3989, -12.9443, 20.0679, -0.124199994, 6.6893, 23.412498, 0, 3.3446, 23.412498, 0, 6.6893, 20.0679, -0.124199994, 6.6893, 20.0679, 0, 3.3446, 23.412498, 0, 3.3446, 51.2184, -3.9413, -15.0508995, 54.072197, -3.9413, -13.3786, 51.2184, -3.9413, -13.3786, 51.2184, -3.9413, -13.3786, 54.072197, -3.9413, -13.3786, 54.072197, -3.9413, -11.7063, 51.2184, -3.9413, -15.0508995, 54.072197, -3.9413, -15.0508995, 54.072197, -3.9413, -13.3786, 51.2184, -3.9413, -13.3786, 54.072197, -3.9413, -11.7063, 51.2184, -3.9413, -11.7063, 51.2184, -3.9413, -11.7063, 54.072197, -3.9413, -11.7063, 54.072197, -3.9413, -10.033899, 48.3645, -3.9413, -15.0508995, 51.2184, -3.9413, -15.0508995, 51.2184, -3.9413, -13.3786, 51.2184, -3.9413, -11.7063, 54.072197, -3.9413, -10.033899, 51.2184, -3.9413, -10.033899, 51.2184, -3.9413, -10.033899, 54.072197, -3.9413, -10.033899, 54.072197, -3.9413, -8.3616, 48.3645, -3.9413, -13.3786, 51.2184, -3.9413, -13.3786, 51.2184, -3.9413, -11.7063, 48.3645, -3.9413, -15.0508995, 51.2184, -3.9413, -13.3786, 48.3645, -3.9413, -13.3786, 48.3645, -3.9413, -11.7063, 51.2184, -3.9413, -11.7063, 51.2184, -3.9413, -10.033899, 48.3645, -3.9413, -13.3786, 51.2184, -3.9413, -11.7063, 48.3645, -3.9413, -11.7063, 51.2184, -3.9413, -10.033899, 54.072197, -3.9413, -8.3616, 51.2184, -3.9413, -8.3616, 51.2184, -3.9413, -8.3616, 54.072197, -3.9413, -8.3616, 54.072197, -3.9413, -6.6893, 48.3645, -3.9413, -11.7063, 51.2184, -3.9413, -10.033899, 48.3645, -3.9413, -10.033899, 48.3645, -3.9413, -10.033899, 51.2184, -3.9413, -10.033899, 51.2184, -3.9413, -8.3616, 45.5107, -3.9413, -13.3786, 48.3645, -3.9413, -13.3786, 48.3645, -3.9413, -11.7063, 45.5107, -3.9413, -11.7063, 48.3645, -3.9413, -11.7063, 48.3645, -3.9413, -10.033899, 45.5107, -3.9413, -13.3786, 48.3645, -3.9413, -11.7063, 45.5107, -3.9413, -11.7063, 51.2184, -3.9413, -8.3616, 54.072197, -3.9413, -6.6893, 51.2184, -3.9413, -6.6893, 51.2184, -3.9413, -6.6893, 54.072197, -3.9413, -6.6893, 54.072197, -3.9413, -5.0169997, 48.3645, -3.9413, -10.033899, 51.2184, -3.9413, -8.3616, 48.3645, -3.9413, -8.3616, 48.3645, -3.9413, -8.3616, 51.2184, -3.9413, -8.3616, 51.2184, -3.9413, -6.6893, 45.5107, -3.9413, -11.7063, 48.3645, -3.9413, -10.033899, 45.5107, -3.9413, -10.033899, 45.5107, -3.9413, -10.033899, 48.3645, -3.9413, -10.033899, 48.3645, -3.9413, -8.3616, 51.2184, -3.9413, -6.6893, 54.072197, -3.9413, -5.0169997, 51.2184, -3.9413, -5.0169997, 51.2184, -3.9413, -5.0169997, 54.072197, -3.9413, -5.0169997, 54.072197, -3.9413, -3.3446, 48.3645, -3.9413, -8.3616, 51.2184, -3.9413, -6.6893, 48.3645, -3.9413, -6.6893, 48.3645, -3.9413, -6.6893, 51.2184, -3.9413, -6.6893, 51.2184, -3.9413, -5.0169997, 45.5107, -3.9413, -10.033899, 48.3645, -3.9413, -8.3616, 45.5107, -3.9413, -8.3616, 45.5107, -3.9413, -8.3616, 48.3645, -3.9413, -8.3616, 48.3645, -3.9413, -6.6893, 51.2184, -3.9413, -5.0169997, 54.072197, -3.9413, -3.3446, 51.2184, -3.9413, -3.3446, 51.2184, -3.9413, -3.3446, 54.072197, -3.9413, -3.3446, 54.072197, -3.9413, -1.6723, 48.3645, -3.9413, -6.6893, 51.2184, -3.9413, -5.0169997, 48.3645, -3.9413, -5.0169997, 48.3645, -3.9413, -5.0169997, 51.2184, -3.9413, -5.0169997, 51.2184, -3.9413, -3.3446, 45.5107, -3.9413, -8.3616, 48.3645, -3.9413, -6.6893, 45.5107, -3.9413, -6.6893, 45.5107, -3.9413, -6.6893, 48.3645, -3.9413, -6.6893, 48.3645, -3.9413, -5.0169997, 51.2184, -3.9413, -3.3446, 54.072197, -3.9413, -1.6723, 51.2184, -3.9413, -1.6723, 51.2184, -3.9413, -1.6723, 54.072197, -3.9413, -1.6723, 54.072197, -3.9413, 0, 48.3645, -3.9413, -5.0169997, 51.2184, -3.9413, -3.3446, 48.3645, -3.9413, -3.3446, 48.3645, -3.9413, -3.3446, 51.2184, -3.9413, -3.3446, 51.2184, -3.9413, -1.6723, 45.5107, -3.9413, -6.6893, 48.3645, -3.9413, -5.0169997, 45.5107, -3.9413, -5.0169997, 45.5107, -3.9413, -5.0169997, 48.3645, -3.9413, -5.0169997, 48.3645, -3.9413, -3.3446, 51.2184, -3.9413, -1.6723, 54.072197, -3.9413, 0, 51.2184, -3.9413, 0, 51.2184, -3.9413, 0, 54.072197, -3.9413, 0, 54.072197, -3.9413, 1.6723, 48.3645, -3.9413, -3.3446, 51.2184, -3.9413, -1.6723, 48.3645, -3.9413, -1.6723, 48.3645, -3.9413, -1.6723, 51.2184, -3.9413, -1.6723, 51.2184, -3.9413, 0, 45.5107, -3.9413, -5.0169997, 48.3645, -3.9413, -3.3446, 45.5107, -3.9413, -3.3446, 45.5107, -3.9413, -3.3446, 48.3645, -3.9413, -3.3446, 48.3645, -3.9413, -1.6723, 51.2184, -3.9413, 0, 54.072197, -3.9413, 1.6723, 51.2184, -3.9413, 1.6723, 51.2184, -3.9413, 1.6723, 54.072197, -3.9413, 1.6723, 54.072197, -3.9413, 3.3446, 48.3645, -3.9413, -1.6723, 51.2184, -3.9413, 0, 48.3645, -3.9413, 0, 48.3645, -3.9413, 0, 51.2184, -3.9413, 0, 51.2184, -3.9413, 1.6723, 45.5107, -3.9413, -3.3446, 48.3645, -3.9413, -1.6723, 45.5107, -3.9413, -1.6723, 45.5107, -3.9413, -1.6723, 48.3645, -3.9413, -1.6723, 48.3645, -3.9413, 0, 51.2184, -3.9413, 1.6723, 54.072197, -3.9413, 3.3446, 51.2184, -3.9413, 3.3446, 51.2184, -3.9413, 3.3446, 54.072197, -3.9413, 3.3446, 54.072197, -3.9413, 5.0169997, 48.3645, -3.9413, 0, 51.2184, -3.9413, 1.6723, 48.3645, -3.9413, 1.6723, 48.3645, -3.9413, 1.6723, 51.2184, -3.9413, 1.6723, 51.2184, -3.9413, 3.3446, 45.5107, -3.9413, -1.6723, 48.3645, -3.9413, 0, 45.5107, -3.9413, 0, 45.5107, -3.9413, 0, 48.3645, -3.9413, 0, 48.3645, -3.9413, 1.6723, 51.2184, -3.9413, 3.3446, 54.072197, -3.9413, 5.0169997, 51.2184, -3.9413, 5.0169997, 51.2184, -3.9413, 5.0169997, 54.072197, -3.9413, 5.0169997, 54.072197, -3.9413, 6.6893, 48.3645, -3.9413, 1.6723, 51.2184, -3.9413, 3.3446, 48.3645, -3.9413, 3.3446, 48.3645, -3.9413, 3.3446, 51.2184, -3.9413, 3.3446, 51.2184, -3.9413, 5.0169997, 45.5107, -3.9413, 0, 48.3645, -3.9413, 1.6723, 45.5107, -3.9413, 1.6723, 45.5107, -3.9413, 1.6723, 48.3645, -3.9413, 1.6723, 48.3645, -3.9413, 3.3446, 51.2184, -3.9413, 5.0169997, 54.072197, -3.9413, 6.6893, 51.2184, -3.9413, 6.6893, 51.2184, -3.9413, 6.6893, 54.072197, -3.9413, 6.6893, 54.072197, -3.9413, 8.3616, 48.3645, -3.9413, 3.3446, 51.2184, -3.9413, 5.0169997, 48.3645, -3.9413, 5.0169997, 48.3645, -3.9413, 5.0169997, 51.2184, -3.9413, 5.0169997, 51.2184, -3.9413, 6.6893, 45.5107, -3.9413, 1.6723, 48.3645, -3.9413, 3.3446, 45.5107, -3.9413, 3.3446, 45.5107, -3.9413, 3.3446, 48.3645, -3.9413, 3.3446, 48.3645, -3.9413, 5.0169997, 51.2184, -3.9413, 6.6893, 54.072197, -3.9413, 8.3616, 51.2184, -3.9413, 8.3616, 51.2184, -3.9413, 8.3616, 54.072197, -3.9413, 8.3616, 54.072197, -3.9413, 10.033899, 48.3645, -3.9413, 5.0169997, 51.2184, -3.9413, 6.6893, 48.3645, -3.9413, 6.6893, 48.3645, -3.9413, 6.6893, 51.2184, -3.9413, 6.6893, 51.2184, -3.9413, 8.3616, 45.5107, -3.9413, 3.3446, 48.3645, -3.9413, 5.0169997, 45.5107, -3.9413, 5.0169997, 45.5107, -3.9413, 5.0169997, 48.3645, -3.9413, 5.0169997, 48.3645, -3.9413, 6.6893, 51.2184, -3.9413, 8.3616, 54.072197, -3.9413, 10.033899, 51.2184, -3.9413, 10.033899, 51.2184, -3.9413, 10.033899, 54.072197, -3.9413, 10.033899, 54.072197, -3.9413, 11.7063, 48.3645, -3.9413, 6.6893, 51.2184, -3.9413, 8.3616, 48.3645, -3.9413, 8.3616, 48.3645, -3.9413, 8.3616, 51.2184, -3.9413, 8.3616, 51.2184, -3.9413, 10.033899, 45.5107, -3.9413, 5.0169997, 48.3645, -3.9413, 6.6893, 45.5107, -3.9413, 6.6893, 45.5107, -3.9413, 6.6893, 48.3645, -3.9413, 6.6893, 48.3645, -3.9413, 8.3616, 51.2184, -3.9413, 10.033899, 54.072197, -3.9413, 11.7063, 51.2184, -3.9413, 11.7063, 51.2184, -3.9413, 11.7063, 54.072197, -3.9413, 11.7063, 54.072197, -3.9413, 13.3786, 48.3645, -3.9413, 8.3616, 51.2184, -3.9413, 10.033899, 48.3645, -3.9413, 10.033899, 48.3645, -3.9413, 10.033899, 51.2184, -3.9413, 10.033899, 51.2184, -3.9413, 11.7063, 45.5107, -3.9413, 6.6893, 48.3645, -3.9413, 8.3616, 45.5107, -3.9413, 8.3616, 45.5107, -3.9413, 8.3616, 48.3645, -3.9413, 8.3616, 48.3645, -3.9413, 10.033899, 51.2184, -3.9413, 11.7063, 54.072197, -3.9413, 13.3786, 51.2184, -3.9413, 13.3786, 51.2184, -3.9413, 13.3786, 54.072197, -3.9413, 13.3786, 54.072197, -3.9413, 15.0508995, 48.3645, -3.9413, 10.033899, 51.2184, -3.9413, 11.7063, 48.3645, -3.9413, 11.7063, 48.3645, -3.9413, 11.7063, 51.2184, -3.9413, 11.7063, 51.2184, -3.9413, 13.3786, 45.5107, -3.9413, 8.3616, 48.3645, -3.9413, 10.033899, 45.5107, -3.9413, 10.033899, 45.5107, -3.9413, 10.033899, 48.3645, -3.9413, 10.033899, 48.3645, -3.9413, 11.7063, 51.2184, -3.9413, 13.3786, 54.072197, -3.9413, 15.0508995, 51.2184, -3.9413, 15.0508995, 51.2184, -3.9413, 15.0508995, 54.072197, -3.9413, 15.0508995, 54.072197, -3.9413, 16.7232, 48.3645, -3.9413, 11.7063, 51.2184, -3.9413, 13.3786, 48.3645, -3.9413, 13.3786, 48.3645, -3.9413, 13.3786, 51.2184, -3.9413, 13.3786, 51.2184, -3.9413, 15.0508995, 45.5107, -3.9413, 10.033899, 48.3645, -3.9413, 11.7063, 45.5107, -3.9413, 11.7063, 45.5107, -3.9413, 11.7063, 48.3645, -3.9413, 11.7063, 48.3645, -3.9413, 13.3786, 51.2184, -3.9413, 15.0508995, 54.072197, -3.9413, 16.7232, 51.2184, -3.9413, 16.7232, 51.2184, -3.9413, 16.7232, 54.072197, -3.9413, 16.7232, 54.072197, -3.9413, 18.3955, 48.3645, -3.9413, 13.3786, 51.2184, -3.9413, 15.0508995, 48.3645, -3.9413, 15.0508995, 48.3645, -3.9413, 15.0508995, 51.2184, -3.9413, 15.0508995, 51.2184, -3.9413, 16.7232, 45.5107, -3.9413, 11.7063, 48.3645, -3.9413, 13.3786, 45.5107, -3.9413, 13.3786, 45.5107, -3.9413, 13.3786, 48.3645, -3.9413, 13.3786, 48.3645, -3.9413, 15.0508995, 51.2184, -3.9413, 16.7232, 54.072197, -3.9413, 18.3955, 51.2184, -3.9413, 18.3955, 51.2184, -3.9413, 18.3955, 54.072197, -3.9413, 18.3955, 54.072197, -3.9413, 20.0679, 48.3645, -3.9413, 15.0508995, 51.2184, -3.9413, 16.7232, 48.3645, -3.9413, 16.7232, 48.3645, -3.9413, 16.7232, 51.2184, -3.9413, 16.7232, 51.2184, -3.9413, 18.3955, 45.5107, -3.9413, 13.3786, 48.3645, -3.9413, 15.0508995, 45.5107, -3.9413, 15.0508995, 45.5107, -3.9413, 15.0508995, 48.3645, -3.9413, 15.0508995, 48.3645, -3.9413, 16.7232, 51.2184, -3.9413, 18.3955, 54.072197, -3.9413, 20.0679, 51.2184, -3.9413, 20.0679, 51.2184, -3.9413, 20.0679, 54.072197, -3.9413, 20.0679, 54.072197, -3.9413, 21.7402, 48.3645, -3.9413, 16.7232, 51.2184, -3.9413, 18.3955, 48.3645, -3.9413, 18.3955, 48.3645, -3.9413, 18.3955, 51.2184, -3.9413, 18.3955, 51.2184, -3.9413, 20.0679, 45.5107, -3.9413, 15.0508995, 48.3645, -3.9413, 16.7232, 45.5107, -3.9413, 16.7232, 45.5107, -3.9413, 16.7232, 48.3645, -3.9413, 16.7232, 48.3645, -3.9413, 18.3955, 51.2184, -3.9413, 20.0679, 54.072197, -3.9413, 21.7402, 51.2184, -3.9413, 21.7402, 51.2184, -3.9413, 21.7402, 54.072197, -3.9413, 21.7402, 54.072197, -3.9413, 23.412498, 48.3645, -3.9413, 18.3955, 51.2184, -3.9413, 20.0679, 48.3645, -3.9413, 20.0679, 48.3645, -3.9413, 20.0679, 51.2184, -3.9413, 20.0679, 51.2184, -3.9413, 21.7402, 45.5107, -3.9413, 16.7232, 48.3645, -3.9413, 18.3955, 45.5107, -3.9413, 18.3955, 45.5107, -3.9413, 18.3955, 48.3645, -3.9413, 18.3955, 48.3645, -3.9413, 20.0679, 51.2184, -3.9413, 21.7402, 54.072197, -3.9413, 23.412498, 51.2184, -3.9413, 23.412498, 51.2184, -3.9413, 23.412498, 54.072197, -3.9413, 23.412498, 54.072197, -3.9413, 25.084799, 48.3645, -3.9413, 20.0679, 51.2184, -3.9413, 21.7402, 48.3645, -3.9413, 21.7402, 48.3645, -3.9413, 21.7402, 51.2184, -3.9413, 21.7402, 51.2184, -3.9413, 23.412498, 45.5107, -3.9413, 18.3955, 48.3645, -3.9413, 20.0679, 45.5107, -3.9413, 20.0679, 45.5107, -3.9413, 20.0679, 48.3645, -3.9413, 20.0679, 48.3645, -3.9413, 21.7402, 51.2184, -3.9413, 23.412498, 54.072197, -3.9413, 25.084799, 51.2184, -3.9413, 25.084799, 51.2184, -3.9413, 25.084799, 54.072197, -3.9413, 25.084799, 54.072197, -3.9413, 26.7572, 51.2184, -3.9413, 25.084799, 54.072197, -3.9413, 26.7572, 51.2184, -3.9413, 26.7572, 48.3645, -3.9413, 23.412498, 51.2184, -3.9413, 23.412498, 51.2184, -3.9413, 25.084799, 48.3645, -3.9413, 21.7402, 51.2184, -3.9413, 23.412498, 48.3645, -3.9413, 23.412498, 48.3645, -3.9413, 25.084799, 51.2184, -3.9413, 25.084799, 51.2184, -3.9413, 26.7572, 48.3645, -3.9413, 23.412498, 51.2184, -3.9413, 25.084799, 48.3645, -3.9413, 25.084799, 48.3645, -3.9413, 25.084799, 51.2184, -3.9413, 26.7572, 48.3645, -3.9413, 26.7572, 45.5107, -3.9413, 21.7402, 48.3645, -3.9413, 21.7402, 48.3645, -3.9413, 23.412498, 45.5107, -3.9413, 20.0679, 48.3645, -3.9413, 21.7402, 45.5107, -3.9413, 21.7402, 45.5107, -3.9413, 23.412498, 48.3645, -3.9413, 23.412498, 48.3645, -3.9413, 25.084799, 45.5107, -3.9413, 21.7402, 48.3645, -3.9413, 23.412498, 45.5107, -3.9413, 23.412498, 45.5107, -3.9413, 25.084799, 48.3645, -3.9413, 25.084799, 48.3645, -3.9413, 26.7572, 45.5107, -3.9413, 23.412498, 48.3645, -3.9413, 25.084799, 45.5107, -3.9413, 25.084799, 45.5107, -3.9413, 25.084799, 48.3645, -3.9413, 26.7572, 45.5107, -3.9413, 26.7572, 42.6568, -3.9413, 25.084799, 45.5107, -3.9413, 25.084799, 45.5107, -3.9413, 26.7572, 42.6568, -3.9413, 25.084799, 45.5107, -3.9413, 26.7572, 42.6568, -3.9413, 26.7572, 39.7973, -3.9413, 25.084799, 42.6568, -3.9413, 25.084799, 42.6568, -3.9413, 26.7572, 39.7973, -3.9413, 25.084799, 42.6568, -3.9413, 26.7572, 39.7973, -3.9413, 26.7572, 39.7973, -3.9413, 23.412498, 42.6568, -3.9413, 25.084799, 39.7973, -3.9413, 25.084799, 42.6568, -3.9413, 23.412498, 45.5107, -3.9413, 25.084799, 42.6568, -3.9413, 25.084799, 39.7973, -3.9413, 23.412498, 42.6568, -3.9413, 23.412498, 42.6568, -3.9413, 25.084799, 42.6568, -3.9413, 23.412498, 45.5107, -3.9413, 23.412498, 45.5107, -3.9413, 25.084799, 39.7973, -3.9413, 21.7402, 42.6568, -3.9413, 23.412498, 39.7973, -3.9413, 23.412498, 42.6568, -3.9413, 21.7402, 45.5107, -3.9413, 23.412498, 42.6568, -3.9413, 23.412498, 39.7973, -3.9413, 21.7402, 42.6568, -3.9413, 21.7402, 42.6568, -3.9413, 23.412498, 42.6568, -3.9413, 21.7402, 45.5107, -3.9413, 21.7402, 45.5107, -3.9413, 23.412498, 39.7973, -3.9413, 20.0679, 42.6568, -3.9413, 21.7402, 39.7973, -3.9413, 21.7402, 42.6568, -3.9413, 20.0679, 45.5107, -3.9413, 21.7402, 42.6568, -3.9413, 21.7402, 39.7973, -3.9413, 20.0679, 42.6568, -3.9413, 20.0679, 42.6568, -3.9413, 21.7402, 42.6568, -3.9413, 20.0679, 45.5107, -3.9413, 20.0679, 45.5107, -3.9413, 21.7402, 39.7973, -3.9413, 18.3955, 42.6568, -3.9413, 20.0679, 39.7973, -3.9413, 20.0679, 42.6568, -3.9413, 18.3955, 45.5107, -3.9413, 20.0679, 42.6568, -3.9413, 20.0679, 39.7973, -3.9413, 18.3955, 42.6568, -3.9413, 18.3955, 42.6568, -3.9413, 20.0679, 42.6568, -3.9413, 18.3955, 45.5107, -3.9413, 18.3955, 45.5107, -3.9413, 20.0679, 39.7973, -3.9413, 16.7232, 42.6568, -3.9413, 18.3955, 39.7973, -3.9413, 18.3955, 42.6568, -3.9413, 16.7232, 45.5107, -3.9413, 18.3955, 42.6568, -3.9413, 18.3955, 39.7973, -3.9413, 16.7232, 42.6568, -3.9413, 16.7232, 42.6568, -3.9413, 18.3955, 42.6568, -3.9413, 16.7232, 45.5107, -3.9413, 16.7232, 45.5107, -3.9413, 18.3955, 39.7973, -3.9413, 15.0508995, 42.6568, -3.9413, 16.7232, 39.7973, -3.9413, 16.7232, 42.6568, -3.9413, 15.0508995, 45.5107, -3.9413, 16.7232, 42.6568, -3.9413, 16.7232, 39.7973, -3.9413, 15.0508995, 42.6568, -3.9413, 15.0508995, 42.6568, -3.9413, 16.7232, 42.6568, -3.9413, 15.0508995, 45.5107, -3.9413, 15.0508995, 45.5107, -3.9413, 16.7232, 39.7973, -3.9413, 13.3786, 42.6568, -3.9413, 15.0508995, 39.7973, -3.9413, 15.0508995, 42.6568, -3.9413, 13.3786, 45.5107, -3.9413, 15.0508995, 42.6568, -3.9413, 15.0508995, 39.7973, -3.9413, 13.3786, 42.6568, -3.9413, 13.3786, 42.6568, -3.9413, 15.0508995, 42.6568, -3.9413, 13.3786, 45.5107, -3.9413, 13.3786, 45.5107, -3.9413, 15.0508995, 39.7973, -3.9413, 11.7063, 42.6568, -3.9413, 13.3786, 39.7973, -3.9413, 13.3786, 42.6568, -3.9413, 11.7063, 45.5107, -3.9413, 13.3786, 42.6568, -3.9413, 13.3786, 39.7973, -3.9413, 11.7063, 42.6568, -3.9413, 11.7063, 42.6568, -3.9413, 13.3786, 42.6568, -3.9413, 11.7063, 45.5107, -3.9413, 11.7063, 45.5107, -3.9413, 13.3786, 39.7973, -3.9413, 10.033899, 42.6568, -3.9413, 11.7063, 39.7973, -3.9413, 11.7063, 42.6568, -3.9413, 10.033899, 45.5107, -3.9413, 11.7063, 42.6568, -3.9413, 11.7063, 39.7973, -3.9413, 10.033899, 42.6568, -3.9413, 10.033899, 42.6568, -3.9413, 11.7063, 42.6568, -3.9413, 10.033899, 45.5107, -3.9413, 10.033899, 45.5107, -3.9413, 11.7063, 39.7973, -3.9413, 8.3616, 42.6568, -3.9413, 10.033899, 39.7973, -3.9413, 10.033899, 42.6568, -3.9413, 8.3616, 45.5107, -3.9413, 10.033899, 42.6568, -3.9413, 10.033899, 39.7973, -3.9413, 8.3616, 42.6568, -3.9413, 8.3616, 42.6568, -3.9413, 10.033899, 42.6568, -3.9413, 8.3616, 45.5107, -3.9413, 8.3616, 45.5107, -3.9413, 10.033899, 39.7973, -3.9413, 6.6893, 42.6568, -3.9413, 8.3616, 39.7973, -3.9413, 8.3616, 42.6568, -3.9413, 6.6893, 45.5107, -3.9413, 8.3616, 42.6568, -3.9413, 8.3616, 39.7973, -3.9413, 6.6893, 42.6568, -3.9413, 6.6893, 42.6568, -3.9413, 8.3616, 42.6568, -3.9413, 6.6893, 45.5107, -3.9413, 6.6893, 45.5107, -3.9413, 8.3616, 39.7973, -3.9413, 5.0169997, 42.6568, -3.9413, 6.6893, 39.7973, -3.9413, 6.6893, 42.6568, -3.9413, 5.0169997, 45.5107, -3.9413, 6.6893, 42.6568, -3.9413, 6.6893, 39.7973, -3.9413, 5.0169997, 42.6568, -3.9413, 5.0169997, 42.6568, -3.9413, 6.6893, 42.6568, -3.9413, 5.0169997, 45.5107, -3.9413, 5.0169997, 45.5107, -3.9413, 6.6893, 39.7973, -3.9413, 3.3446, 42.6568, -3.9413, 5.0169997, 39.7973, -3.9413, 5.0169997, 42.6568, -3.9413, 3.3446, 45.5107, -3.9413, 5.0169997, 42.6568, -3.9413, 5.0169997, 39.7973, -3.9413, 3.3446, 42.6568, -3.9413, 3.3446, 42.6568, -3.9413, 5.0169997, 42.6568, -3.9413, 3.3446, 45.5107, -3.9413, 3.3446, 45.5107, -3.9413, 5.0169997, 39.7973, -3.9413, 1.6723, 42.6568, -3.9413, 3.3446, 39.7973, -3.9413, 3.3446, 42.6568, -3.9413, 1.6723, 45.5107, -3.9413, 3.3446, 42.6568, -3.9413, 3.3446, 39.7973, -3.9413, 1.6723, 42.6568, -3.9413, 1.6723, 42.6568, -3.9413, 3.3446, 42.6568, -3.9413, 1.6723, 45.5107, -3.9413, 1.6723, 45.5107, -3.9413, 3.3446, 39.7973, -3.9413, 0, 42.6568, -3.9413, 1.6723, 39.7973, -3.9413, 1.6723, 42.6568, -3.9413, 0, 45.5107, -3.9413, 1.6723, 42.6568, -3.9413, 1.6723, 39.7973, -3.9413, 0, 42.6568, -3.9413, 0, 42.6568, -3.9413, 1.6723, 42.6568, -3.9413, 0, 45.5107, -3.9413, 0, 45.5107, -3.9413, 1.6723, 39.7973, -3.9413, -1.6723, 42.6568, -3.9413, 0, 39.7973, -3.9413, 0, 42.6568, -3.9413, -1.6723, 45.5107, -3.9413, 0, 42.6568, -3.9413, 0, 39.7973, -3.9413, -1.6723, 42.6568, -3.9413, -1.6723, 42.6568, -3.9413, 0, 42.6568, -3.9413, -1.6723, 45.5107, -3.9413, -1.6723, 45.5107, -3.9413, 0, 39.7973, -3.9413, -3.3446, 42.6568, -3.9413, -1.6723, 39.7973, -3.9413, -1.6723, 42.6568, -3.9413, -3.3446, 45.5107, -3.9413, -1.6723, 42.6568, -3.9413, -1.6723, 39.7973, -3.9413, -3.3446, 42.6568, -3.9413, -3.3446, 42.6568, -3.9413, -1.6723, 42.6568, -3.9413, -3.3446, 45.5107, -3.9413, -3.3446, 45.5107, -3.9413, -1.6723, 39.7973, -3.9413, -5.0169997, 42.6568, -3.9413, -3.3446, 39.7973, -3.9413, -3.3446, 42.6568, -3.9413, -5.0169997, 45.5107, -3.9413, -3.3446, 42.6568, -3.9413, -3.3446, 39.7973, -3.9413, -5.0169997, 42.6568, -3.9413, -5.0169997, 42.6568, -3.9413, -3.3446, 42.6568, -3.9413, -5.0169997, 45.5107, -3.9413, -5.0169997, 45.5107, -3.9413, -3.3446, 39.7973, -3.9413, -6.6893, 42.6568, -3.9413, -5.0169997, 39.7973, -3.9413, -5.0169997, 42.6568, -3.9413, -6.6893, 45.5107, -3.9413, -5.0169997, 42.6568, -3.9413, -5.0169997, 39.7973, -3.9413, -6.6893, 42.6568, -3.9413, -6.6893, 42.6568, -3.9413, -5.0169997, 42.6568, -3.9413, -6.6893, 45.5107, -3.9413, -6.6893, 45.5107, -3.9413, -5.0169997, 39.7973, -3.9413, -8.3616, 42.6568, -3.9413, -6.6893, 39.7973, -3.9413, -6.6893, 42.6568, -3.9413, -8.3616, 45.5107, -3.9413, -6.6893, 42.6568, -3.9413, -6.6893, 39.7973, -3.9413, -8.3616, 42.6568, -3.9413, -8.3616, 42.6568, -3.9413, -6.6893, 42.6568, -3.9413, -8.3616, 45.5107, -3.9413, -8.3616, 45.5107, -3.9413, -6.6893, 39.7973, -3.9413, -10.033899, 42.6568, -3.9413, -8.3616, 39.7973, -3.9413, -8.3616, 42.6568, -3.9413, -10.033899, 45.5107, -3.9413, -8.3616, 42.6568, -3.9413, -8.3616, 39.7973, -3.9413, -10.033899, 42.6568, -3.9413, -10.033899, 42.6568, -3.9413, -8.3616, 42.6568, -3.9413, -10.033899, 45.5107, -3.9413, -10.033899, 45.5107, -3.9413, -8.3616, 39.7973, -3.9413, -11.7063, 42.6568, -3.9413, -10.033899, 39.7973, -3.9413, -10.033899, 42.6568, -3.9413, -11.7063, 45.5107, -3.9413, -10.033899, 42.6568, -3.9413, -10.033899, 39.7973, -3.9413, -11.7063, 42.6568, -3.9413, -11.7063, 42.6568, -3.9413, -10.033899, 42.6568, -3.9413, -11.7063, 45.5107, -3.9413, -11.7063, 45.5107, -3.9413, -10.033899, 39.7973, -3.9413, -13.3786, 42.6568, -3.9413, -11.7063, 39.7973, -3.9413, -11.7063, 42.6568, -3.9413, -13.3786, 45.5107, -3.9413, -11.7063, 42.6568, -3.9413, -11.7063, 39.7973, -3.9413, -13.3786, 42.6568, -3.9413, -13.3786, 42.6568, -3.9413, -11.7063, 42.6568, -3.9413, -13.3786, 45.5107, -3.9413, -13.3786, 45.5107, -3.9413, -11.7063, 39.7973, -3.9413, -15.0508995, 42.6568, -3.9413, -13.3786, 39.7973, -3.9413, -13.3786, 39.7973, -3.9413, -15.0508995, 42.6568, -3.9413, -15.0508995, 42.6568, -3.9413, -13.3786, 42.6568, -3.9413, -15.0508995, 45.5107, -3.9413, -13.3786, 42.6568, -3.9413, -13.3786, 39.7973, -3.9413, -16.7232, 42.6568, -3.9413, -15.0508995, 39.7973, -3.9413, -15.0508995, 42.6568, -3.9413, -15.0508995, 45.5107, -3.9413, -15.0508995, 45.5107, -3.9413, -13.3786, 45.5107, -3.9413, -15.0508995, 48.3645, -3.9413, -13.3786, 45.5107, -3.9413, -13.3786, 39.7973, -3.9413, -16.7232, 42.6568, -3.9413, -16.7232, 42.6568, -3.9413, -15.0508995, 42.6568, -3.9413, -16.7232, 45.5107, -3.9413, -15.0508995, 42.6568, -3.9413, -15.0508995, 39.7973, -3.9413, -18.3955, 42.6568, -3.9413, -16.7232, 39.7973, -3.9413, -16.7232, 45.5107, -3.9413, -15.0508995, 48.3645, -3.9413, -15.0508995, 48.3645, -3.9413, -13.3786, 39.7973, -3.9413, -18.3955, 42.6568, -3.9413, -18.3955, 42.6568, -3.9413, -16.7232, 42.6568, -3.9413, -16.7232, 45.5107, -3.9413, -16.7232, 45.5107, -3.9413, -15.0508995, 45.5107, -3.9413, -16.7232, 48.3645, -3.9413, -15.0508995, 45.5107, -3.9413, -15.0508995, 42.6568, -3.9413, -18.3955, 45.5107, -3.9413, -16.7232, 42.6568, -3.9413, -16.7232, 39.7973, -3.9413, -20.0679, 42.6568, -3.9413, -18.3955, 39.7973, -3.9413, -18.3955, 45.5107, -3.9413, -16.7232, 48.3645, -3.9413, -16.7232, 48.3645, -3.9413, -15.0508995, 48.3645, -3.9413, -16.7232, 51.2184, -3.9413, -15.0508995, 48.3645, -3.9413, -15.0508995, 42.6568, -3.9413, -18.3955, 45.5107, -3.9413, -18.3955, 45.5107, -3.9413, -16.7232, 45.5107, -3.9413, -18.3955, 48.3645, -3.9413, -16.7232, 45.5107, -3.9413, -16.7232, 39.7973, -3.9413, -20.0679, 42.6568, -3.9413, -20.0679, 42.6568, -3.9413, -18.3955, 42.6568, -3.9413, -20.0679, 45.5107, -3.9413, -18.3955, 42.6568, -3.9413, -18.3955, 39.7973, -3.9413, -21.7402, 42.6568, -3.9413, -20.0679, 39.7973, -3.9413, -20.0679, 48.3645, -3.9413, -16.7232, 51.2184, -3.9413, -16.7232, 51.2184, -3.9413, -15.0508995, 51.2184, -3.9413, -16.7232, 54.072197, -3.9413, -15.0508995, 51.2184, -3.9413, -15.0508995, 51.2184, -3.9413, -16.7232, 54.072197, -3.9413, -16.7232, 54.072197, -3.9413, -15.0508995, 48.3645, -3.9413, -18.3955, 51.2184, -3.9413, -16.7232, 48.3645, -3.9413, -16.7232, 45.5107, -3.9413, -18.3955, 48.3645, -3.9413, -18.3955, 48.3645, -3.9413, -16.7232, 51.2184, -3.9413, -18.3955, 54.072197, -3.9413, -16.7232, 51.2184, -3.9413, -16.7232, 48.3645, -3.9413, -18.3955, 51.2184, -3.9413, -18.3955, 51.2184, -3.9413, -16.7232, 51.2184, -3.9413, -18.3955, 54.072197, -3.9413, -18.3955, 54.072197, -3.9413, -16.7232, 45.5107, -3.9413, -20.0679, 48.3645, -3.9413, -18.3955, 45.5107, -3.9413, -18.3955, 42.6568, -3.9413, -20.0679, 45.5107, -3.9413, -20.0679, 45.5107, -3.9413, -18.3955, 48.3645, -3.9413, -20.0679, 51.2184, -3.9413, -18.3955, 48.3645, -3.9413, -18.3955, 45.5107, -3.9413, -20.0679, 48.3645, -3.9413, -20.0679, 48.3645, -3.9413, -18.3955, 51.2184, -3.9413, -20.0679, 54.072197, -3.9413, -18.3955, 51.2184, -3.9413, -18.3955, 48.3645, -3.9413, -20.0679, 51.2184, -3.9413, -20.0679, 51.2184, -3.9413, -18.3955, 51.2184, -3.9413, -20.0679, 54.072197, -3.9413, -20.0679, 54.072197, -3.9413, -18.3955, 39.7973, -3.9413, -21.7402, 42.6568, -3.9413, -21.7402, 42.6568, -3.9413, -20.0679, 42.6568, -3.9413, -21.7402, 45.5107, -3.9413, -20.0679, 42.6568, -3.9413, -20.0679, 39.7973, -3.9413, -23.412498, 42.6568, -3.9413, -21.7402, 39.7973, -3.9413, -21.7402, 45.5107, -3.9413, -21.7402, 48.3645, -3.9413, -20.0679, 45.5107, -3.9413, -20.0679, 42.6568, -3.9413, -21.7402, 45.5107, -3.9413, -21.7402, 45.5107, -3.9413, -20.0679, 39.7973, -3.9413, -23.412498, 42.6568, -3.9413, -23.412498, 42.6568, -3.9413, -21.7402, 42.6568, -3.9413, -23.412498, 45.5107, -3.9413, -21.7402, 42.6568, -3.9413, -21.7402, 39.7973, -3.9413, -25.084799, 42.6568, -3.9413, -23.412498, 39.7973, -3.9413, -23.412498, 45.5107, -3.9413, -21.7402, 48.3645, -3.9413, -21.7402, 48.3645, -3.9413, -20.0679, 48.3645, -3.9413, -21.7402, 51.2184, -3.9413, -20.0679, 48.3645, -3.9413, -20.0679, 42.6568, -3.9413, -23.412498, 45.5107, -3.9413, -23.412498, 45.5107, -3.9413, -21.7402, 45.5107, -3.9413, -23.412498, 48.3645, -3.9413, -21.7402, 45.5107, -3.9413, -21.7402, 39.7973, -3.9413, -25.084799, 42.6568, -3.9413, -25.084799, 42.6568, -3.9413, -23.412498, 42.6568, -3.9413, -25.084799, 45.5107, -3.9413, -23.412498, 42.6568, -3.9413, -23.412498, 39.7973, -3.9413, -26.7572, 42.6568, -3.9413, -25.084799, 39.7973, -3.9413, -25.084799, 39.7973, -3.9413, -26.7572, 42.6568, -3.9413, -26.7572, 42.6568, -3.9413, -25.084799, 42.6568, -3.9413, -26.7572, 45.5107, -3.9413, -25.084799, 42.6568, -3.9413, -25.084799, 42.6568, -3.9413, -25.084799, 45.5107, -3.9413, -25.084799, 45.5107, -3.9413, -23.412498, 42.6568, -3.9413, -26.7572, 45.5107, -3.9413, -26.7572, 45.5107, -3.9413, -25.084799, 45.5107, -3.9413, -23.412498, 48.3645, -3.9413, -23.412498, 48.3645, -3.9413, -21.7402, 45.5107, -3.9413, -25.084799, 48.3645, -3.9413, -23.412498, 45.5107, -3.9413, -23.412498, 45.5107, -3.9413, -26.7572, 48.3645, -3.9413, -25.084799, 45.5107, -3.9413, -25.084799, 45.5107, -3.9413, -25.084799, 48.3645, -3.9413, -25.084799, 48.3645, -3.9413, -23.412498, 45.5107, -3.9413, -26.7572, 48.3645, -3.9413, -26.7572, 48.3645, -3.9413, -25.084799, 48.3645, -3.9413, -21.7402, 51.2184, -3.9413, -21.7402, 51.2184, -3.9413, -20.0679, 48.3645, -3.9413, -23.412498, 51.2184, -3.9413, -21.7402, 48.3645, -3.9413, -21.7402, 51.2184, -3.9413, -21.7402, 54.072197, -3.9413, -20.0679, 51.2184, -3.9413, -20.0679, 51.2184, -3.9413, -21.7402, 54.072197, -3.9413, -21.7402, 54.072197, -3.9413, -20.0679, 48.3645, -3.9413, -23.412498, 51.2184, -3.9413, -23.412498, 51.2184, -3.9413, -21.7402, 51.2184, -3.9413, -23.412498, 54.072197, -3.9413, -21.7402, 51.2184, -3.9413, -21.7402, 48.3645, -3.9413, -25.084799, 51.2184, -3.9413, -23.412498, 48.3645, -3.9413, -23.412498, 51.2184, -3.9413, -23.412498, 54.072197, -3.9413, -23.412498, 54.072197, -3.9413, -21.7402, 48.3645, -3.9413, -26.7572, 51.2184, -3.9413, -25.084799, 48.3645, -3.9413, -25.084799, 48.3645, -3.9413, -25.084799, 51.2184, -3.9413, -25.084799, 51.2184, -3.9413, -23.412498, 51.2184, -3.9413, -25.084799, 54.072197, -3.9413, -23.412498, 51.2184, -3.9413, -23.412498, 48.3645, -3.9413, -26.7572, 51.2184, -3.9413, -26.7572, 51.2184, -3.9413, -25.084799, 51.2184, -3.9413, -25.084799, 54.072197, -3.9413, -25.084799, 54.072197, -3.9413, -23.412498, 51.2184, -3.9413, -26.7572, 54.072197, -3.9413, -25.084799, 51.2184, -3.9413, -25.084799, 51.2184, -3.9413, -26.7572, 54.072197, -3.9413, -26.7572, 54.072197, -3.9413, -25.084799, 29.679499, -0.6044, -15.0508995, 32.4649, -1.1213, -13.3786, 29.679499, -0.6044, -13.3786, 29.679499, -0.6044, -13.3786, 32.4649, -1.1213, -13.3786, 32.4649, -1.1213, -11.7063, 29.679499, -0.6044, -15.0508995, 32.4649, -1.1213, -15.0508995, 32.4649, -1.1213, -13.3786, 29.679499, -0.6044, -13.3786, 32.4649, -1.1213, -11.7063, 29.679499, -0.6044, -11.7063, 29.679499, -0.6044, -16.7232, 32.4649, -1.1213, -15.0508995, 29.679499, -0.6044, -15.0508995, 29.679499, -0.6044, -11.7063, 32.4649, -1.1213, -11.7063, 32.4649, -1.1213, -10.033899, 29.679499, -0.6044, -16.7232, 32.4649, -1.1213, -16.7232, 32.4649, -1.1213, -15.0508995, 29.679499, -0.6044, -11.7063, 32.4649, -1.1213, -10.033899, 29.679499, -0.6044, -10.033899, 29.679499, -0.6044, -18.3955, 32.4649, -1.1213, -16.7232, 29.679499, -0.6044, -16.7232, 29.679499, -0.6044, -10.033899, 32.4649, -1.1213, -10.033899, 32.4649, -1.1213, -8.3616, 29.679499, -0.6044, -18.3955, 32.4649, -1.1213, -18.3955, 32.4649, -1.1213, -16.7232, 29.679499, -0.6044, -10.033899, 32.4649, -1.1213, -8.3616, 29.679499, -0.6044, -8.3616, 29.679499, -0.6044, -20.0679, 32.4649, -1.1213, -18.3955, 29.679499, -0.6044, -18.3955, 29.679499, -0.6044, -8.3616, 32.4649, -1.1213, -8.3616, 32.4649, -1.1213, -6.6893, 29.679499, -0.6044, -20.0679, 32.4649, -1.1213, -20.0679, 32.4649, -1.1213, -18.3955, 29.679499, -0.6044, -8.3616, 32.4649, -1.1213, -6.6893, 29.679499, -0.6044, -6.6893, 29.679499, -0.6044, -21.7402, 32.4649, -1.1213, -20.0679, 29.679499, -0.6044, -20.0679, 29.679499, -0.6044, -6.6893, 32.4649, -1.1213, -6.6893, 32.4649, -1.1213, -5.0169997, 29.679499, -0.6044, -21.7402, 32.4649, -1.1213, -21.7402, 32.4649, -1.1213, -20.0679, 29.679499, -0.6044, -6.6893, 32.4649, -1.1213, -5.0169997, 29.679499, -0.6044, -5.0169997, 29.679499, -0.6044, -23.412498, 32.4649, -1.1213, -21.7402, 29.679499, -0.6044, -21.7402, 29.679499, -0.6044, -5.0169997, 32.4649, -1.1213, -5.0169997, 32.4649, -1.1213, -3.3446, 29.679499, -0.6044, -23.412498, 32.4649, -1.1213, -23.412498, 32.4649, -1.1213, -21.7402, 29.679499, -0.6044, -5.0169997, 32.4649, -1.1213, -3.3446, 29.679499, -0.6044, -3.3446, 29.679499, -0.6044, -25.084799, 32.4649, -1.1213, -23.412498, 29.679499, -0.6044, -23.412498, 29.679499, -0.6044, -3.3446, 32.4649, -1.1213, -3.3446, 32.4649, -1.1213, -1.6723, 29.679499, -0.6044, -25.084799, 32.4649, -1.1213, -25.084799, 32.4649, -1.1213, -23.412498, 29.679499, -0.6044, -26.7572, 32.4649, -1.1213, -25.084799, 29.679499, -0.6044, -25.084799, 29.679499, -0.6044, -26.7572, 32.4649, -1.1213, -26.7572, 32.4649, -1.1213, -25.084799, 29.679499, -0.6044, -3.3446, 32.4649, -1.1213, -1.6723, 29.679499, -0.6044, -1.6723, 29.679499, -0.6044, -1.6723, 32.4649, -1.1213, -1.6723, 32.4649, -1.1213, 0, 29.679499, -0.6044, -1.6723, 32.4649, -1.1213, 0, 29.679499, -0.6044, 0, 29.679499, -0.6044, 0, 32.4649, -1.1213, 0, 32.4649, -1.1213, 1.6723, 29.679499, -0.6044, 0, 32.4649, -1.1213, 1.6723, 29.679499, -0.6044, 1.6723, 29.679499, -0.6044, 1.6723, 32.4649, -1.1213, 1.6723, 32.4649, -1.1213, 3.3446, 29.679499, -0.6044, 1.6723, 32.4649, -1.1213, 3.3446, 29.679499, -0.6044, 3.3446, 29.679499, -0.6044, 3.3446, 32.4649, -1.1213, 3.3446, 32.4649, -1.1213, 5.0169997, 29.679499, -0.6044, 3.3446, 32.4649, -1.1213, 5.0169997, 29.679499, -0.6044, 5.0169997, 29.679499, -0.6044, 5.0169997, 32.4649, -1.1213, 5.0169997, 32.4649, -1.1213, 6.6893, 29.679499, -0.6044, 5.0169997, 32.4649, -1.1213, 6.6893, 29.679499, -0.6044, 6.6893, 29.679499, -0.6044, 6.6893, 32.4649, -1.1213, 6.6893, 32.4649, -1.1213, 8.3616, 29.679499, -0.6044, 6.6893, 32.4649, -1.1213, 8.3616, 29.679499, -0.6044, 8.3616, 29.679499, -0.6044, 8.3616, 32.4649, -1.1213, 8.3616, 32.4649, -1.1213, 10.033899, 29.679499, -0.6044, 8.3616, 32.4649, -1.1213, 10.033899, 29.679499, -0.6044, 10.033899, 29.679499, -0.6044, 10.033899, 32.4649, -1.1213, 10.033899, 32.4649, -1.1213, 11.7063, 29.679499, -0.6044, 10.033899, 32.4649, -1.1213, 11.7063, 29.679499, -0.6044, 11.7063, 29.679499, -0.6044, 11.7063, 32.4649, -1.1213, 11.7063, 32.4649, -1.1213, 13.3786, 29.679499, -0.6044, 11.7063, 32.4649, -1.1213, 13.3786, 29.679499, -0.6044, 13.3786, 29.679499, -0.6044, 13.3786, 32.4649, -1.1213, 13.3786, 32.4649, -1.1213, 15.0508995, 29.679499, -0.6044, 13.3786, 32.4649, -1.1213, 15.0508995, 29.679499, -0.6044, 15.0508995, 29.679499, -0.6044, 15.0508995, 32.4649, -1.1213, 15.0508995, 32.4649, -1.1213, 16.7232, 29.679499, -0.6044, 15.0508995, 32.4649, -1.1213, 16.7232, 29.679499, -0.6044, 16.7232, 29.679499, -0.6044, 16.7232, 32.4649, -1.1213, 16.7232, 32.4649, -1.1213, 18.3955, 29.679499, -0.6044, 16.7232, 32.4649, -1.1213, 18.3955, 29.679499, -0.6044, 18.3955, 29.679499, -0.6044, 18.3955, 32.4649, -1.1213, 18.3955, 32.4649, -1.1213, 20.0679, 29.679499, -0.6044, 18.3955, 32.4649, -1.1213, 20.0679, 29.679499, -0.6044, 20.0679, 29.679499, -0.6044, 20.0679, 32.4649, -1.1213, 20.0679, 32.4649, -1.1213, 21.7402, 29.679499, -0.6044, 20.0679, 32.4649, -1.1213, 21.7402, 29.679499, -0.6044, 21.7402, 29.679499, -0.6044, 21.7402, 32.4649, -1.1213, 21.7402, 32.4649, -1.1213, 23.412498, 29.679499, -0.6044, 21.7402, 32.4649, -1.1213, 23.412498, 29.679499, -0.6044, 23.412498, 29.679499, -0.6044, 23.412498, 32.4649, -1.1213, 23.412498, 32.4649, -1.1213, 25.084799, 29.679499, -0.6044, 23.412498, 32.4649, -1.1213, 25.084799, 29.679499, -0.6044, 25.084799, 29.679499, -0.6044, 25.084799, 32.4649, -1.1213, 25.084799, 32.4649, -1.1213, 26.7572, 29.679499, -0.6044, 25.084799, 32.4649, -1.1213, 26.7572, 29.679499, -0.6044, 26.7572, 32.4649, -1.1213, -15.0508995, 39.7973, -3.9413, -13.3786, 32.4649, -1.1213, -13.3786, 32.4649, -1.1213, -13.3786, 39.7973, -3.9413, -13.3786, 39.7973, -3.9413, -11.7063, 32.4649, -1.1213, -15.0508995, 39.7973, -3.9413, -15.0508995, 39.7973, -3.9413, -13.3786, 32.4649, -1.1213, -13.3786, 39.7973, -3.9413, -11.7063, 32.4649, -1.1213, -11.7063, 32.4649, -1.1213, -16.7232, 39.7973, -3.9413, -15.0508995, 32.4649, -1.1213, -15.0508995, 32.4649, -1.1213, -11.7063, 39.7973, -3.9413, -11.7063, 39.7973, -3.9413, -10.033899, 32.4649, -1.1213, -16.7232, 39.7973, -3.9413, -16.7232, 39.7973, -3.9413, -15.0508995, 32.4649, -1.1213, -11.7063, 39.7973, -3.9413, -10.033899, 32.4649, -1.1213, -10.033899, 32.4649, -1.1213, -18.3955, 39.7973, -3.9413, -16.7232, 32.4649, -1.1213, -16.7232, 32.4649, -1.1213, -10.033899, 39.7973, -3.9413, -10.033899, 39.7973, -3.9413, -8.3616, 32.4649, -1.1213, -18.3955, 39.7973, -3.9413, -18.3955, 39.7973, -3.9413, -16.7232, 32.4649, -1.1213, -10.033899, 39.7973, -3.9413, -8.3616, 32.4649, -1.1213, -8.3616, 32.4649, -1.1213, -20.0679, 39.7973, -3.9413, -18.3955, 32.4649, -1.1213, -18.3955, 32.4649, -1.1213, -8.3616, 39.7973, -3.9413, -8.3616, 39.7973, -3.9413, -6.6893, 32.4649, -1.1213, -20.0679, 39.7973, -3.9413, -20.0679, 39.7973, -3.9413, -18.3955, 32.4649, -1.1213, -8.3616, 39.7973, -3.9413, -6.6893, 32.4649, -1.1213, -6.6893, 32.4649, -1.1213, -21.7402, 39.7973, -3.9413, -20.0679, 32.4649, -1.1213, -20.0679, 32.4649, -1.1213, -6.6893, 39.7973, -3.9413, -6.6893, 39.7973, -3.9413, -5.0169997, 32.4649, -1.1213, -21.7402, 39.7973, -3.9413, -21.7402, 39.7973, -3.9413, -20.0679, 32.4649, -1.1213, -6.6893, 39.7973, -3.9413, -5.0169997, 32.4649, -1.1213, -5.0169997, 32.4649, -1.1213, -23.412498, 39.7973, -3.9413, -21.7402, 32.4649, -1.1213, -21.7402, 32.4649, -1.1213, -5.0169997, 39.7973, -3.9413, -5.0169997, 39.7973, -3.9413, -3.3446, 32.4649, -1.1213, -23.412498, 39.7973, -3.9413, -23.412498, 39.7973, -3.9413, -21.7402, 32.4649, -1.1213, -5.0169997, 39.7973, -3.9413, -3.3446, 32.4649, -1.1213, -3.3446, 32.4649, -1.1213, -25.084799, 39.7973, -3.9413, -23.412498, 32.4649, -1.1213, -23.412498, 32.4649, -1.1213, -3.3446, 39.7973, -3.9413, -3.3446, 39.7973, -3.9413, -1.6723, 32.4649, -1.1213, -25.084799, 39.7973, -3.9413, -25.084799, 39.7973, -3.9413, -23.412498, 32.4649, -1.1213, -26.7572, 39.7973, -3.9413, -25.084799, 32.4649, -1.1213, -25.084799, 32.4649, -1.1213, -26.7572, 39.7973, -3.9413, -26.7572, 39.7973, -3.9413, -25.084799, 32.4649, -1.1213, -3.3446, 39.7973, -3.9413, -1.6723, 32.4649, -1.1213, -1.6723, 32.4649, -1.1213, -1.6723, 39.7973, -3.9413, -1.6723, 39.7973, -3.9413, 0, 32.4649, -1.1213, -1.6723, 39.7973, -3.9413, 0, 32.4649, -1.1213, 0, 32.4649, -1.1213, 0, 39.7973, -3.9413, 0, 39.7973, -3.9413, 1.6723, 32.4649, -1.1213, 0, 39.7973, -3.9413, 1.6723, 32.4649, -1.1213, 1.6723, 32.4649, -1.1213, 1.6723, 39.7973, -3.9413, 1.6723, 39.7973, -3.9413, 3.3446, 32.4649, -1.1213, 1.6723, 39.7973, -3.9413, 3.3446, 32.4649, -1.1213, 3.3446, 32.4649, -1.1213, 3.3446, 39.7973, -3.9413, 3.3446, 39.7973, -3.9413, 5.0169997, 32.4649, -1.1213, 3.3446, 39.7973, -3.9413, 5.0169997, 32.4649, -1.1213, 5.0169997, 32.4649, -1.1213, 5.0169997, 39.7973, -3.9413, 5.0169997, 39.7973, -3.9413, 6.6893, 32.4649, -1.1213, 5.0169997, 39.7973, -3.9413, 6.6893, 32.4649, -1.1213, 6.6893, 32.4649, -1.1213, 6.6893, 39.7973, -3.9413, 6.6893, 39.7973, -3.9413, 8.3616, 32.4649, -1.1213, 6.6893, 39.7973, -3.9413, 8.3616, 32.4649, -1.1213, 8.3616, 32.4649, -1.1213, 8.3616, 39.7973, -3.9413, 8.3616, 39.7973, -3.9413, 10.033899, 32.4649, -1.1213, 8.3616, 39.7973, -3.9413, 10.033899, 32.4649, -1.1213, 10.033899, 32.4649, -1.1213, 10.033899, 39.7973, -3.9413, 10.033899, 39.7973, -3.9413, 11.7063, 32.4649, -1.1213, 10.033899, 39.7973, -3.9413, 11.7063, 32.4649, -1.1213, 11.7063, 32.4649, -1.1213, 11.7063, 39.7973, -3.9413, 11.7063, 39.7973, -3.9413, 13.3786, 32.4649, -1.1213, 11.7063, 39.7973, -3.9413, 13.3786, 32.4649, -1.1213, 13.3786, 32.4649, -1.1213, 13.3786, 39.7973, -3.9413, 13.3786, 39.7973, -3.9413, 15.0508995, 32.4649, -1.1213, 13.3786, 39.7973, -3.9413, 15.0508995, 32.4649, -1.1213, 15.0508995, 32.4649, -1.1213, 15.0508995, 39.7973, -3.9413, 15.0508995, 39.7973, -3.9413, 16.7232, 32.4649, -1.1213, 15.0508995, 39.7973, -3.9413, 16.7232, 32.4649, -1.1213, 16.7232, 32.4649, -1.1213, 16.7232, 39.7973, -3.9413, 16.7232, 39.7973, -3.9413, 18.3955, 32.4649, -1.1213, 16.7232, 39.7973, -3.9413, 18.3955, 32.4649, -1.1213, 18.3955, 32.4649, -1.1213, 18.3955, 39.7973, -3.9413, 18.3955, 39.7973, -3.9413, 20.0679, 32.4649, -1.1213, 18.3955, 39.7973, -3.9413, 20.0679, 32.4649, -1.1213, 20.0679, 32.4649, -1.1213, 20.0679, 39.7973, -3.9413, 20.0679, 39.7973, -3.9413, 21.7402, 32.4649, -1.1213, 20.0679, 39.7973, -3.9413, 21.7402, 32.4649, -1.1213, 21.7402, 32.4649, -1.1213, 21.7402, 39.7973, -3.9413, 21.7402, 39.7973, -3.9413, 23.412498, 32.4649, -1.1213, 21.7402, 39.7973, -3.9413, 23.412498, 32.4649, -1.1213, 23.412498, 32.4649, -1.1213, 23.412498, 39.7973, -3.9413, 23.412498, 39.7973, -3.9413, 25.084799, 32.4649, -1.1213, 23.412498, 39.7973, -3.9413, 25.084799, 32.4649, -1.1213, 25.084799, 32.4649, -1.1213, 25.084799, 39.7973, -3.9413, 25.084799, 39.7973, -3.9413, 26.7572, 32.4649, -1.1213, 25.084799, 39.7973, -3.9413, 26.7572, 32.4649, -1.1213, 26.7572, 26.7572, -0.2593, 16.7232, 29.679499, -0.6044, 18.3955, 26.7572, -0.2593, 18.3955, 26.7572, -0.2593, 18.3955, 29.679499, -0.6044, 18.3955, 29.679499, -0.6044, 20.0679, 26.7572, -0.2593, 16.7232, 29.679499, -0.6044, 16.7232, 29.679499, -0.6044, 18.3955, 26.7572, -0.2593, 18.3955, 29.679499, -0.6044, 20.0679, 26.7572, -0.2593, 20.0679, 26.7572, -0.2593, 15.0508995, 29.679499, -0.6044, 16.7232, 26.7572, -0.2593, 16.7232, 26.7572, -0.2593, 20.0679, 29.679499, -0.6044, 20.0679, 29.679499, -0.6044, 21.7402, 26.7572, -0.2593, 15.0508995, 29.679499, -0.6044, 15.0508995, 29.679499, -0.6044, 16.7232, 26.7572, -0.2593, 20.0679, 29.679499, -0.6044, 21.7402, 26.7572, -0.2593, 21.7402, 26.7572, -0.2593, 13.3786, 29.679499, -0.6044, 15.0508995, 26.7572, -0.2593, 15.0508995, 26.7572, -0.2593, 21.7402, 29.679499, -0.6044, 21.7402, 29.679499, -0.6044, 23.412498, 26.7572, -0.2593, 13.3786, 29.679499, -0.6044, 13.3786, 29.679499, -0.6044, 15.0508995, 26.7572, -0.2593, 21.7402, 29.679499, -0.6044, 23.412498, 26.7572, -0.2593, 23.412498, 26.7572, -0.2593, 11.7063, 29.679499, -0.6044, 13.3786, 26.7572, -0.2593, 13.3786, 26.7572, -0.2593, 23.412498, 29.679499, -0.6044, 23.412498, 29.679499, -0.6044, 25.084799, 26.7572, -0.2593, 11.7063, 29.679499, -0.6044, 11.7063, 29.679499, -0.6044, 13.3786, 26.7572, -0.2593, 23.412498, 29.679499, -0.6044, 25.084799, 26.7572, -0.2593, 25.084799, 26.7572, -0.2593, 25.084799, 29.679499, -0.6044, 25.084799, 29.679499, -0.6044, 26.7572, 26.7572, -0.2593, 25.084799, 29.679499, -0.6044, 26.7572, 26.7572, -0.2593, 26.7572, 26.7572, -0.2593, 10.033899, 29.679499, -0.6044, 11.7063, 26.7572, -0.2593, 11.7063, 26.7572, -0.2593, 10.033899, 29.679499, -0.6044, 10.033899, 29.679499, -0.6044, 11.7063, 26.7572, -0.2593, 8.3616, 29.679499, -0.6044, 10.033899, 26.7572, -0.2593, 10.033899, 26.7572, -0.2593, 8.3616, 29.679499, -0.6044, 8.3616, 29.679499, -0.6044, 10.033899, 26.7572, -0.2593, 6.6893, 29.679499, -0.6044, 8.3616, 26.7572, -0.2593, 8.3616, 26.7572, -0.2593, 6.6893, 29.679499, -0.6044, 6.6893, 29.679499, -0.6044, 8.3616, 26.7572, -0.2593, 5.0169997, 29.679499, -0.6044, 6.6893, 26.7572, -0.2593, 6.6893, 26.7572, -0.2593, 5.0169997, 29.679499, -0.6044, 5.0169997, 29.679499, -0.6044, 6.6893, 26.7572, -0.2593, 3.3446, 29.679499, -0.6044, 5.0169997, 26.7572, -0.2593, 5.0169997, 26.7572, -0.2593, 3.3446, 29.679499, -0.6044, 3.3446, 29.679499, -0.6044, 5.0169997, 26.7572, -0.2593, 1.6723, 29.679499, -0.6044, 3.3446, 26.7572, -0.2593, 3.3446, 26.7572, -0.2593, 1.6723, 29.679499, -0.6044, 1.6723, 29.679499, -0.6044, 3.3446, 26.7572, -0.2593, 0, 29.679499, -0.6044, 1.6723, 26.7572, -0.2593, 1.6723, 26.7572, -0.2593, 0, 29.679499, -0.6044, 0, 29.679499, -0.6044, 1.6723, 26.7572, -0.2593, -1.6723, 29.679499, -0.6044, 0, 26.7572, -0.2593, 0, 26.7572, -0.2593, -1.6723, 29.679499, -0.6044, -1.6723, 29.679499, -0.6044, 0, 26.7572, -0.2593, -3.3446, 29.679499, -0.6044, -1.6723, 26.7572, -0.2593, -1.6723, 26.7572, -0.2593, -3.3446, 29.679499, -0.6044, -3.3446, 29.679499, -0.6044, -1.6723, 26.7572, -0.2593, -5.0169997, 29.679499, -0.6044, -3.3446, 26.7572, -0.2593, -3.3446, 26.7572, -0.2593, -5.0169997, 29.679499, -0.6044, -5.0169997, 29.679499, -0.6044, -3.3446, 26.7572, -0.2593, -6.6893, 29.679499, -0.6044, -5.0169997, 26.7572, -0.2593, -5.0169997, 26.7572, -0.2593, -6.6893, 29.679499, -0.6044, -6.6893, 29.679499, -0.6044, -5.0169997, 26.7572, -0.2593, -8.3616, 29.679499, -0.6044, -6.6893, 26.7572, -0.2593, -6.6893, 26.7572, -0.2593, -8.3616, 29.679499, -0.6044, -8.3616, 29.679499, -0.6044, -6.6893, 26.7572, -0.2593, -10.033899, 29.679499, -0.6044, -8.3616, 26.7572, -0.2593, -8.3616, 26.7572, -0.2593, -10.033899, 29.679499, -0.6044, -10.033899, 29.679499, -0.6044, -8.3616, 26.7572, -0.2593, -11.7063, 29.679499, -0.6044, -10.033899, 26.7572, -0.2593, -10.033899, 26.7572, -0.2593, -11.7063, 29.679499, -0.6044, -11.7063, 29.679499, -0.6044, -10.033899, 26.7572, -0.2593, -13.3786, 29.679499, -0.6044, -11.7063, 26.7572, -0.2593, -11.7063, 26.7572, -0.2593, -13.3786, 29.679499, -0.6044, -13.3786, 29.679499, -0.6044, -11.7063, 26.7572, -0.2593, -15.0508995, 29.679499, -0.6044, -13.3786, 26.7572, -0.2593, -13.3786, 26.7572, -0.2593, -15.0508995, 29.679499, -0.6044, -15.0508995, 29.679499, -0.6044, -13.3786, 26.7572, -0.2593, -16.7232, 29.679499, -0.6044, -15.0508995, 26.7572, -0.2593, -15.0508995, 26.7572, -0.2593, -16.7232, 29.679499, -0.6044, -16.7232, 29.679499, -0.6044, -15.0508995, 26.7572, -0.2593, -18.3955, 29.679499, -0.6044, -16.7232, 26.7572, -0.2593, -16.7232, 26.7572, -0.2593, -18.3955, 29.679499, -0.6044, -18.3955, 29.679499, -0.6044, -16.7232, 26.7572, -0.2593, -20.0679, 29.679499, -0.6044, -18.3955, 26.7572, -0.2593, -18.3955, 26.7572, -0.2593, -20.0679, 29.679499, -0.6044, -20.0679, 29.679499, -0.6044, -18.3955, 26.7572, -0.2593, -21.7402, 29.679499, -0.6044, -20.0679, 26.7572, -0.2593, -20.0679, 26.7572, -0.2593, -21.7402, 29.679499, -0.6044, -21.7402, 29.679499, -0.6044, -20.0679, 26.7572, -0.2593, -23.412498, 29.679499, -0.6044, -21.7402, 26.7572, -0.2593, -21.7402, 26.7572, -0.2593, -23.412498, 29.679499, -0.6044, -23.412498, 29.679499, -0.6044, -21.7402, 26.7572, -0.2593, -25.084799, 29.679499, -0.6044, -23.412498, 26.7572, -0.2593, -23.412498, 26.7572, -0.2593, -25.084799, 29.679499, -0.6044, -25.084799, 29.679499, -0.6044, -23.412498, 26.7572, -0.2593, -26.7572, 29.679499, -0.6044, -25.084799, 26.7572, -0.2593, -25.084799, 26.7572, -0.2593, -26.7572, 29.679499, -0.6044, -26.7572, 29.679499, -0.6044, -25.084799) @@ -82,6 +85,8 @@ script = ExtResource("1_region") region_id = &"starter_island" fishable_water_root = NodePath("WaterBodies") water_recovery_root = NodePath("WaterBodies") +diggable_area_root = NodePath("DiggableAreas") +gatherable_anchor_root = NodePath("GatherableAnchors") [node name="Terrain" type="Node3D" parent="." unique_id=1130051521] @@ -92,6 +97,48 @@ water_recovery_root = NodePath("WaterBodies") [node name="Shape" type="CollisionShape3D" parent="Terrain/Collision" unique_id=674742331] shape = SubResource("ConcavePolygonShape3D_4e4k5") +[node name="DiggableAreas" type="Node3D" parent="."] + +[node name="Beach" type="Node3D" parent="DiggableAreas"] +script = ExtResource("23_diggable_area") +area_id = &"starter_beach" +terrain_source = NodePath("../../Terrain/Visual") +surface_materials = Array[StringName]([&"sand"]) +generation_bounds = Rect2(-42, -50, 108, 100) +minimum_global_y = -0.44 +maximum_global_y = 1.25 +minimum_up_dot = 0.72 + +[node name="GatherableAnchors" type="Node3D" parent="."] + +[node name="ReachableTreeTrunks" type="Node3D" parent="GatherableAnchors"] +script = ExtResource("24_gatherable_anchors") +anchor_set_id = &"starter_reachable_tree_trunks" + +[node name="TreeNorthwest" type="Marker3D" parent="GatherableAnchors/ReachableTreeTrunks"] +position = Vector3(6.1501, 4.3708, 17.0759) + +[node name="TreeNortheast" type="Marker3D" parent="GatherableAnchors/ReachableTreeTrunks"] +position = Vector3(9.3435, 4.3689, 13.7807) + +[node name="TreeEast" type="Marker3D" parent="GatherableAnchors/ReachableTreeTrunks"] +position = Vector3(19.0088, 4.3641, -6.7882) + +[node name="TreeSoutheast" type="Marker3D" parent="GatherableAnchors/ReachableTreeTrunks"] +position = Vector3(14.977, 4.4039, -14.0058) + +[node name="TreeSouthwest" type="Marker3D" parent="GatherableAnchors/ReachableTreeTrunks"] +position = Vector3(-12.6959, 4.404, -13.6249) + +[node name="TreeWestSouth" type="Marker3D" parent="GatherableAnchors/ReachableTreeTrunks"] +position = Vector3(-7.6129, 4.3886, -11.2386) + +[node name="TreeWest" type="Marker3D" parent="GatherableAnchors/ReachableTreeTrunks"] +position = Vector3(-19.4381, 4.4016, 0.5559) + +[node name="TreeWestNorth" type="Marker3D" parent="GatherableAnchors/ReachableTreeTrunks"] +position = Vector3(-17.4368, 4.3886, 1.3864) + [node name="ShorelineRibbonBaker" type="Node" parent="."] script = ExtResource("21_shoreline_baker") water_bodies = Array[ExtResource("22_shoreline_config")]([SubResource("ShorelineRibbonConfigPond"), SubResource("ShorelineRibbonConfigOcean")]) @@ -233,3 +280,6 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 3.95, 13) [node name="FishingShopWorld" parent="Interactables" unique_id=1255373524 instance=ExtResource("7_shop")] transform = Transform3D(-0.9976046, 0, -0.06917416, 0, 1, 0, 0.06917416, 0, -0.9976046, 5.976795, 3.42, 4.299163) + +[node name="PlayerStorageBox" parent="Interactables" instance=ExtResource("8_storage")] +position = Vector3(-3.25, 3.42, 11.25) diff --git a/world/regions/world_region.gd b/world/regions/world_region.gd index a8c560c..372165c 100644 --- a/world/regions/world_region.gd +++ b/world/regions/world_region.gd @@ -6,6 +6,8 @@ extends Node3D @export var fishable_water_root: NodePath = ^"FishingWater" @export var water_recovery_root: NodePath = ^"WaterRecovery" @export var safe_respawns_root: NodePath = ^"SafeRespawns" +@export var diggable_area_root: NodePath = ^"DiggableAreas" +@export var gatherable_anchor_root: NodePath = ^"GatherableAnchors" func get_fishable_water_regions() -> Array[FishableWaterRegion]: @@ -38,6 +40,44 @@ func get_safe_respawn_points() -> Array[SafeRespawnPoint]: return points +func get_diggable_areas() -> Array[DiggableArea3D]: + var areas: Array[DiggableArea3D] = [] + var root: Node = get_node_or_null(diggable_area_root) + if root == null: + return areas + _collect_diggable_areas(root, areas) + return areas + + +func get_diggable_area(area_id: StringName) -> DiggableArea3D: + if area_id.is_empty(): + return null + for area: DiggableArea3D in get_diggable_areas(): + if area.area_id == area_id: + return area + return null + + +func get_gatherable_anchor_sets() -> Array[GatherableAnchorSet3D]: + var anchor_sets: Array[GatherableAnchorSet3D] = [] + var root: Node = get_node_or_null(gatherable_anchor_root) + if root == null: + return anchor_sets + _collect_gatherable_anchor_sets(root, anchor_sets) + return anchor_sets + + +func get_gatherable_anchor_set( + anchor_set_id: StringName, +) -> GatherableAnchorSet3D: + if anchor_set_id.is_empty(): + return null + for anchor_set: GatherableAnchorSet3D in get_gatherable_anchor_sets(): + if anchor_set.anchor_set_id == anchor_set_id: + return anchor_set + return null + + func _collect_fishable_water_regions( root: Node, regions: Array[FishableWaterRegion], @@ -58,3 +98,25 @@ func _collect_water_recovery_triggers( if trigger != null: triggers.append(trigger) _collect_water_recovery_triggers(child, triggers) + + +func _collect_diggable_areas( + root: Node, + areas: Array[DiggableArea3D], +) -> void: + for child: Node in root.get_children(): + var area := child as DiggableArea3D + if area != null: + areas.append(area) + _collect_diggable_areas(child, areas) + + +func _collect_gatherable_anchor_sets( + root: Node, + anchor_sets: Array[GatherableAnchorSet3D], +) -> void: + for child: Node in root.get_children(): + var anchor_set := child as GatherableAnchorSet3D + if anchor_set != null: + anchor_sets.append(anchor_set) + _collect_gatherable_anchor_sets(child, anchor_sets) diff --git a/world/test_world.gd b/world/test_world.gd index e40c3d3..ea46d7d 100644 --- a/world/test_world.gd +++ b/world/test_world.gd @@ -4,6 +4,9 @@ extends Node3D const FishingShopInteractionType = preload( "res://world/fishing_shop_interaction.gd" ) +const PlayerStorageInteractionType = preload( + "res://world/player_storage_interaction.gd" +) @onready var _regions_root: Node3D = $Regions @onready var _starter_island: StarterIslandRegion = ( @@ -15,6 +18,9 @@ const FishingShopInteractionType = preload( @onready var _fishing_shop: FishingShopInteractionType = ( _starter_island.get_fishing_shop() ) +@onready var _player_storage: PlayerStorageInteractionType = ( + _starter_island.get_player_storage() +) @onready var _world_environment: WorldEnvironment = $Environment/WorldEnvironment @onready var _sun: DirectionalLight3D = $Environment/Sun @@ -38,6 +44,10 @@ func get_fishing_shop() -> FishingShopInteractionType: return _fishing_shop +func get_player_storage() -> PlayerStorageInteractionType: + return _player_storage + + func get_player_spawn_transform() -> Transform3D: return _starter_island.get_player_spawn_transform() @@ -75,6 +85,28 @@ func get_spawn_surface_triangles( ) +func get_diggable_area_triangles( + area_id: StringName, +) -> Array[PackedVector3Array]: + for region: WorldRegion in _get_regions(): + var area: DiggableArea3D = region.get_diggable_area(area_id) + if area != null: + return area.get_surface_triangles() + return [] + + +func get_gatherable_spawn_positions( + anchor_set_id: StringName, +) -> PackedVector3Array: + for region: WorldRegion in _get_regions(): + var anchor_set: GatherableAnchorSet3D = ( + region.get_gatherable_anchor_set(anchor_set_id) + ) + if anchor_set != null: + return anchor_set.get_spawn_positions() + return PackedVector3Array() + + func _get_regions() -> Array[WorldRegion]: var regions: Array[WorldRegion] = [] for child: Node in _regions_root.get_children(): diff --git a/world/world_time_service.gd b/world/world_time_service.gd index c3186b4..a710c1d 100644 --- a/world/world_time_service.gd +++ b/world/world_time_service.gd @@ -14,8 +14,9 @@ enum Phase { } const HOURS_PER_DAY: float = 24.0 -const REAL_SECONDS_PER_CYCLE: float = 60.0 * 60.0 +const REAL_SECONDS_PER_CYCLE: float = 24.0 * 60.0 * 60.0 const HOURS_PER_REAL_SECOND: float = HOURS_PER_DAY / REAL_SECONDS_PER_CYCLE +const SYSTEM_CLOCK_SAMPLE_INTERVAL_SECONDS: float = 0.25 const DAY_START_HOUR: float = 8.0 const NIGHT_START_HOUR: float = 20.0 const TRANSITION_HALF_HOURS: float = 0.5 @@ -27,10 +28,15 @@ const DEFAULT_START_HOUR: float = DAY_START_HOUR var _time_hours: float = DEFAULT_START_HOUR var _persistent_time_hours: float = DEFAULT_START_HOUR -var _persistence_tracking_enabled: bool = false var _running: bool = false +var _system_clock_authority: bool = false +var _editor_time_override: bool = false var _phase: Phase = Phase.DAWN var _last_emitted_clock_minute: int = floori(DEFAULT_START_HOUR * 60.0) +var _system_clock_sample_elapsed: float = 0.0 +var _last_system_unix_seconds: float = -1.0 +var _calendar_date_id: String = "" +var _local_datetime: Dictionary = {} func _ready() -> void: @@ -38,17 +44,54 @@ func _ready() -> void: func _process(delta: float) -> void: + if not _running: + return + if _system_clock_authority and not _editor_time_override: + _system_clock_sample_elapsed += delta + if _system_clock_sample_elapsed >= SYSTEM_CLOCK_SAMPLE_INTERVAL_SECONDS: + _system_clock_sample_elapsed = 0.0 + _sample_system_clock(false) + return advance_time(delta) -func begin_session(start_hour: float = DEFAULT_START_HOUR) -> void: +func begin_authoritative_session() -> void: _running = true + _system_clock_authority = true + _editor_time_override = false + _system_clock_sample_elapsed = 0.0 + _last_system_unix_seconds = -1.0 set_process(true) + _sample_system_clock(true) + + +func begin_remote_session() -> void: + _running = true + _system_clock_authority = false + _editor_time_override = false + _system_clock_sample_elapsed = 0.0 + _last_system_unix_seconds = -1.0 + set_process(true) + + +func begin_test_session(start_hour: float = DEFAULT_START_HOUR) -> void: + _running = true + _system_clock_authority = false + _editor_time_override = false + _system_clock_sample_elapsed = 0.0 + _last_system_unix_seconds = -1.0 + set_process(false) _set_time_hours(start_hour, true) func end_session() -> void: _running = false + _system_clock_authority = false + _editor_time_override = false + _system_clock_sample_elapsed = 0.0 + _last_system_unix_seconds = -1.0 + _calendar_date_id = "" + _local_datetime = {} set_process(false) _set_time_hours(DEFAULT_START_HOUR, true) @@ -56,11 +99,8 @@ func end_session() -> void: func advance_time(real_seconds: float) -> void: if not _running or real_seconds <= 0.0: return - var advanced_hours := real_seconds * HOURS_PER_REAL_SECOND - _set_time_hours( - _time_hours + advanced_hours, - false, - ) + var advanced_hours: float = real_seconds * HOURS_PER_REAL_SECOND + _set_time_hours(_time_hours + advanced_hours, false) natural_time_advanced.emit(advanced_hours) @@ -71,23 +111,39 @@ func synchronize_time(authoritative_time_hours: float) -> void: func set_authoritative_time(time_hours: float) -> bool: + # This hook intentionally exists only in the Godot editor/test runtime. + # Exported clients and dedicated servers always follow their authoritative + # machine clock and cannot expose mutable time commands to players. if ( - not is_finite(time_hours) + not OS.has_feature("editor") + or not is_finite(time_hours) or time_hours < 0.0 or time_hours >= HOURS_PER_DAY ): return false + _editor_time_override = true _set_time_hours(time_hours, true) authoritative_time_set.emit(_time_hours) return true -func set_persistence_tracking_enabled(enabled: bool) -> void: - if _persistence_tracking_enabled == enabled: +func clear_editor_time_override() -> void: + if not _editor_time_override: return - if _persistence_tracking_enabled: - _persistent_time_hours = _time_hours - _persistence_tracking_enabled = enabled + _editor_time_override = false + _last_system_unix_seconds = -1.0 + if _system_clock_authority: + _sample_system_clock(true) + + +func is_using_system_clock() -> bool: + return _system_clock_authority and not _editor_time_override + + +func set_persistence_tracking_enabled(_enabled: bool) -> void: + # Kept as a compatibility seam for schema-4 saves. Real-time sessions never + # use saved time as an authority. + pass func restore_persistent_time_hours(time_hours: float) -> bool: @@ -98,19 +154,27 @@ func restore_persistent_time_hours(time_hours: float) -> bool: ): return false _persistent_time_hours = time_hours - if _persistence_tracking_enabled: - _set_time_hours(_persistent_time_hours, true) return true func get_persistent_time_hours() -> float: - return _persistent_time_hours + return _time_hours if _running else _persistent_time_hours func get_time_hours() -> float: return _time_hours +func get_calendar_date_id() -> String: + return _calendar_date_id + + +func get_calendar_cycle_id(rollover_hour: float) -> String: + if _local_datetime.is_empty(): + return "" + return calendar_cycle_id_from_datetime(_local_datetime, rollover_hour) + + func get_phase() -> Phase: return _phase @@ -127,6 +191,40 @@ func get_clock_text() -> String: return format_clock_time(_time_hours) +static func time_hours_from_datetime(datetime: Dictionary) -> float: + var hour: int = clampi(int(datetime.get("hour", 0)), 0, 23) + var minute: int = clampi(int(datetime.get("minute", 0)), 0, 59) + var second: int = clampi(int(datetime.get("second", 0)), 0, 59) + return ( + float(hour) + + float(minute) / 60.0 + + float(second) / (60.0 * 60.0) + ) + + +static func date_id_from_datetime(datetime: Dictionary) -> String: + return "%04d-%02d-%02d" % [ + int(datetime.get("year", 0)), + int(datetime.get("month", 0)), + int(datetime.get("day", 0)), + ] + + +static func calendar_cycle_id_from_datetime( + datetime: Dictionary, + rollover_hour: float, +) -> String: + var cycle_datetime: Dictionary = datetime.duplicate(true) + if time_hours_from_datetime(datetime) < rollover_hour: + var previous_day_unix: int = ( + Time.get_unix_time_from_datetime_dict(datetime) - 24 * 60 * 60 + ) + cycle_datetime = Time.get_datetime_dict_from_unix_time( + previous_day_unix + ) + return date_id_from_datetime(cycle_datetime) + + static func phase_for_hour(time_hours: float) -> Phase: var hour: float = _normalized_hour(time_hours) if hour >= DAWN_START_HOUR and hour < DAWN_END_HOUR: @@ -153,6 +251,21 @@ static func format_clock_time(time_hours: float) -> String: ] +func _sample_system_clock(force_emit: bool) -> void: + var datetime: Dictionary = Time.get_datetime_dict_from_system(false) + var unix_seconds: float = Time.get_unix_time_from_system() + var previous_unix_seconds: float = _last_system_unix_seconds + _last_system_unix_seconds = unix_seconds + _local_datetime = datetime.duplicate(true) + _calendar_date_id = date_id_from_datetime(datetime) + _set_time_hours(time_hours_from_datetime(datetime), force_emit) + if previous_unix_seconds < 0.0: + return + var advanced_seconds: float = unix_seconds - previous_unix_seconds + if advanced_seconds > 0.0: + natural_time_advanced.emit(advanced_seconds / 3600.0) + + func _set_time_hours(time_hours: float, force_emit: bool) -> void: var normalized: float = _normalized_hour(time_hours) var next_phase: Phase = phase_for_hour(normalized) @@ -163,8 +276,6 @@ func _set_time_hours(time_hours: float, force_emit: bool) -> void: ) _time_hours = normalized _phase = next_phase - if _persistence_tracking_enabled: - _persistent_time_hours = normalized if phase_was_changed: phase_changed.emit(_phase) if force_emit or clock_minute_changed or phase_was_changed: diff --git a/world/world_time_visual_controller.gd b/world/world_time_visual_controller.gd index 4beee85..fd743ed 100644 --- a/world/world_time_visual_controller.gd +++ b/world/world_time_visual_controller.gd @@ -66,6 +66,8 @@ const WARM_SUN := Color(1.0, 0.58, 0.30) const WARM_WATER_TINT := Color(0.78, 0.62, 0.58) const WARM_SUN_DISC := Color(1.0, 0.45, 0.16) const MOON_DISC := Color(0.78, 0.88, 1.0) +const STAR_COLOR := Color(0.72, 0.82, 0.96) +const STAR_STRENGTH: float = 0.72 const DAY_CLOUD_LIGHT := Color(0.70, 0.76, 0.77) const DAY_CLOUD_SHADOW := Color(0.31, 0.38, 0.40) const NIGHT_CLOUD_LIGHT := Color(0.18, 0.22, 0.30) @@ -425,6 +427,12 @@ func _apply_time(time_hours: float) -> void: "moon_visibility", (1.0 - daylight) * _weather_value(1.0, 0.72, 0.36, 1.0), ) + _sky_material.set_shader_parameter("star_color", STAR_COLOR) + _sky_material.set_shader_parameter("star_strength", STAR_STRENGTH) + _sky_material.set_shader_parameter( + "star_visibility", + (1.0 - daylight) * _weather_value(1.0, 0.18, 0.03, 0.0), + ) _sun.light_color = _blended_color( NIGHT_SUN, DAY_SUN, WARM_SUN, daylight, warmth ) diff --git a/world/world_weather_service.gd b/world/world_weather_service.gd index 4fce78e..9c8d995 100644 --- a/world/world_weather_service.gd +++ b/world/world_weather_service.gd @@ -11,13 +11,22 @@ enum Weather { } const DEFAULT_WEATHER: Weather = Weather.SUNNY -const SUNNY_DURATION_RANGE := Vector2(480.0, 900.0) -const CLOUDY_DURATION_RANGE := Vector2(300.0, 720.0) -const RAINY_DURATION_RANGE := Vector2(300.0, 600.0) -const FOGGY_DURATION_RANGE := Vector2(300.0, 600.0) -const MAX_PERSISTED_SECONDS: float = 1800.0 -const DAILY_PLAN_SEGMENT_HOURS: float = 2.0 -const DAILY_PLAN_SEGMENT_COUNT: int = 12 +const WEATHER_PERIOD_SECONDS: float = 60.0 * 60.0 +const SUNNY_DURATION_RANGE := Vector2( + WEATHER_PERIOD_SECONDS, WEATHER_PERIOD_SECONDS +) +const CLOUDY_DURATION_RANGE := Vector2( + WEATHER_PERIOD_SECONDS, WEATHER_PERIOD_SECONDS +) +const RAINY_DURATION_RANGE := Vector2( + WEATHER_PERIOD_SECONDS, WEATHER_PERIOD_SECONDS +) +const FOGGY_DURATION_RANGE := Vector2( + WEATHER_PERIOD_SECONDS, WEATHER_PERIOD_SECONDS +) +const MAX_PERSISTED_SECONDS: float = WEATHER_PERIOD_SECONDS +const DAILY_PLAN_SEGMENT_HOURS: float = 1.0 +const DAILY_PLAN_SEGMENT_COUNT: int = 24 var _weather: Weather = DEFAULT_WEATHER var _seconds_remaining: float = SUNNY_DURATION_RANGE.x @@ -48,18 +57,11 @@ func begin_authoritative_session(seed_value: int) -> void: set_process(true) _clear_manual_override() if _daily_schedule.is_empty() or _world_time == null: - if _has_persistent_state: - _set_weather( - _persistent_weather, - _persistent_seconds_remaining, - true, - ) - else: - _set_weather( - DEFAULT_WEATHER, - _roll_duration(DEFAULT_WEATHER), - true, - ) + _set_weather( + DEFAULT_WEATHER, + _roll_duration(DEFAULT_WEATHER), + true, + ) else: _update_scheduled_weather(true) @@ -104,7 +106,12 @@ func apply_authoritative_snapshot( func set_authoritative_weather(weather: Weather) -> bool: - if not _running_authority or not is_valid_weather(int(weather)): + # Manual weather is an editor/test hook, not a player or operator command. + if ( + not OS.has_feature("editor") + or not _running_authority + or not is_valid_weather(int(weather)) + ): return false var duration: float = _roll_duration(weather) if not _daily_schedule.is_empty() and _world_time != null: