From e5067bb0e29c0b122a1634b1b27c5ff4756438a6 Mon Sep 17 00:00:00 2001 From: Tomasz Boruc Date: Sat, 10 Oct 2026 14:13:56 +0200 Subject: [PATCH] =?UTF-8?q?System=20item=C3=B3w?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- documentation.txt | 92 +++++++++++++++ resources/items/affix_database.tres | 17 +++ resources/items/affixes/credits_percent.tres | 17 +++ resources/items/affixes/damage_percent.tres | 17 +++ .../items/affixes/energy_regeneration.tres | 17 +++ .../items/affixes/experience_percent.tres | 17 +++ resources/items/affixes/hull_percent.tres | 17 +++ resources/items/affixes/shield_percent.tres | 17 +++ .../items/affixes/shield_regeneration.tres | 17 +++ resources/items/affixes/speed_percent.tres | 17 +++ resources/items/beam_laser.tres | 4 + resources/items/machine_gun.tres | 4 + resources/items/missile_launcher.tres | 4 + resources/items/plasma_cannon.tres | 4 + scripts/game/enemy_ship.gd | 17 +++ scripts/game/game_state.gd | 1 + scripts/game/ship_controller.gd | 12 +- scripts/game/weapon_controller.gd | 20 ++-- scripts/items/item_affix_database.gd | 32 ++++++ scripts/items/item_affix_database.gd.uid | 1 + scripts/items/item_affix_definition.gd | 53 +++++++++ scripts/items/item_affix_definition.gd.uid | 1 + scripts/items/item_definition.gd | 6 + scripts/items/item_generator.gd | 41 +++++++ scripts/items/item_generator.gd.uid | 1 + scripts/items/item_instance.gd | 75 +++++++++++- scripts/items/item_stat_modifier.gd | 6 + scripts/items/loot_pickup.gd | 46 ++++++++ scripts/items/loot_pickup.gd.uid | 1 + scripts/items/ship_inventory.gd | 19 ++- scripts/ui/equipment_slot_view.gd | 48 ++++++++ tests/items/affix_database_test.gd | 71 ++++++++++++ tests/items/affix_database_test.gd.uid | 1 + tests/items/affix_database_test.tscn | 6 + tests/items/weapon_damage_test.gd | 108 ++++++++++++++++++ tests/items/weapon_damage_test.gd.uid | 1 + tests/items/weapon_damage_test.tscn | 6 + 37 files changed, 821 insertions(+), 13 deletions(-) create mode 100644 documentation.txt create mode 100644 resources/items/affix_database.tres create mode 100644 resources/items/affixes/credits_percent.tres create mode 100644 resources/items/affixes/damage_percent.tres create mode 100644 resources/items/affixes/energy_regeneration.tres create mode 100644 resources/items/affixes/experience_percent.tres create mode 100644 resources/items/affixes/hull_percent.tres create mode 100644 resources/items/affixes/shield_percent.tres create mode 100644 resources/items/affixes/shield_regeneration.tres create mode 100644 resources/items/affixes/speed_percent.tres create mode 100644 scripts/items/item_affix_database.gd create mode 100644 scripts/items/item_affix_database.gd.uid create mode 100644 scripts/items/item_affix_definition.gd create mode 100644 scripts/items/item_affix_definition.gd.uid create mode 100644 scripts/items/item_generator.gd create mode 100644 scripts/items/item_generator.gd.uid create mode 100644 scripts/items/loot_pickup.gd create mode 100644 scripts/items/loot_pickup.gd.uid create mode 100644 tests/items/affix_database_test.gd create mode 100644 tests/items/affix_database_test.gd.uid create mode 100644 tests/items/affix_database_test.tscn create mode 100644 tests/items/weapon_damage_test.gd create mode 100644 tests/items/weapon_damage_test.gd.uid create mode 100644 tests/items/weapon_damage_test.tscn diff --git a/documentation.txt b/documentation.txt new file mode 100644 index 0000000..dc1f9a6 --- /dev/null +++ b/documentation.txt @@ -0,0 +1,92 @@ +SYSTEM PRZEDMIOTÓW I LOOTU + +Przedmioty: Weapon, Shield, Hull, Generator, Extension. Baza typów znajduje się +w resources/items, generator w scripts/items/item_generator.gd. +Poziom przedmiotu = GameState.system_level (1–1000; obecnie układ 1). +Cecha główna modułów i regeneracja generatora: baza * (1 + 0.05 * (L - 1)) * losowe 0.8–1.2. +Definicje bazowe pozostają wspólne i nie są zmieniane; wylosowane wartości zapisuje +ItemInstance. Każdy drop ma 1–5 różnych addonów z bazy zasobów affix_database.tres. +Liczba addonów zwykłego dropu: 1=50%, 2=25%, 3=14%, 4=8%, 5=3%. +Elite dostaje jeden addon więcej (maks. 5), Boss co najmniej 4. +Rzadkość wg liczby addonów: zwykły, niepospolity, rzadki, epicki, legendarny. +Zakresy addonów interpolują liniowo między poziomem 1 a 1000 wg concept_game.txt. +Cap dotyczy pojedynczego addonu; bonusy zamontowanych przedmiotów sumują się. + +Po zniszczeniu wroga: Normal 65%, Elite 90%, Boss 100% szansy na jeden przedmiot. +Typ wroga ustawia eksportowane loot_tier; obecna mapa nadal tworzy zwykłych wrogów. +Drop pozostaje na mapie, przyciąga się z 150 px i zbiera do wolnego slotu z 48 px. +Pełna ładownia: drop czeka i pokazuje komunikat. Zniszczony gracz nie zbiera lootu. +Niezebrany loot znika przy restarcie poziomu. Zebrane przedmioty zachowują wszystkie +wylosowane wartości, addony, poziomy i rozmieszczenie po śmierci gracza. + +Addony działają wyłącznie w zamontowanej broni/modułach. Procenty kadłuba, tarcz +i prędkości mnożą bazę powiększoną o bonusy płaskie. Bonus obrażeń działa na obie +bronie (także laser i rakiety). Regeneracja dodaje punkty na sekundę. +EXP i kredyty mają obliczone mnożniki statku; naliczanie nagród wymaga przyszłego +systemu postępu pilota i waluty, których gra obecnie nie posiada. + +UI: kolor przedmiotu wskazuje rzadkość. Najechanie na slot pokazuje pełne parametry, +poziom, rzadkość, addony i parametry broni. Przenoszenie nadal odbywa się drag/drop. +Małe × w zajętym slocie magazynu usuwa przedmiot natychmiast, bez przesuwania +pozostałych slotów. Przedmioty zamontowane nie mają przycisku usuwania. + +EDYCJA BAZY CECH (ADDONÓW) + +Katalog bazy: resources/items/affix_database.tres. +Definicje poszczególnych cech: resources/items/affixes/*.tres. +Klasa pól i reguł: scripts/items/item_affix_definition.gd. +Losowanie ważone i kontrola zgodności: scripts/items/item_affix_database.gd. + +W Godocie wybierz plik cechy .tres w FileSystem i edytuj go w Inspectorze: +- affix_id: unikalny identyfikator cechy. +- display_name, description, unit: nazwa, opis, jednostka. +- stat_key: identyfikator efektu obsługiwanego przez statek/bronie. +- enabled: włącza lub wyłącza przyszłe losowanie cechy. +- selection_weight: względna waga losowania (2 = dwa razy większa waga niż 1). + Waga 0 wyłącza losowanie; procent szansy zależy od pozostałej dozwolonej puli. +- category_mask: zaznacz dozwolone Weapon, Shield, Hull, Generator, Extension. + Obecnie zaznaczone są wszystkie kategorie, zgodnie z wcześniejszą pulą ogólną. +- allowed_item_ids: pusta lista dopuszcza wszystkie itemy dozwolonych kategorii; + wpisana lista dopuszcza tylko podane item_id z bazowych resources/items/*.tres. +- excluded_item_ids: zawsze wyklucza wskazane itemy. +- minimum_level / maximum_level: poziomy, na których cecha może się pojawić. +- minimum/maximum_value_level_1 i level_1000: zakresy dla poziomów krańcowych; + między nimi generator interpoluje liniowo. Dla stałej wartości ustaw wszystkie + cztery pola identycznie. +- exclusive_group: puste = bez ograniczenia. Cechy o tej samej niepustej grupie + nie mogą pojawić się razem na jednym przedmiocie. +- incompatible_affix_ids: lista cech wykluczonych na tym samym przedmiocie. + Wystarczy wpisać wykluczenie po jednej stronie. Ta sama cecha nie powtarza się. + +Aby dodać cechę: utwórz zasób ItemAffixDefinition, zapisz w affixes i dodaj do +tablicy affixes w affix_database.tres. Sama obecność pliku w katalogu nie dodaje +go do losowania. Aktualne osiem cech ma wagę 1 i nie ma wzajemnych wykluczeń. +Nowy stat_key wymaga również implementacji efektu w kodzie; baza opisuje reguły +losowania i prezentację, nie tworzy automatycznie nowych mechanik statku. +Tooltip pobiera nazwę i jednostkę z definicji cechy. Wylosowana wartość pozostaje +w ItemStatModifier; zmiana zakresów w bazie nie przelicza zdobytych przedmiotów. +Przy zbyt małej puli zgodnych cech generator kończy losowanie wcześniej, bez +duplikatów i bez pętli nieskończonej. Rzadkość wynika z faktycznej liczby addonów. +Pusta dozwolona pula daje przedmiot bez addonów o rzadkości zwykłej. + +ZAKRES OBRAŻEŃ BRONI + +Każda nowa broń ma zapisane w ItemInstance damage_min / damage_max. Generator +losuje dolną granicę oraz szerokość zakresu. Obie granice skaluje przez +(1 + 0.05 * (L - 1)) i zaokrągla do całkowitych punktów obrażeń. +Wartości dla poziomu 1 edytuje się w zasobach poszczególnych broni .tres: +damage_lower_roll_min/max = zakres losowania dolnej granicy; +damage_spread_roll_min/max = zakres losowania szerokości (górna = dolna + szerokość). +Laser: dolna 1–4, szerokość 2–4 (możliwe m.in. 1–5 i 4–6). +Plazma: dolna 10–14, szerokość 2–4. +Działko maszynowe: dolna 2–4, szerokość 1–2. +Rakiety: dolna 45–58, szerokość 5–12. + +Każdy wystrzelony pocisk i każdy trafiający impuls lasera losują obrażenia +równomiernie z zakresu swojej broni, włącznie z obiema granicami. +Po losowaniu naliczany jest mnożnik damage_percent z całego wyposażenia. +Jedna rakieta ma jeden wynik losowania, wspólny dla trafienia i jej eksplozji. +Tooltip i etykieta slotu pokazują bazowy zakres broni; bonus procentowy statku +jest naliczany w walce. Zakres dropu zachowuje się po restarcie poziomu. +Bronie startowe mają zakres z wartości środkowych ustawień w definicji. +Starszy item z primary_value_override i bez zakresu zachowuje stałe obrażenia. diff --git a/resources/items/affix_database.tres b/resources/items/affix_database.tres new file mode 100644 index 0000000..62f500a --- /dev/null +++ b/resources/items/affix_database.tres @@ -0,0 +1,17 @@ +[gd_resource type="Resource" script_class="ItemAffixDatabase" load_steps=11 format=3] + +[ext_resource type="Script" path="res://scripts/items/item_affix_database.gd" id="1_database"] +[ext_resource type="Script" path="res://scripts/items/item_affix_definition.gd" id="2_type"] +[ext_resource type="Resource" path="res://resources/items/affixes/hull_percent.tres" id="3_affix"] +[ext_resource type="Resource" path="res://resources/items/affixes/damage_percent.tres" id="4_affix"] +[ext_resource type="Resource" path="res://resources/items/affixes/shield_percent.tres" id="5_affix"] +[ext_resource type="Resource" path="res://resources/items/affixes/energy_regeneration.tres" id="6_affix"] +[ext_resource type="Resource" path="res://resources/items/affixes/shield_regeneration.tres" id="7_affix"] +[ext_resource type="Resource" path="res://resources/items/affixes/speed_percent.tres" id="8_affix"] +[ext_resource type="Resource" path="res://resources/items/affixes/experience_percent.tres" id="9_affix"] +[ext_resource type="Resource" path="res://resources/items/affixes/credits_percent.tres" id="10_affix"] + +[resource] +script = ExtResource("1_database") +affixes = Array[ExtResource("2_type")]([ExtResource("3_affix"), ExtResource("4_affix"), ExtResource("5_affix"), ExtResource("6_affix"), ExtResource("7_affix"), ExtResource("8_affix"), ExtResource("9_affix"), ExtResource("10_affix")]) + diff --git a/resources/items/affixes/credits_percent.tres b/resources/items/affixes/credits_percent.tres new file mode 100644 index 0000000..ecc9d3a --- /dev/null +++ b/resources/items/affixes/credits_percent.tres @@ -0,0 +1,17 @@ +[gd_resource type="Resource" script_class="ItemAffixDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/items/item_affix_definition.gd" id="1_affix"] + +[resource] +script = ExtResource("1_affix") +affix_id = "credits_percent" +display_name = "Kredyty" +stat_key = "credits_percent" +unit = "%" +selection_weight = 1.0 +category_mask = 31 +minimum_value_level_1 = 2 +maximum_value_level_1 = 5 +minimum_value_level_1000 = 40 +maximum_value_level_1000 = 75 + diff --git a/resources/items/affixes/damage_percent.tres b/resources/items/affixes/damage_percent.tres new file mode 100644 index 0000000..f16b63e --- /dev/null +++ b/resources/items/affixes/damage_percent.tres @@ -0,0 +1,17 @@ +[gd_resource type="Resource" script_class="ItemAffixDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/items/item_affix_definition.gd" id="1_affix"] + +[resource] +script = ExtResource("1_affix") +affix_id = "damage_percent" +display_name = "Obrażenia broni" +stat_key = "damage_percent" +unit = "%" +selection_weight = 1.0 +category_mask = 31 +minimum_value_level_1 = 1 +maximum_value_level_1 = 3 +minimum_value_level_1000 = 25 +maximum_value_level_1000 = 50 + diff --git a/resources/items/affixes/energy_regeneration.tres b/resources/items/affixes/energy_regeneration.tres new file mode 100644 index 0000000..1b9b84c --- /dev/null +++ b/resources/items/affixes/energy_regeneration.tres @@ -0,0 +1,17 @@ +[gd_resource type="Resource" script_class="ItemAffixDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/items/item_affix_definition.gd" id="1_affix"] + +[resource] +script = ExtResource("1_affix") +affix_id = "energy_regeneration" +display_name = "Regeneracja energii" +stat_key = "energy_regeneration" +unit = "/s" +selection_weight = 1.0 +category_mask = 31 +minimum_value_level_1 = 0.2 +maximum_value_level_1 = 0.5 +minimum_value_level_1000 = 100 +maximum_value_level_1000 = 180 + diff --git a/resources/items/affixes/experience_percent.tres b/resources/items/affixes/experience_percent.tres new file mode 100644 index 0000000..4da9778 --- /dev/null +++ b/resources/items/affixes/experience_percent.tres @@ -0,0 +1,17 @@ +[gd_resource type="Resource" script_class="ItemAffixDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/items/item_affix_definition.gd" id="1_affix"] + +[resource] +script = ExtResource("1_affix") +affix_id = "experience_percent" +display_name = "Doświadczenie (EXP)" +stat_key = "experience_percent" +unit = "%" +selection_weight = 1.0 +category_mask = 31 +minimum_value_level_1 = 2 +maximum_value_level_1 = 5 +minimum_value_level_1000 = 40 +maximum_value_level_1000 = 75 + diff --git a/resources/items/affixes/hull_percent.tres b/resources/items/affixes/hull_percent.tres new file mode 100644 index 0000000..095c6d1 --- /dev/null +++ b/resources/items/affixes/hull_percent.tres @@ -0,0 +1,17 @@ +[gd_resource type="Resource" script_class="ItemAffixDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/items/item_affix_definition.gd" id="1_affix"] + +[resource] +script = ExtResource("1_affix") +affix_id = "hull_percent" +display_name = "Kadłub" +stat_key = "hull_percent" +unit = "%" +selection_weight = 1.0 +category_mask = 31 +minimum_value_level_1 = 1 +maximum_value_level_1 = 3 +minimum_value_level_1000 = 25 +maximum_value_level_1000 = 50 + diff --git a/resources/items/affixes/shield_percent.tres b/resources/items/affixes/shield_percent.tres new file mode 100644 index 0000000..febb45e --- /dev/null +++ b/resources/items/affixes/shield_percent.tres @@ -0,0 +1,17 @@ +[gd_resource type="Resource" script_class="ItemAffixDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/items/item_affix_definition.gd" id="1_affix"] + +[resource] +script = ExtResource("1_affix") +affix_id = "shield_percent" +display_name = "Tarcze" +stat_key = "shield_percent" +unit = "%" +selection_weight = 1.0 +category_mask = 31 +minimum_value_level_1 = 1 +maximum_value_level_1 = 3 +minimum_value_level_1000 = 25 +maximum_value_level_1000 = 50 + diff --git a/resources/items/affixes/shield_regeneration.tres b/resources/items/affixes/shield_regeneration.tres new file mode 100644 index 0000000..6496ba7 --- /dev/null +++ b/resources/items/affixes/shield_regeneration.tres @@ -0,0 +1,17 @@ +[gd_resource type="Resource" script_class="ItemAffixDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/items/item_affix_definition.gd" id="1_affix"] + +[resource] +script = ExtResource("1_affix") +affix_id = "shield_regeneration" +display_name = "Regeneracja tarcz" +stat_key = "shield_regeneration" +unit = "/s" +selection_weight = 1.0 +category_mask = 31 +minimum_value_level_1 = 0.5 +maximum_value_level_1 = 1 +minimum_value_level_1000 = 200 +maximum_value_level_1000 = 350 + diff --git a/resources/items/affixes/speed_percent.tres b/resources/items/affixes/speed_percent.tres new file mode 100644 index 0000000..294643a --- /dev/null +++ b/resources/items/affixes/speed_percent.tres @@ -0,0 +1,17 @@ +[gd_resource type="Resource" script_class="ItemAffixDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/items/item_affix_definition.gd" id="1_affix"] + +[resource] +script = ExtResource("1_affix") +affix_id = "speed_percent" +display_name = "Prędkość statku" +stat_key = "speed_percent" +unit = "%" +selection_weight = 1.0 +category_mask = 31 +minimum_value_level_1 = 1 +maximum_value_level_1 = 2 +minimum_value_level_1000 = 15 +maximum_value_level_1000 = 25 + diff --git a/resources/items/beam_laser.tres b/resources/items/beam_laser.tres index 81d0e64..abc2e71 100644 --- a/resources/items/beam_laser.tres +++ b/resources/items/beam_laser.tres @@ -15,4 +15,8 @@ weapon_kind = 2 fire_interval = 0.2 weapon_range = 800.0 energy_cost = 12.0 +damage_lower_roll_min = 1 +damage_lower_roll_max = 4 +damage_spread_roll_min = 2 +damage_spread_roll_max = 4 laser_pulse_duration = 0.08 diff --git a/resources/items/machine_gun.tres b/resources/items/machine_gun.tres index 231332d..b2648e2 100644 --- a/resources/items/machine_gun.tres +++ b/resources/items/machine_gun.tres @@ -16,3 +16,7 @@ fire_interval = 0.1 projectile_speed = 1150.0 weapon_range = 550.0 energy_cost = 3.0 +damage_lower_roll_min = 2 +damage_lower_roll_max = 4 +damage_spread_roll_min = 1 +damage_spread_roll_max = 2 diff --git a/resources/items/missile_launcher.tres b/resources/items/missile_launcher.tres index a9d3641..7fbbc83 100644 --- a/resources/items/missile_launcher.tres +++ b/resources/items/missile_launcher.tres @@ -17,3 +17,7 @@ weapon_cooldown = 5.0 projectile_speed = 400.0 weapon_range = 1000.0 energy_cost = 25.0 +damage_lower_roll_min = 45 +damage_lower_roll_max = 58 +damage_spread_roll_min = 5 +damage_spread_roll_max = 12 diff --git a/resources/items/plasma_cannon.tres b/resources/items/plasma_cannon.tres index 14d01a5..89e1c3d 100644 --- a/resources/items/plasma_cannon.tres +++ b/resources/items/plasma_cannon.tres @@ -16,3 +16,7 @@ fire_interval = 0.2857 projectile_speed = 720.0 weapon_range = 350.0 energy_cost = 4.0 +damage_lower_roll_min = 10 +damage_lower_roll_max = 14 +damage_spread_roll_min = 2 +damage_spread_roll_max = 4 diff --git a/scripts/game/enemy_ship.gd b/scripts/game/enemy_ship.gd index a4bae23..c6caf6c 100644 --- a/scripts/game/enemy_ship.gd +++ b/scripts/game/enemy_ship.gd @@ -4,6 +4,10 @@ const DAMAGE_POPUP_SCENE := preload("res://scenes/game/damage_popup.tscn") const IMPACT_EFFECT_SCENE := preload("res://scenes/game/impact_effect.tscn") const PLASMA_PROJECTILE_SCENE := preload("res://scenes/game/enemy_plasma_projectile.tscn") const WEAPON_AUDIO_SCRIPT := preload("res://scripts/game/weapon_audio.gd") +const ITEM_GENERATOR := preload("res://scripts/items/item_generator.gd") +const LOOT_PICKUP := preload("res://scripts/items/loot_pickup.gd") + +@export_enum("Normal", "Elite", "Boss") var loot_tier: int = 0 @export var max_hull := 50.0 @export var activation_range := 760.0 @@ -70,12 +74,25 @@ func _fire_plasma(direction: Vector2) -> void: WEAPON_AUDIO_SCRIPT.play(get_parent(), global_position, ItemDefinition.WeaponKind.PLASMA, -10.0) func take_damage(amount: float) -> void: + if is_queued_for_deletion() or hull <= 0.0: + return hull = maxf(0.0, hull - amount) _show_damage_popup(amount) if hull <= 0.0: + _drop_loot() _spawn_destruction_effect() queue_free() +func _drop_loot() -> void: + var chance := 0.65 if loot_tier == 0 else (0.9 if loot_tier == 1 else 1.0) + if randf() > chance: + return + var pickup := Node2D.new() + pickup.set_script(LOOT_PICKUP) + pickup.set("item", ITEM_GENERATOR.generate(GameState.system_level, loot_tier)) + pickup.position = position + get_parent().call_deferred("add_child", pickup) + func _spawn_destruction_effect() -> void: var effect := IMPACT_EFFECT_SCENE.instantiate() as Node2D effect.global_position = global_position diff --git a/scripts/game/game_state.gd b/scripts/game/game_state.gd index cc151f1..3f5c6a4 100644 --- a/scripts/game/game_state.gd +++ b/scripts/game/game_state.gd @@ -2,6 +2,7 @@ extends Node var selected_ship_path: String = "" var restart_inventory: Dictionary = {} +var system_level: int = 1 func _ready() -> void: var game_window := get_tree().root diff --git a/scripts/game/ship_controller.gd b/scripts/game/ship_controller.gd index ec449ae..78807c3 100644 --- a/scripts/game/ship_controller.gd +++ b/scripts/game/ship_controller.gd @@ -27,6 +27,9 @@ var shields := 100.0 var energy := 100.0 var energy_regeneration := 12.0 var shield_regeneration := 0.0 +var damage_multiplier := 1.0 +var experience_multiplier := 1.0 +var credits_multiplier := 1.0 var angular_velocity := 0.0 var _sprite: Sprite2D @@ -35,10 +38,13 @@ var _is_destroyed := false @onready var _shield_visual: Node2D = $ShieldVisual func apply_equipment_bonuses(bonuses: Dictionary) -> void: - max_hull = base_max_hull + float(bonuses.get("max_hull", 0.0)) - max_shields = base_max_shields + float(bonuses.get("max_shields", 0.0)) + max_hull = (base_max_hull + float(bonuses.get("max_hull", 0.0))) * (1.0 + float(bonuses.get("hull_percent", 0.0)) / 100.0) + max_shields = (base_max_shields + float(bonuses.get("max_shields", 0.0))) * (1.0 + float(bonuses.get("shield_percent", 0.0)) / 100.0) max_energy = base_max_energy + float(bonuses.get("max_energy", 0.0)) - max_speed = base_max_speed + float(bonuses.get("max_speed", 0.0)) + max_speed = (base_max_speed + float(bonuses.get("max_speed", 0.0))) * (1.0 + float(bonuses.get("speed_percent", 0.0)) / 100.0) + damage_multiplier = 1.0 + float(bonuses.get("damage_percent", 0.0)) / 100.0 + experience_multiplier = 1.0 + float(bonuses.get("experience_percent", 0.0)) / 100.0 + credits_multiplier = 1.0 + float(bonuses.get("credits_percent", 0.0)) / 100.0 energy_regeneration = base_energy_regeneration + float(bonuses.get("energy_regeneration", 0.0)) shield_regeneration = base_shield_regeneration + float(bonuses.get("shield_regeneration", 0.0)) reverse_max_speed = max_speed * 0.5 diff --git a/scripts/game/weapon_controller.gd b/scripts/game/weapon_controller.gd index e9773e3..891f4a2 100644 --- a/scripts/game/weapon_controller.gd +++ b/scripts/game/weapon_controller.gd @@ -49,6 +49,10 @@ func _unhandled_input(event: InputEvent) -> void: _fire_just_pressed[slot_index] = true func _physics_process(delta: float) -> void: + if bool(ship.get("_is_destroyed")): + for line in _laser_lines: + line.visible = false + return var mouse_over_ui := ship.get_viewport().gui_get_hovered_control() != null for slot_index in 2: _shot_timers[slot_index] = maxf(0.0, _shot_timers[slot_index] - delta) @@ -77,23 +81,24 @@ func _physics_process(delta: float) -> void: match definition.weapon_kind: ItemDefinition.WeaponKind.LASER: _laser_lines[slot_index].visible = _laser_pulse_timers[slot_index] > 0.0 - _fire_laser(slot_index, definition, item.get_stat_total("damage")) + _fire_laser(slot_index, item) ItemDefinition.WeaponKind.MISSILE: if ( is_just_pressed and _shot_timers[slot_index] <= 0.0 and _consume_energy(definition.energy_cost) ): - _fire_projectile(definition, item.get_stat_total("damage")) + _fire_projectile(item) _shot_timers[slot_index] = definition.weapon_cooldown _: if _shot_timers[slot_index] <= 0.0 and _consume_energy(definition.energy_cost): - _fire_projectile(definition, item.get_stat_total("damage")) + _fire_projectile(item) _shot_timers[slot_index] = definition.fire_interval for slot_index in 2: _fire_just_pressed[slot_index] = false -func _fire_projectile(definition: ItemDefinition, projectile_damage: float) -> void: +func _fire_projectile(item: ItemInstance) -> void: + var definition := item.definition var muzzle_position := _muzzle_position() var direction := _aim_direction_from(muzzle_position) var projectile := PROJECTILE_SCENE.instantiate() @@ -103,13 +108,14 @@ func _fire_projectile(definition: ItemDefinition, projectile_damage: float) -> v direction, definition.weapon_kind, definition.projectile_speed, - projectile_damage, + item.roll_damage() * float(ship.get("damage_multiplier")), definition.weapon_range ) ship.get_parent().add_child(projectile) WEAPON_AUDIO_SCRIPT.play(ship.get_parent(), muzzle_position, definition.weapon_kind) -func _fire_laser(slot_index: int, definition: ItemDefinition, beam_damage: float) -> void: +func _fire_laser(slot_index: int, item: ItemInstance) -> void: + var definition := item.definition var line := _laser_lines[slot_index] if _shot_timers[slot_index] > 0.0 or float(ship.get("energy")) < definition.energy_cost: return @@ -134,7 +140,7 @@ func _fire_laser(slot_index: int, definition: ItemDefinition, beam_damage: float return WEAPON_AUDIO_SCRIPT.play(ship.get_parent(), muzzle_position, ItemDefinition.WeaponKind.LASER) if not result.is_empty(): - _apply_damage(result.get("collider"), beam_damage) + _apply_damage(result.get("collider"), item.roll_damage() * float(ship.get("damage_multiplier"))) _spawn_laser_impact(endpoint) _laser_pulse_timers[slot_index] = definition.laser_pulse_duration _shot_timers[slot_index] = definition.fire_interval diff --git a/scripts/items/item_affix_database.gd b/scripts/items/item_affix_database.gd new file mode 100644 index 0000000..74e889d --- /dev/null +++ b/scripts/items/item_affix_database.gd @@ -0,0 +1,32 @@ +extends Resource +class_name ItemAffixDatabase + +@export var affixes: Array[ItemAffixDefinition] = [] + +func roll_affixes(item: ItemDefinition, level: int, requested_count: int) -> Array[ItemStatModifier]: + var pool: Array[ItemAffixDefinition] = [] + for affix in affixes: + if affix != null and affix.can_roll(item, level): + pool.append(affix) + var modifiers: Array[ItemStatModifier] = [] + while modifiers.size() < requested_count and not pool.is_empty(): + var total_weight := 0.0 + for candidate in pool: + total_weight += candidate.selection_weight + var roll := randf() * total_weight + var selected := pool.back() as ItemAffixDefinition + for candidate in pool: + roll -= candidate.selection_weight + if roll <= 0.0: + selected = candidate + break + var modifier := ItemStatModifier.new() + modifier.affix = selected + modifier.stat_key = selected.stat_key + modifier.value = selected.roll_value(level) + modifiers.append(modifier) + # Removing incompatible candidates also prevents duplicate affix IDs. + for index in range(pool.size() - 1, -1, -1): + if not selected.can_combine(pool[index]): + pool.remove_at(index) + return modifiers diff --git a/scripts/items/item_affix_database.gd.uid b/scripts/items/item_affix_database.gd.uid new file mode 100644 index 0000000..8b46609 --- /dev/null +++ b/scripts/items/item_affix_database.gd.uid @@ -0,0 +1 @@ +uid://chgc2743hvpnn diff --git a/scripts/items/item_affix_definition.gd b/scripts/items/item_affix_definition.gd new file mode 100644 index 0000000..ecacf18 --- /dev/null +++ b/scripts/items/item_affix_definition.gd @@ -0,0 +1,53 @@ +extends Resource +class_name ItemAffixDefinition + +@export var affix_id: String = "" +@export var display_name: String = "" +@export_multiline var description: String = "" +@export var stat_key: String = "" +@export_enum("%", "pkt", "/s", "px/s") var unit: String = "%" +@export var enabled: bool = true +@export_range(0.0, 1000.0, 0.1) var selection_weight: float = 1.0 + +@export_group("Dozwolone przedmioty") +@export_flags("Weapon", "Shield", "Hull", "Generator", "Extension") var category_mask: int = 31 +# Empty means every item in the allowed categories. +@export var allowed_item_ids: PackedStringArray = PackedStringArray() +@export var excluded_item_ids: PackedStringArray = PackedStringArray() + +@export_group("Zakres i skalowanie") +@export_range(1, 1000) var minimum_level: int = 1 +@export_range(1, 1000) var maximum_level: int = 1000 +@export var minimum_value_level_1: float = 1.0 +@export var maximum_value_level_1: float = 3.0 +@export var minimum_value_level_1000: float = 25.0 +@export var maximum_value_level_1000: float = 50.0 + +@export_group("Zasady łączenia") +# At most one affix from a nonempty group can appear on the same item. +@export var exclusive_group: String = "" +@export var incompatible_affix_ids: PackedStringArray = PackedStringArray() + +func can_roll(item: ItemDefinition, level: int) -> bool: + return ( + enabled and selection_weight > 0.0 and not affix_id.is_empty() + and not stat_key.is_empty() and item != null + and level >= minimum_level and level <= maximum_level + and (category_mask & (1 << item.category)) != 0 + and (allowed_item_ids.is_empty() or item.item_id in allowed_item_ids) + and item.item_id not in excluded_item_ids + ) + +func can_combine(other: ItemAffixDefinition) -> bool: + return ( + affix_id != other.affix_id + and (exclusive_group.is_empty() or exclusive_group != other.exclusive_group) + and other.affix_id not in incompatible_affix_ids + and affix_id not in other.incompatible_affix_ids + ) + +func roll_value(level: int) -> float: + var progression := float(clampi(level, 1, 1000) - 1) / 999.0 + var lower := lerpf(minimum_value_level_1, minimum_value_level_1000, progression) + var upper := lerpf(maximum_value_level_1, maximum_value_level_1000, progression) + return randf_range(minf(lower, upper), maxf(lower, upper)) diff --git a/scripts/items/item_affix_definition.gd.uid b/scripts/items/item_affix_definition.gd.uid new file mode 100644 index 0000000..c6d9325 --- /dev/null +++ b/scripts/items/item_affix_definition.gd.uid @@ -0,0 +1 @@ +uid://cp6ucjqrac8so diff --git a/scripts/items/item_definition.gd b/scripts/items/item_definition.gd index d35b63a..437b5e0 100644 --- a/scripts/items/item_definition.gd +++ b/scripts/items/item_definition.gd @@ -19,3 +19,9 @@ enum WeaponKind { PLASMA, MACHINE_GUN, LASER, MISSILE } @export var projectile_speed: float = 0.0 @export var weapon_range: float = 0.0 @export var energy_cost: float = 0.0 + +@export_group("Losowanie zakresu obrażeń broni (poziom 1)") +@export_range(1, 10000) var damage_lower_roll_min: int = 1 +@export_range(1, 10000) var damage_lower_roll_max: int = 4 +@export_range(1, 10000) var damage_spread_roll_min: int = 2 +@export_range(1, 10000) var damage_spread_roll_max: int = 4 diff --git a/scripts/items/item_generator.gd b/scripts/items/item_generator.gd new file mode 100644 index 0000000..0fe1cb4 --- /dev/null +++ b/scripts/items/item_generator.gd @@ -0,0 +1,41 @@ +extends RefCounted + +const DEFINITIONS := [ + preload("res://resources/items/plasma_cannon.tres"), + preload("res://resources/items/beam_laser.tres"), + preload("res://resources/items/machine_gun.tres"), + preload("res://resources/items/missile_launcher.tres"), + preload("res://resources/items/reinforced_shield.tres"), + preload("res://resources/items/hull_plating.tres"), + preload("res://resources/items/compact_generator.tres"), + preload("res://resources/items/engine_extension.tres"), + preload("res://resources/items/tuned_thrusters.tres"), + preload("res://resources/items/shield_recharger.tres"), +] +const AFFIX_DATABASE := preload("res://resources/items/affix_database.tres") + +static func generate(level: int, enemy_tier: int = 0) -> ItemInstance: + return generate_for_definition(DEFINITIONS.pick_random(), level, enemy_tier) + +static func generate_for_definition(definition: ItemDefinition, level: int, enemy_tier: int = 0) -> ItemInstance: + var item := ItemInstance.new() + item.definition = definition + item.item_level = clampi(level, 1, 1000) + var scale_factor := 1.0 + 0.05 * (item.item_level - 1) + item.primary_value_override = item.definition.primary_value * scale_factor * randf_range(0.8, 1.2) + if definition.category == ItemDefinition.Category.WEAPON: + var lower := randi_range(mini(definition.damage_lower_roll_min, definition.damage_lower_roll_max), maxi(definition.damage_lower_roll_min, definition.damage_lower_roll_max)) + var spread := randi_range(mini(definition.damage_spread_roll_min, definition.damage_spread_roll_max), maxi(definition.damage_spread_roll_min, definition.damage_spread_roll_max)) + item.damage_min = maxf(1.0, roundf(lower * scale_factor)) + item.damage_max = maxf(item.damage_min + 1.0, roundf((lower + spread) * scale_factor)) + item.primary_value_override = (item.damage_min + item.damage_max) * 0.5 + item.energy_regeneration_override = item.definition.energy_regeneration_bonus * scale_factor * randf_range(0.8, 1.2) + var roll := randf() + var count := 1 if roll < 0.5 else (2 if roll < 0.75 else (3 if roll < 0.89 else (4 if roll < 0.97 else 5))) + if enemy_tier == 1: + count = mini(5, count + 1) + elif enemy_tier >= 2: + count = maxi(4, count) + item.additional_modifiers = AFFIX_DATABASE.roll_affixes(item.definition, item.item_level, count) + item.rarity = clampi(item.additional_modifiers.size() - 1, 0, 4) + return item diff --git a/scripts/items/item_generator.gd.uid b/scripts/items/item_generator.gd.uid new file mode 100644 index 0000000..60a2a58 --- /dev/null +++ b/scripts/items/item_generator.gd.uid @@ -0,0 +1 @@ +uid://3mi6rla5f5r8 diff --git a/scripts/items/item_instance.gd b/scripts/items/item_instance.gd index 04bfcec..a410370 100644 --- a/scripts/items/item_instance.gd +++ b/scripts/items/item_instance.gd @@ -4,14 +4,85 @@ class_name ItemInstance @export var definition: ItemDefinition @export_range(1, 1000, 1) var item_level: int = 1 @export var additional_modifiers: Array[ItemStatModifier] = [] +@export var primary_value_override: float = -1.0 +@export var energy_regeneration_override: float = -1.0 +@export_range(0, 4) var rarity: int = 0 +@export var damage_min: float = -1.0 +@export var damage_max: float = -1.0 func get_stat_total(stat_key: String) -> float: + if stat_key == "damage" and definition and definition.category == ItemDefinition.Category.WEAPON: + var damage_range := get_damage_range() + return (damage_range.x + damage_range.y) * 0.5 var total := 0.0 if definition and definition.primary_stat == stat_key: - total += definition.primary_value + total += primary_value_override if primary_value_override >= 0.0 else definition.primary_value if definition and stat_key == "energy_regeneration": - total += definition.energy_regeneration_bonus + total += energy_regeneration_override if energy_regeneration_override >= 0.0 else definition.energy_regeneration_bonus for modifier in additional_modifiers: if modifier and modifier.stat_key == stat_key: total += modifier.value return total + +func get_damage_range() -> Vector2: + if definition == null or definition.category != ItemDefinition.Category.WEAPON: + return Vector2.ZERO + var lower := damage_min + var upper := damage_max + if lower < 0.0 or upper < 0.0: + if primary_value_override >= 0.0: + # Older item instances retain their previously rolled damage. + lower = primary_value_override + upper = primary_value_override + else: + # Starter weapons use the middle of the definition's roll settings. + lower = roundf((definition.damage_lower_roll_min + definition.damage_lower_roll_max) * 0.5) + upper = lower + roundf((definition.damage_spread_roll_min + definition.damage_spread_roll_max) * 0.5) + var flat_bonus := 0.0 + for modifier in additional_modifiers: + if modifier and modifier.stat_key == "damage": + flat_bonus += modifier.value + return Vector2(maxf(0.0, minf(lower, upper) + flat_bonus), maxf(0.0, maxf(lower, upper) + flat_bonus)) + +func roll_damage() -> float: + var damage_range := get_damage_range() + # Inclusive integer rolls, preserving fractional flat bonuses on both bounds. + var whole_span := floori(damage_range.y - damage_range.x) + return damage_range.x + randi_range(0, whole_span) + +func get_damage_range_text() -> String: + var damage_range := get_damage_range() + return "%s–%s DMG" % [_format_damage(damage_range.x), _format_damage(damage_range.y)] + +static func _format_damage(value: float) -> String: + return str(roundi(value)) if is_equal_approx(value, roundf(value)) else "%.1f" % value + +func get_rarity_name() -> String: + return ["Zwykły", "Niepospolity", "Rzadki", "Epicki", "Legendarny"][clampi(rarity, 0, 4)] + +func get_rarity_color() -> Color: + return [Color("b9c5d4"), Color("63df91"), Color("65b4ff"), Color("bd83ff"), Color("ffca63")][clampi(rarity, 0, 4)] + +func get_tooltip_text() -> String: + if definition == null: + return "" + var primary_text := "%s: +%.1f%s" % [stat_name(definition.primary_stat), get_stat_total(definition.primary_stat), definition.stat_suffix] + if definition.category == ItemDefinition.Category.WEAPON: + primary_text = "Obrażenia: %s" % get_damage_range_text() + var lines: Array[String] = [definition.display_name, "%s • Poziom %d" % [get_rarity_name(), item_level], "", definition.description, "", "CECHA GŁÓWNA", primary_text] + if definition.energy_regeneration_bonus > 0.0: + lines.append("Regeneracja generatora: +%.1f /s" % (energy_regeneration_override if energy_regeneration_override >= 0.0 else definition.energy_regeneration_bonus)) + if definition.category == ItemDefinition.Category.WEAPON: + lines.append("Każdy strzał / impuls losuje obrażenia z tego zakresu.") + lines.append("Zasięg: %.0f px • Energia: %.1f / strzał" % [definition.weapon_range, definition.energy_cost]) + lines.append("Odstęp strzałów: %.2f s" % maxf(definition.fire_interval, definition.weapon_cooldown)) + if not additional_modifiers.is_empty(): + lines.append("\nADDONY (aktywne po zamontowaniu)") + for modifier in additional_modifiers: + if modifier == null: + continue + lines.append(modifier.get_display_text()) + return "\n".join(lines) + +static func stat_name(key: String) -> String: + return str({"damage": "Obrażenia", "max_hull": "Kadłub", "max_shields": "Tarcze", "max_energy": "Energia", "max_speed": "Prędkość", "energy_regeneration": "Regeneracja energii", "shield_regeneration": "Regeneracja tarcz", "hull_percent": "Kadłub", "shield_percent": "Tarcze", "damage_percent": "Obrażenia broni", "speed_percent": "Prędkość statku", "experience_percent": "Doświadczenie (EXP)", "credits_percent": "Kredyty"}.get(key, key)) diff --git a/scripts/items/item_stat_modifier.gd b/scripts/items/item_stat_modifier.gd index b145dbc..0eb318b 100644 --- a/scripts/items/item_stat_modifier.gd +++ b/scripts/items/item_stat_modifier.gd @@ -3,3 +3,9 @@ class_name ItemStatModifier @export var stat_key: String = "" @export var value: float = 0.0 +@export var affix: ItemAffixDefinition + +func get_display_text() -> String: + var label := affix.display_name if affix else ItemInstance.stat_name(stat_key) + var suffix := affix.unit if affix else ("%" if stat_key.ends_with("_percent") else "/s") + return "%s: %s%.2f %s" % [label, "+" if value >= 0.0 else "", value, suffix] diff --git a/scripts/items/loot_pickup.gd b/scripts/items/loot_pickup.gd new file mode 100644 index 0000000..c9b2492 --- /dev/null +++ b/scripts/items/loot_pickup.gd @@ -0,0 +1,46 @@ +extends Node2D + +const PICKUP_RADIUS := 48.0 +const ATTRACTION_RADIUS := 150.0 +var item: ItemInstance +var _player: CharacterBody2D +var _label: Label + +func _ready() -> void: + add_to_group("loot") + _player = get_parent().get_node_or_null("PlayerShip") as CharacterBody2D + _label = Label.new() + _label.position = Vector2(-110, 18) + _label.size = Vector2(220, 44) + _label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _label.add_theme_font_size_override("font_size", 11) + _label.add_theme_color_override("font_color", item.get_rarity_color()) + _label.text = item.definition.display_name + add_child(_label) + queue_redraw() + +func _draw() -> void: + if item == null: + return + var color := item.get_rarity_color() + draw_circle(Vector2.ZERO, 15.0, Color(color, 0.16)) + draw_arc(Vector2.ZERO, 15.0, 0.0, TAU, 32, color, 2.0, true) + draw_colored_polygon(PackedVector2Array([Vector2(0, -9), Vector2(8, 0), Vector2(0, 9), Vector2(-8, 0)]), color) + +func _physics_process(delta: float) -> void: + if not is_instance_valid(_player) or bool(_player.get("_is_destroyed")): + return + var distance := global_position.distance_to(_player.global_position) + if distance > ATTRACTION_RADIUS: + return + var inventory := _player.get_node("ShipInventory") as ShipInventory + if inventory.get_cargo_used() >= ShipInventory.CARGO_SIZE: + _label.text = "%s\nMagazyn pełny" % item.definition.display_name + return + _label.text = item.definition.display_name + if distance <= PICKUP_RADIUS: + if inventory.add_item(item): + set_physics_process(false) + queue_free() + else: + global_position = global_position.move_toward(_player.global_position, 180.0 * delta) diff --git a/scripts/items/loot_pickup.gd.uid b/scripts/items/loot_pickup.gd.uid new file mode 100644 index 0000000..fd36c3a --- /dev/null +++ b/scripts/items/loot_pickup.gd.uid @@ -0,0 +1 @@ +uid://ndu1j4ns1lj7 diff --git a/scripts/items/ship_inventory.gd b/scripts/items/ship_inventory.gd index 4c652dd..f48174c 100644 --- a/scripts/items/ship_inventory.gd +++ b/scripts/items/ship_inventory.gd @@ -49,6 +49,23 @@ func get_item(slot_kind: String, index: int) -> ItemInstance: return null return slots[index] +func add_item(item: ItemInstance) -> bool: + if item == null or item.definition == null: + return false + for index in cargo.size(): + if cargo[index] == null: + cargo[index] = item + inventory_changed.emit() + return true + return false + +func delete_cargo_item(index: int) -> bool: + if index < 0 or index >= cargo.size() or cargo[index] == null: + return false + cargo[index] = null + inventory_changed.emit() + return true + func can_move_item(from_kind: String, from_index: int, to_kind: String, to_index: int) -> bool: var source := _get_slots(from_kind) var destination := _get_slots(to_kind) @@ -98,7 +115,7 @@ func _recalculate_ship_stats() -> void: for item in weapon_slots + module_slots: if item == null or item.definition == null: continue - for stat_key in ["max_hull", "max_shields", "max_energy", "max_speed", "energy_regeneration", "shield_regeneration"]: + for stat_key in ["max_hull", "max_shields", "max_energy", "max_speed", "energy_regeneration", "shield_regeneration", "hull_percent", "shield_percent", "damage_percent", "speed_percent", "experience_percent", "credits_percent"]: bonuses[stat_key] = float(bonuses.get(stat_key, 0.0)) + item.get_stat_total(stat_key) ship.call("apply_equipment_bonuses", bonuses) diff --git a/scripts/ui/equipment_slot_view.gd b/scripts/ui/equipment_slot_view.gd index 87893ee..101e5d8 100644 --- a/scripts/ui/equipment_slot_view.gd +++ b/scripts/ui/equipment_slot_view.gd @@ -6,8 +6,26 @@ extends PanelContainer @onready var slot_label: Label = $SlotLabel var _inventory: ShipInventory +var _delete_button: Button func _ready() -> void: + if slot_kind == "cargo": + var overlay := Control.new() + overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(overlay) + _delete_button = Button.new() + _delete_button.text = "×" + _delete_button.tooltip_text = "Usuń przedmiot z magazynu" + _delete_button.flat = true + _delete_button.add_theme_font_size_override("font_size", 13) + _delete_button.add_theme_color_override("font_color", Color("ff8989")) + overlay.add_child(_delete_button) + _delete_button.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT) + _delete_button.offset_left = -20 + _delete_button.offset_right = 0 + _delete_button.offset_top = 0 + _delete_button.offset_bottom = 18 + _delete_button.pressed.connect(_delete_item) _inventory = get_tree().get_first_node_in_group("ship_inventory") as ShipInventory if _inventory: _inventory.inventory_changed.connect(_refresh) @@ -24,10 +42,15 @@ func _refresh() -> void: if not is_node_ready() or not _inventory: return var item: ItemInstance = _inventory.get_item(slot_kind, slot_index) + tooltip_text = item.get_tooltip_text() if item != null else "" + if _delete_button: + _delete_button.visible = item != null if item == null or item.definition == null: + slot_label.add_theme_color_override("font_color", Color("82909e")) slot_label.text = _empty_slot_text() return var definition := item.definition + slot_label.add_theme_color_override("font_color", item.get_rarity_color()) var category_name := _category_name(definition.category) var value_text := _stat_text(item) var slot_title := category_name @@ -38,6 +61,29 @@ func _refresh() -> void: slot_title = "MODUŁ %d [%d]" % [slot_index + 1, slot_index + 1] slot_label.text = "%s\n%s\n%s" % [slot_title, definition.display_name, value_text] +func _delete_item() -> void: + if _inventory: + _inventory.delete_cargo_item(slot_index) + +func _make_custom_tooltip(for_text: String) -> Object: + var panel := PanelContainer.new() + var background := StyleBoxFlat.new() + background.bg_color = Color("101925") + background.border_color = Color("52677e") + background.set_border_width_all(1) + background.set_content_margin_all(12) + panel.add_theme_stylebox_override("panel", background) + var label := Label.new() + label.text = for_text + label.custom_minimum_size.x = 330 + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + label.add_theme_font_size_override("font_size", 13) + var item := _inventory.get_item(slot_kind, slot_index) + if item: + label.add_theme_color_override("font_color", item.get_rarity_color()) + panel.add_child(label) + return panel + func _get_drag_data(_at_position: Vector2) -> Variant: if not _inventory: return null @@ -94,6 +140,8 @@ func _category_name(category: int) -> String: func _stat_text(item: ItemInstance) -> String: var definition := item.definition + if definition.category == ItemDefinition.Category.WEAPON: + return "%s · Poz. %d" % [item.get_damage_range_text(), item.item_level] if definition.primary_stat.is_empty(): return "Poz. %d" % item.item_level var total_value := item.get_stat_total(definition.primary_stat) diff --git a/tests/items/affix_database_test.gd b/tests/items/affix_database_test.gd new file mode 100644 index 0000000..35d2748 --- /dev/null +++ b/tests/items/affix_database_test.gd @@ -0,0 +1,71 @@ +extends Node + +const GENERATOR := preload("res://scripts/items/item_generator.gd") +const DATABASE := preload("res://resources/items/affix_database.tres") + +func _ready() -> void: + assert(DATABASE.affixes.size() == 8) + for level in [1, 50, 1000]: + for tier in [0, 1, 2]: + for sample in 50: + var item := GENERATOR.generate(level, tier) + assert(item.item_level == level) + assert(item.additional_modifiers.size() >= 1 and item.additional_modifiers.size() <= 5) + if tier == 2: + assert(item.additional_modifiers.size() >= 4) + var ids: Array[String] = [] + for modifier in item.additional_modifiers: + assert(modifier.affix.can_roll(item.definition, level)) + assert(modifier.affix.affix_id not in ids) + ids.append(modifier.affix.affix_id) + var progression := float(level - 1) / 999.0 + assert(modifier.value >= lerpf(modifier.affix.minimum_value_level_1, modifier.affix.minimum_value_level_1000, progression)) + assert(modifier.value <= lerpf(modifier.affix.maximum_value_level_1, modifier.affix.maximum_value_level_1000, progression)) + assert(item.get_tooltip_text().contains(item.additional_modifiers[0].affix.display_name)) + + var weapon := ItemDefinition.new() + weapon.item_id = "test_weapon" + weapon.category = ItemDefinition.Category.WEAPON + var shield := ItemDefinition.new() + shield.item_id = "test_shield" + shield.category = ItemDefinition.Category.SHIELD + var first := ItemAffixDefinition.new() + first.affix_id = "first" + first.stat_key = "damage_percent" + first.category_mask = 1 + assert(first.can_roll(weapon, 1)) + assert(not first.can_roll(shield, 1)) + first.allowed_item_ids = PackedStringArray(["another_weapon"]) + assert(not first.can_roll(weapon, 1)) + first.allowed_item_ids = PackedStringArray([weapon.item_id]) + assert(first.can_roll(weapon, 1)) + first.excluded_item_ids = PackedStringArray([weapon.item_id]) + assert(not first.can_roll(weapon, 1)) + first.excluded_item_ids.clear() + first.minimum_level = 10 + first.maximum_level = 20 + assert(not first.can_roll(weapon, 1)) + assert(first.can_roll(weapon, 15)) + assert(not first.can_roll(weapon, 21)) + first.minimum_level = 1 + first.maximum_level = 1000 + + var second := first.duplicate() as ItemAffixDefinition + second.affix_id = "second" + var database := ItemAffixDatabase.new() + database.affixes.assign([first, second]) + assert(database.roll_affixes(weapon, 1, 5).size() == 2) + first.exclusive_group = "test_group" + second.exclusive_group = "test_group" + assert(database.roll_affixes(weapon, 1, 5).size() == 1) + first.exclusive_group = "" + second.exclusive_group = "" + first.incompatible_affix_ids = PackedStringArray([second.affix_id]) + assert(not first.can_combine(second) and not second.can_combine(first)) + assert(database.roll_affixes(weapon, 1, 5).size() == 1) + first.incompatible_affix_ids.clear() + first.selection_weight = 0.0 + assert(database.roll_affixes(weapon, 1, 5)[0].affix == second) + second.enabled = false + assert(database.roll_affixes(weapon, 1, 5).is_empty()) + print("AFFIX_DATABASE_TEST: PASS (450 generated items, category/ID/level filters, exclusions, weights and empty pools)") diff --git a/tests/items/affix_database_test.gd.uid b/tests/items/affix_database_test.gd.uid new file mode 100644 index 0000000..ee12afb --- /dev/null +++ b/tests/items/affix_database_test.gd.uid @@ -0,0 +1 @@ +uid://bhpv7xasq34qg diff --git a/tests/items/affix_database_test.tscn b/tests/items/affix_database_test.tscn new file mode 100644 index 0000000..793004e --- /dev/null +++ b/tests/items/affix_database_test.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/items/affix_database_test.gd" id="1_test"] + +[node name="AffixDatabaseTest" type="Node"] +script = ExtResource("1_test") diff --git a/tests/items/weapon_damage_test.gd b/tests/items/weapon_damage_test.gd new file mode 100644 index 0000000..ead2e36 --- /dev/null +++ b/tests/items/weapon_damage_test.gd @@ -0,0 +1,108 @@ +extends Node2D + +const GENERATOR := preload("res://scripts/items/item_generator.gd") +const PLAYER_SCENE := preload("res://scenes/game/player_ship.tscn") +const PROJECTILE_SCRIPT := preload("res://scripts/game/weapon_projectile.gd") + +class LaserProbe: + extends "res://scripts/game/weapon_controller.gd" + var hits: Array[float] = [] + func _aim_direction_from(_origin: Vector2) -> Vector2: + return Vector2.RIGHT + func _apply_damage(_target: Variant, damage: float) -> void: + hits.append(damage) + +func _ready() -> void: + var saw_laser_1_5 := false + var saw_laser_4_6 := false + for definition in GENERATOR.DEFINITIONS: + if definition.category != ItemDefinition.Category.WEAPON: + continue + for level in [1, 50, 1000]: + for sample in 100: + var item := GENERATOR.generate_for_definition(definition, level) + var bounds := item.get_damage_range() + assert(bounds.x >= 1 and bounds.y > bounds.x) + var scale_factor: float = 1.0 + 0.05 * (level - 1) + assert(bounds.x >= roundf(definition.damage_lower_roll_min * scale_factor)) + assert(bounds.x <= roundf(definition.damage_lower_roll_max * scale_factor)) + assert(bounds.y <= roundf((definition.damage_lower_roll_max + definition.damage_spread_roll_max) * scale_factor)) + for shot in 20: + var damage := item.roll_damage() + assert(damage >= bounds.x and damage <= bounds.y) + assert(item.get_damage_range() == bounds) + assert(item.get_tooltip_text().contains(item.get_damage_range_text())) + if definition.item_id == "beam_laser" and level == 1: + saw_laser_1_5 = saw_laser_1_5 or bounds == Vector2(1, 5) + saw_laser_4_6 = saw_laser_4_6 or bounds == Vector2(4, 6) + assert(saw_laser_1_5 and saw_laser_4_6) + + var laser := ItemInstance.new() + laser.definition = preload("res://resources/items/beam_laser.tres") + laser.damage_min = 1 + laser.damage_max = 5 + var results: Dictionary = {} + for shot in 300: + results[laser.roll_damage()] = true + assert(results.size() == 5 and results.has(1.0) and results.has(5.0)) + var legacy := ItemInstance.new() + legacy.definition = laser.definition + legacy.primary_value_override = 7.5 + assert(legacy.roll_damage() == 7.5) + + var player := PLAYER_SCENE.instantiate() as CharacterBody2D + add_child(player) + player.set_physics_process(false) + player.get_node("WeaponController").set_physics_process(false) + player.set("damage_multiplier", 1.25) + var probe := LaserProbe.new() + player.add_child(probe) + probe.set_physics_process(false) + var target := StaticBody2D.new() + target.position = Vector2(200, -28) + target.collision_layer = 4 + var collision := CollisionShape2D.new() + var shape := CircleShape2D.new() + shape.radius = 20 + collision.shape = shape + target.add_child(collision) + add_child(target) + await get_tree().physics_frame + await get_tree().physics_frame + for shot in 100: + player.set("energy", 100.0) + probe.set("_shot_timers", [0.0, 0.0]) + probe._fire_laser(0, laser) + assert(probe.hits.size() == 100) + var laser_hits: Dictionary = {} + for hit in probe.hits: + assert(hit >= 1.25 and hit <= 6.25) + laser_hits[hit] = true + assert(laser_hits.size() == 5) + player.set("energy", 0.0) + probe.set("_shot_timers", [0.0, 0.0]) + probe._fire_laser(0, laser) + assert(probe.hits.size() == 100) + player.set("energy", 100.0) + probe.set("_shot_timers", [10.0, 0.0]) + probe._fire_laser(0, laser) + assert(probe.hits.size() == 100) + + for definition in GENERATOR.DEFINITIONS: + if definition.category != ItemDefinition.Category.WEAPON or definition.weapon_kind == ItemDefinition.WeaponKind.LASER: + continue + var item := GENERATOR.generate_for_definition(definition, 1) + var bounds := item.get_damage_range() + var projectile_results: Dictionary = {} + for shot in 100: + var previous_child_count := get_child_count() + probe._fire_projectile(item) + var projectile := get_child(previous_child_count) + assert(projectile.get_script() == PROJECTILE_SCRIPT) + var damage := float(projectile.get("damage")) + assert(damage >= bounds.x * 1.25 and damage <= bounds.y * 1.25) + projectile_results[damage] = true + remove_child(projectile) + projectile.free() + assert(projectile_results.size() > 1) + print("WEAPON_DAMAGE_TEST: PASS (1200 weapon rolls, 24000 damage rolls, 100 laser pulses and 300 projectiles; bonuses and energy/cooldown gates)") diff --git a/tests/items/weapon_damage_test.gd.uid b/tests/items/weapon_damage_test.gd.uid new file mode 100644 index 0000000..0010b69 --- /dev/null +++ b/tests/items/weapon_damage_test.gd.uid @@ -0,0 +1 @@ +uid://cfpa84wi27vr6 diff --git a/tests/items/weapon_damage_test.tscn b/tests/items/weapon_damage_test.tscn new file mode 100644 index 0000000..e0df2c4 --- /dev/null +++ b/tests/items/weapon_damage_test.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/items/weapon_damage_test.gd" id="1_test"] + +[node name="WeaponDamageTest" type="Node2D"] +script = ExtResource("1_test")