System itemów

This commit is contained in:
Tomasz Boruc 2026-10-10 14:13:56 +02:00
parent d7099548b5
commit e5067bb0e2
37 changed files with 821 additions and 13 deletions

92
documentation.txt Normal file
View File

@ -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.

View File

@ -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")])

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
uid://chgc2743hvpnn

View File

@ -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))

View File

@ -0,0 +1 @@
uid://cp6ucjqrac8so

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
uid://3mi6rla5f5r8

View File

@ -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))

View File

@ -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]

View File

@ -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)

View File

@ -0,0 +1 @@
uid://ndu1j4ns1lj7

View File

@ -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)

View File

@ -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)

View File

@ -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)")

View File

@ -0,0 +1 @@
uid://bhpv7xasq34qg

View File

@ -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")

View File

@ -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)")

View File

@ -0,0 +1 @@
uid://cfpa84wi27vr6

View File

@ -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")