Compare commits

...

2 Commits

Author SHA1 Message Date
b5338a7564 Zmiany 2026-10-11 18:42:15 +02:00
0c9f812cfe Slot na rakiety 2026-10-11 17:42:46 +02:00
40 changed files with 689 additions and 72 deletions

View File

@ -1,4 +1,4 @@
[gd_resource type="Resource" script_class="ItemAffixDatabase" load_steps=13 format=3]
[gd_resource type="Resource" script_class="ItemAffixDatabase" load_steps=15 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"]
@ -13,6 +13,10 @@
[ext_resource type="Resource" path="res://resources/items/affixes/critical_chance_percent.tres" id="11_affix"]
[ext_resource type="Resource" path="res://resources/items/affixes/critical_damage_percent.tres" id="12_affix"]
[ext_resource type="Resource" path="res://resources/items/affixes/missile_cooldown.tres" id="13_affix"]
[ext_resource type="Resource" path="res://resources/items/affixes/shield_on_damage.tres" id="14_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"), ExtResource("11_affix"), ExtResource("12_affix")])
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"), ExtResource("11_affix"), ExtResource("12_affix"), ExtResource("13_affix"), ExtResource("14_affix")])

View File

@ -0,0 +1,13 @@
[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 = "missile_cooldown"
display_name = "Skrócenie cooldownu rakiet"
stat_key = "missile_cooldown_reduction"
unit = "s"
category_mask = 63
minimum_value_level_1 = 0.1
maximum_value_level_1 = 0.15
minimum_value_level_1000 = 1.0
maximum_value_level_1000 = 2.0

View File

@ -0,0 +1,14 @@
[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_on_damage"
display_name = "Odzysk tarcz z obrażeń"
description = "Globalnie odnawia tarcze o procent faktycznie zadanych obrażeń każdą bronią."
stat_key = "shield_on_damage_percent"
unit = "%"
category_mask = 63
minimum_value_level_1 = 1.0
maximum_value_level_1 = 1.0
minimum_value_level_1000 = 5.0
maximum_value_level_1000 = 5.0

View File

@ -5,8 +5,8 @@
[resource]
script = ExtResource("1_definition")
item_id = "engine_extension"
display_name = "Moduł silnika"
description = "Pasywny moduł zwiększający prędkość maksymalną."
display_name = "Ulepszony silnik"
description = "Pasywny moduł prędkości. Na poziomie 1 bonus wynosi 1–5 px/s i rośnie wraz z poziomem przedmiotu."
primary_stat = "max_speed"
primary_value = 40.0
primary_value = 3.0
stat_suffix = " px/s"

View File

@ -5,7 +5,7 @@
[resource]
script = ExtResource("1_definition")
item_id = "missile_launcher"
display_name = "Wyrzutnia rakiet"
display_name = "Wyrzutnia rakiet naprowadzanych"
category = 0
description = "Wystrzeliwuje naprowadzaną rakietę z eksplozją obszarową. Cooldown: 5 s."
primary_stat = "damage"
@ -16,7 +16,7 @@ fire_interval = 5.0
weapon_cooldown = 5.0
projectile_speed = 400.0
weapon_range = 1000.0
energy_cost = 25.0
energy_cost = 0.0
damage_lower_roll_min = 45
damage_lower_roll_max = 58
damage_spread_roll_min = 5

View File

@ -9,14 +9,14 @@ display_name = "Działko plazmowe"
category = 0
description = "Szybkostrzelna broń krótkiego zasięgu."
primary_stat = "damage"
primary_value = 15.0
primary_value = 12.0
stat_suffix = " DMG"
weapon_kind = 0
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_lower_roll_min = 8
damage_lower_roll_max = 11
damage_spread_roll_min = 2
damage_spread_roll_max = 4
damage_spread_roll_max = 3

View File

@ -1,13 +0,0 @@
[gd_resource type="Resource" script_class="ItemDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/items/item_definition.gd" id="1_definition"]
[resource]
script = ExtResource("1_definition")
item_id = "tuned_thrusters"
display_name = "Dostrojone silniki"
category = 4
description = "Ulepszone dysze zwiększające prędkość maksymalną statku o 20 px/s."
primary_stat = "max_speed"
primary_value = 20.0
stat_suffix = " px/s"

View File

@ -0,0 +1,22 @@
[gd_resource type="Resource" script_class="ItemDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/items/item_definition.gd" id="1_definition"]
[resource]
script = ExtResource("1_definition")
item_id = "unguided_missile"
display_name = "Wyrzutnia rakiet niekierowanych"
category = 0
description = "Wystrzeliwuje rakietę prosto w kierunku celownika. Większe obrażenia i eksplozja obszarowa. Zużywa amunicję rakietową."
primary_stat = "damage"
primary_value = 110.0
stat_suffix = " DMG"
weapon_kind = 3
missile_homing = false
fire_interval = 5.0
weapon_cooldown = 5.0
projectile_speed = 480.0
weapon_range = 1000.0
energy_cost = 0.0
damage_lower_roll_min = 90
damage_lower_roll_max = 116
damage_spread_roll_min = 10
damage_spread_roll_max = 24

View File

@ -59,9 +59,9 @@ script = ExtResource("1_schematic")
[node name="Weapon1" parent="Contents/EquipmentLayout" instance=ExtResource("2_slot")]
layout_mode = 0
offset_left = 44.0
offset_left = 0.0
offset_top = 22.0
offset_right = 134.0
offset_right = 90.0
offset_bottom = 66.0
slot_kind = "weapon"
slot_index = 0
@ -69,14 +69,24 @@ overlay_slot = true
[node name="Weapon2" parent="Contents/EquipmentLayout" instance=ExtResource("2_slot")]
layout_mode = 0
offset_left = 172.0
offset_left = 216.0
offset_top = 22.0
offset_right = 262.0
offset_right = 306.0
offset_bottom = 66.0
slot_kind = "weapon"
slot_index = 1
overlay_slot = true
[node name="Missile" parent="Contents/EquipmentLayout" instance=ExtResource("2_slot")]
layout_mode = 0
offset_left = 108.0
offset_top = 22.0
offset_right = 198.0
offset_bottom = 66.0
slot_kind = "weapon"
slot_index = 2
overlay_slot = true
[node name="Module1" parent="Contents/EquipmentLayout" instance=ExtResource("2_slot")]
layout_mode = 0
offset_left = 0.0

View File

@ -57,7 +57,7 @@ theme_override_constants/separation = 12
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 11
text = "AMUNICJA: 300"
text = "AMUNICJA: 50"
[node name="CraftButton" type="Button" parent="Contents/Ammunition"]
layout_mode = 2

View File

@ -75,10 +75,10 @@ func add_rewards(xp_amount: int, credit_amount: int) -> void:
learning_points += BALANCE.LEARNING_POINTS_PER_LEVEL
emit_changed()
func reward_enemy(system_level: int, tier: int, xp_multiplier: float, credit_multiplier: float) -> Vector2i:
func reward_enemy(system_level: int, tier: int, xp_multiplier: float, credit_multiplier: float, grant_credits: bool = true) -> Vector2i:
var index := clampi(tier, 0, 2)
var scale_factor := 1.0 + 0.05 * (clampi(system_level, 1, 1000) - 1)
var xp := roundi(BALANCE.KILL_XP[index] * scale_factor * maxf(0.0, xp_multiplier))
var money := roundi(BALANCE.KILL_CREDITS[index] * scale_factor * maxf(0.0, credit_multiplier))
add_rewards(xp, money)
add_rewards(xp, money if grant_credits else 0)
return Vector2i(xp, money)

View File

@ -6,6 +6,7 @@ const PLASMA_PROJECTILE_SCENE := preload("res://scenes/game/enemy_plasma_project
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")
const LOOT_OFFSET := Vector2(0, 36)
const LEVEL_BALANCE := preload("res://scripts/levels/level_balance.gd")
@export_enum("Normal", "Elite", "Boss") var loot_tier: int = 0
@ -99,7 +100,12 @@ func _fire_plasma(direction: Vector2) -> void:
WEAPON_AUDIO_SCRIPT.play(get_parent(), global_position, ItemDefinition.WeaponKind.PLASMA, -4.0)
func take_weapon_hit(amount: float, critical: bool) -> void:
var actual_damage := minf(maxf(0.0, amount), maxf(0.0, hull))
if is_queued_for_deletion():
actual_damage = 0.0
take_damage(amount, critical)
if is_instance_valid(_player):
_player.call("restore_shields_from_damage", actual_damage)
func take_damage(amount: float, critical: bool = false) -> void:
if is_queued_for_deletion() or hull <= 0.0:
@ -114,7 +120,13 @@ func take_damage(amount: float, critical: bool = false) -> void:
func _reward_captain() -> void:
if is_instance_valid(_player):
GameState.captain.reward_enemy(GameState.system_level, loot_tier, float(_player.get("experience_multiplier")), float(_player.get("credits_multiplier")))
var reward := GameState.captain.reward_enemy(GameState.system_level, loot_tier, float(_player.get("experience_multiplier")), float(_player.get("credits_multiplier")), false)
if reward.y > 0:
var credits := Node2D.new()
credits.set_script(preload("res://scripts/items/credit_pickup.gd"))
credits.set("amount", reward.y)
credits.position = position - LOOT_OFFSET
get_parent().call_deferred("add_child", credits)
func _drop_loot() -> void:
var chance := 0.65 if loot_tier == 0 else (0.9 if loot_tier == 1 else 1.0)
@ -123,7 +135,7 @@ func _drop_loot() -> void:
var pickup := Node2D.new()
pickup.set_script(LOOT_PICKUP)
pickup.set("item", ITEM_GENERATOR.generate(GameState.system_level, loot_tier))
pickup.position = position
pickup.position = position + LOOT_OFFSET
get_parent().call_deferred("add_child", pickup)
func _spawn_destruction_effect() -> void:

View File

@ -74,7 +74,7 @@ func _process(_delta: float) -> void:
W: ciąg S: hamulec / wsteczny
Tab: mini boost (+100%% prędkości, 20 energii/s)
Q / E: strafe A / D: obrót
LPM / PPM: broń 1 / broń 2
LPM / PPM: broń 1 / broń 2 · ŚPM: rakieta
1–6: aktywne rozszerzenia
UKŁAD: %d · PLANETY: %d
@ -133,6 +133,7 @@ Redukcja obrażeń: %.1f%%
Regeneracja energii: %.1f pkt/s (%+.1f)
Ładowanie solarne: +%.1f pkt/s
Regeneracja tarcz: %.1f pkt/s (%+.1f)
Odzysk tarcz z obrażeń: %.1f%%
Maks. kadłub: %.1f (%+.1f pkt)
Maks. tarcze: %.1f (%+.1f pkt)
Maks. energia: %.1f (%+.1f pkt)
@ -151,6 +152,7 @@ Zdobywane kredyty: %+.1f%%""" % [
float(player.call("get_solar_regeneration")),
float(player.get("shield_regeneration")),
float(player.get("shield_regeneration")) - float(player.get("base_shield_regeneration")),
float(player.get("shield_on_damage_percent")),
float(player.get("max_hull")), float(player.get("max_hull")) - float(player.get("base_max_hull")),
float(player.get("max_shields")), float(player.get("max_shields")) - float(player.get("base_max_shields")),
float(player.get("max_energy")), float(player.get("max_energy")) - float(player.get("base_max_energy")),

View File

@ -36,6 +36,7 @@ var shield_regeneration := 0.0
var damage_multiplier := 1.0
var experience_multiplier := 1.0
var credits_multiplier := 1.0
var shield_on_damage_percent := 0.0
var critical_chance_bonus := 0.0
var critical_damage_bonus := 0.0
@export var solar_charge_radius := 550.0
@ -73,6 +74,7 @@ func apply_equipment_bonuses(bonuses: Dictionary) -> void:
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
shield_on_damage_percent = maxf(0.0, float(bonuses.get("shield_on_damage_percent", 0.0)))
critical_chance_bonus = float(bonuses.get("critical_chance_percent", 0.0)) / 100.0
critical_damage_bonus = float(bonuses.get("critical_damage_percent", 0.0)) / 100.0
energy_regeneration = base_energy_regeneration + float(bonuses.get("energy_regeneration", 0.0))
@ -148,6 +150,12 @@ func take_damage(amount: float) -> void:
if hull <= 0.0:
_destroy_ship()
func restore_shields_from_damage(actual_damage: float) -> void:
if _is_destroyed or actual_damage <= 0.0 or shield_on_damage_percent <= 0.0:
return
shields = minf(max_shields, shields + actual_damage * shield_on_damage_percent / 100.0)
_update_shield_visual()
func get_evasion_chance() -> float:
return GameState.captain.get_skill_bonus("piloting")

View File

@ -1,8 +1,9 @@
extends Node2D
const FONT := preload("res://assets/fonts/font2.ttf")
var player: CharacterBody2D
var _display_ratio := 1.0
var _display_shield_ratio := 1.0
var _display_hull_ratio := 1.0
var _background: StyleBoxFlat
func _ready() -> void:
@ -13,6 +14,8 @@ func _ready() -> void:
reset_physics_interpolation()
z_index = 24
_display_ratio = _energy_ratio()
_display_shield_ratio = _shield_ratio()
_display_hull_ratio = _hull_ratio()
_background = StyleBoxFlat.new()
_background.bg_color = Color("101925")
_background.border_color = Color("456277")
@ -33,11 +36,25 @@ func _process(delta: float) -> void:
_display_ratio = lerpf(_display_ratio, target, 1.0 - exp(-20.0 * delta))
if absf(_display_ratio - target) < 0.001:
_display_ratio = target
var shield_target := _shield_ratio()
_display_shield_ratio = lerpf(_display_shield_ratio, shield_target, 1.0 - exp(-20.0 * delta))
if absf(_display_shield_ratio - shield_target) < 0.001:
_display_shield_ratio = shield_target
var hull_target := _hull_ratio()
_display_hull_ratio = lerpf(_display_hull_ratio, hull_target, 1.0 - exp(-20.0 * delta))
if absf(_display_hull_ratio - hull_target) < 0.001:
_display_hull_ratio = hull_target
queue_redraw()
func _energy_ratio() -> float:
return clampf(float(player.get("energy")) / maxf(1.0, float(player.get("max_energy"))), 0.0, 1.0)
func _shield_ratio() -> float:
return clampf(float(player.get("shields")) / maxf(1.0, float(player.get("max_shields"))), 0.0, 1.0)
func _hull_ratio() -> float:
return clampf(float(player.get("hull")) / maxf(1.0, float(player.get("max_hull"))), 0.0, 1.0)
func _draw() -> void:
if not is_instance_valid(player):
return
@ -45,8 +62,9 @@ func _draw() -> void:
var bar_position := Vector2(-44, 48)
draw_style_box(_background, Rect2(bar_position - Vector2(2, 2), Vector2(92, 10)))
draw_rect(Rect2(bar_position, Vector2(88 * _display_ratio, 6)), color)
var charging := float(player.call("get_solar_regeneration")) > 0.0 and _energy_ratio() < 1.0
var caption := "ENERGIA %d%%" % roundi(_energy_ratio() * 100)
draw_string(FONT, bar_position + Vector2(0, 22), caption, HORIZONTAL_ALIGNMENT_CENTER, 88, 10, color)
if charging:
draw_string(FONT, bar_position + Vector2(-18, 36), "ŁADOWANIE SOLARNE", HORIZONTAL_ALIGNMENT_CENTER, 124, 9, Color("71e3ff"))
var shield_bar_position := bar_position + Vector2(0, 14)
draw_style_box(_background, Rect2(shield_bar_position - Vector2(2, 2), Vector2(92, 10)))
draw_rect(Rect2(shield_bar_position, Vector2(88 * _display_shield_ratio, 6)), Color.WHITE)
var hull_bar_position := bar_position + Vector2(0, 28)
draw_style_box(_background, Rect2(hull_bar_position - Vector2(2, 2), Vector2(92, 10)))
draw_rect(Rect2(hull_bar_position, Vector2(88 * _display_hull_ratio, 6)), Color("ef5967"))

View File

@ -14,10 +14,14 @@ var _laser_lines: Array[Line2D] = []
var _fire_buttons_down := [false, false]
var _fire_just_pressed := [false, false]
var _drag_fire_blocked := false
var missile_cooldown_remaining := 0.0
var missile_cooldown_duration := 5.0
var _missile_just_pressed := false
func _ready() -> void:
_ensure_input_action(&"ship_fire_primary", MOUSE_BUTTON_LEFT)
_ensure_input_action(&"ship_fire_secondary", MOUSE_BUTTON_RIGHT)
_ensure_input_action(&"ship_fire_missile", MOUSE_BUTTON_MIDDLE)
for slot_index in 2:
var beam := Line2D.new()
beam.width = 4.0
@ -41,6 +45,9 @@ func _unhandled_input(event: InputEvent) -> void:
if ship.get_viewport().gui_is_dragging() or _drag_fire_blocked:
return
var slot_index := -1
if event.button_index == MOUSE_BUTTON_MIDDLE:
_missile_just_pressed = event.pressed
return
if event.button_index == MOUSE_BUTTON_LEFT:
slot_index = 0
elif event.button_index == MOUSE_BUTTON_RIGHT:
@ -52,9 +59,11 @@ func _unhandled_input(event: InputEvent) -> void:
_fire_just_pressed[slot_index] = true
func _physics_process(delta: float) -> void:
missile_cooldown_remaining = maxf(0.0, missile_cooldown_remaining - delta)
if ship.get_viewport().gui_is_dragging():
_drag_fire_blocked = true
if _drag_fire_blocked:
_missile_just_pressed = false
for index in 2:
_fire_buttons_down[index] = false
_fire_just_pressed[index] = false
@ -66,10 +75,14 @@ func _physics_process(delta: float) -> void:
_drag_fire_blocked = false
return
if bool(ship.get("_is_destroyed")) or bool(ship.get("is_docked")):
_missile_just_pressed = false
for line in _laser_lines:
line.visible = false
return
var mouse_over_ui := ship.get_viewport().gui_get_hovered_control() != null
if not mouse_over_ui and (Input.is_action_just_pressed("ship_fire_missile") or _missile_just_pressed):
try_fire_missile()
_missile_just_pressed = false
for slot_index in 2:
_shot_timers[slot_index] = maxf(0.0, _shot_timers[slot_index] - delta)
_laser_pulse_timers[slot_index] = maxf(0.0, _laser_pulse_timers[slot_index] - delta)
@ -99,13 +112,7 @@ func _physics_process(delta: float) -> void:
_laser_lines[slot_index].visible = _laser_pulse_timers[slot_index] > 0.0
_fire_laser(slot_index, item)
ItemDefinition.WeaponKind.MISSILE:
if (
is_just_pressed
and _shot_timers[slot_index] <= 0.0
and _consume_energy(item.get_energy_cost())
):
_fire_projectile(item)
_shot_timers[slot_index] = definition.weapon_cooldown
pass # Rockets fire exclusively from slot 3.
_:
if _shot_timers[slot_index] <= 0.0 and _consume_shot_resource(item):
_fire_projectile(item)
@ -113,6 +120,20 @@ func _physics_process(delta: float) -> void:
for slot_index in 2:
_fire_just_pressed[slot_index] = false
func try_fire_missile() -> bool:
if missile_cooldown_remaining > 0.0 or bool(ship.get("_is_destroyed")) or bool(ship.get("is_docked")):
return false
var item := inventory.get_item("weapon", ShipInventory.MISSILE_SLOT)
if item == null or item.definition.weapon_kind != ItemDefinition.WeaponKind.MISSILE:
return false
if inventory.missile_ammunition <= 0:
return false
missile_cooldown_duration = inventory.get_missile_cooldown(item)
_fire_projectile(item)
missile_cooldown_remaining = missile_cooldown_duration
inventory.consume_missile()
return true
func _fire_projectile(item: ItemInstance) -> void:
var definition := item.definition
var hit: Dictionary = ship.call("roll_weapon_hit", item)
@ -131,6 +152,7 @@ func _fire_projectile(item: ItemInstance) -> void:
bool(hit["critical"])
)
ship.get_parent().add_child(projectile)
projectile.set("missile_homing", definition.missile_homing)
WEAPON_AUDIO_SCRIPT.play(ship.get_parent(), muzzle_position, definition.weapon_kind)
func _fire_laser(slot_index: int, item: ItemInstance) -> void:

View File

@ -11,6 +11,7 @@ var is_critical := false
var max_range := 800.0
var weapon_kind := ItemDefinition.WeaponKind.PLASMA
var homing_target: Node2D
var missile_homing := true
var homing_turn_acceleration := 2.3
var homing_max_turn_speed := 1.7
var missile_steering_acceleration := 520.0
@ -58,7 +59,7 @@ func configure(
func _physics_process(delta: float) -> void:
var travel_velocity := direction * projectile_speed
if weapon_kind == ItemDefinition.WeaponKind.MISSILE:
if weapon_kind == ItemDefinition.WeaponKind.MISSILE and missile_homing:
_flight_time += delta
if not is_instance_valid(homing_target):
_has_locked_target = false

View File

@ -0,0 +1,36 @@
extends Node2D
const PICKUP_RADIUS := 48.0
const ATTRACTION_RADIUS := 150.0
const POPUP := preload("res://scenes/game/damage_popup.tscn")
var amount: int = 0
var _player: CharacterBody2D
var _collected := false
func _ready() -> void:
add_to_group("credit_pickups")
_player = get_parent().get_node_or_null("PlayerShip") as CharacterBody2D
z_index = 18
func _draw() -> void:
draw_circle(Vector2.ZERO, 13.0, Color("46351b"))
draw_arc(Vector2.ZERO, 13.0, 0, TAU, 32, Color("ffd56a"), 2.0, true)
draw_arc(Vector2.ZERO, 6.0, PI * 0.3, PI * 1.7, 20, Color("ffd56a"), 2.0, true)
draw_line(Vector2(-2, -8), Vector2(-2, 8), Color("ffd56a"), 1.5, true)
func _physics_process(delta: float) -> void:
if _collected or not is_instance_valid(_player) or bool(_player.get("_is_destroyed")):
return
var distance := global_position.distance_to(_player.global_position)
if distance <= PICKUP_RADIUS:
_collected = true
GameState.captain.add_rewards(0, amount)
var popup := POPUP.instantiate() as Label
popup.text = "+%d KREDYTÓW" % amount
popup.set("text_color", Color("ffd56a"))
popup.position = _player.position + Vector2(-48, -60)
get_parent().add_child(popup)
set_physics_process(false)
queue_free()
elif distance <= ATTRACTION_RADIUS:
global_position = global_position.move_toward(_player.global_position, 180.0 * delta)

View File

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

View File

@ -5,7 +5,7 @@ class_name ItemAffixDefinition
@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_enum("%", "pkt", "/s", "px/s", "s") var unit: String = "%"
@export var enabled: bool = true
@export_range(0.0, 1000.0, 0.1) var selection_weight: float = 1.0

View File

@ -15,6 +15,7 @@ enum WeaponKind { PLASMA, MACHINE_GUN, LASER, MISSILE }
@export var weapon_kind: WeaponKind = WeaponKind.PLASMA
@export var fire_interval: float = 0.25
@export var weapon_cooldown: float = 0.0
@export var missile_homing: bool = true
@export var laser_pulse_duration: float = 0.08
@export var projectile_speed: float = 0.0
@export var weapon_range: float = 0.0

View File

@ -5,11 +5,11 @@ const DEFINITIONS := [
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/unguided_missile.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"),
preload("res://resources/items/afterburner.tres"),
preload("res://resources/items/shield_charge.tres"),
@ -26,6 +26,8 @@ static func generate_for_definition(definition: ItemDefinition, level: int, enem
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.primary_stat == "max_speed":
item.primary_value_override = randf_range(1.0, 5.0) * scale_factor
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))

View File

@ -55,7 +55,7 @@ func roll_damage() -> float:
func get_energy_cost() -> float:
if definition == null or definition.category != ItemDefinition.Category.WEAPON:
return 0.0
if definition.weapon_kind == ItemDefinition.WeaponKind.MACHINE_GUN:
if definition.weapon_kind in [ItemDefinition.WeaponKind.MACHINE_GUN, ItemDefinition.WeaponKind.MISSILE]:
return 0.0
# Compare this weapon with its starter damage. Captain/global gear buffs are free.
var base_lower := roundf((definition.damage_lower_roll_min + definition.damage_lower_roll_max) * 0.5)
@ -115,6 +115,8 @@ func get_tooltip_text() -> String:
lines.append("Każdy strzał / impuls losuje obrażenia z tego zakresu.")
if definition.weapon_kind == ItemDefinition.WeaponKind.MACHINE_GUN:
lines.append("Zasięg: %.0f px • Amunicja: 1 / strzał" % definition.weapon_range)
elif definition.weapon_kind == ItemDefinition.WeaponKind.MISSILE:
lines.append("ŚPM • Amunicja: 1 rakieta / strzał • Cooldown bazowy: 5 s • Bez kosztu energii")
else:
lines.append("Zasięg: %.0f px • Energia: %.1f / strzał" % [definition.weapon_range, get_energy_cost()])
lines.append("Odstęp strzałów: %.2f s" % maxf(definition.fire_interval, definition.weapon_cooldown))
@ -127,4 +129,4 @@ func get_tooltip_text() -> String:
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", "critical_chance_percent": "Szansa na trafienie krytyczne", "critical_damage_percent": "Obrażenia krytyczne"}.get(key, key))
return str({"shield_on_damage_percent": "Odzysk tarcz z obrażeń", "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", "critical_chance_percent": "Szansa na trafienie krytyczne", "critical_damage_percent": "Obrażenia krytyczne"}.get(key, key))

View File

@ -5,15 +5,21 @@ signal inventory_changed
signal ammunition_changed
const CARGO_SIZE := 20
const WEAPON_SLOT_COUNT := 2
const WEAPON_SLOT_COUNT := 3
const MISSILE_SLOT := 2
const STARTING_MISSILE_AMMUNITION := 2
const MISSILE_CRAFT_PARTS_COST := 50.0
const MISSILE_BUY_BASE_COST := 10
const MISSILE_DEFINITIONS := [preload("res://resources/items/missile_launcher.tres"), preload("res://resources/items/unguided_missile.tres")]
const MODULE_SLOT_COUNT := 6
const STARTING_AMMUNITION := 300
const STARTING_AMMUNITION := 50
const AMMUNITION_CRAFT_AMOUNT := 100
const AMMUNITION_CRAFT_PARTS_COST := 50.0
const AMMUNITION_BUY_AMOUNT := 100
const AMMUNITION_BUY_BASE_COST := 20
var ammunition: int = STARTING_AMMUNITION
var missile_ammunition: int = STARTING_MISSILE_AMMUNITION
var cargo: Array[ItemInstance] = []
var weapon_slots: Array[ItemInstance] = []
@ -31,8 +37,10 @@ func _ready() -> void:
else:
cargo.assign(GameState.restart_inventory["cargo"])
weapon_slots.assign(GameState.restart_inventory["weapon"])
weapon_slots.resize(WEAPON_SLOT_COUNT)
module_slots.assign(GameState.restart_inventory["module"])
ammunition = int(GameState.restart_inventory.get("ammunition", STARTING_AMMUNITION))
missile_ammunition = int(GameState.restart_inventory.get("missile_ammunition", STARTING_MISSILE_AMMUNITION))
GameState.restart_inventory.clear()
_recalculate_ship_stats()
inventory_changed.emit()
@ -51,6 +59,7 @@ func preserve_for_restart() -> void:
"weapon": weapon_slots.duplicate(),
"module": module_slots.duplicate(),
"ammunition": ammunition,
"missile_ammunition": missile_ammunition,
}
func consume_ammunition() -> bool:
@ -122,18 +131,63 @@ func can_move_item(from_kind: String, from_index: int, to_kind: String, to_index
var item := source[from_index]
if item == null or item.definition == null:
return false
if to_kind == "weapon" and item.definition.category != ItemDefinition.Category.WEAPON:
return false
if to_kind == "module" and item.definition.category == ItemDefinition.Category.WEAPON:
if not _accepts_item(to_kind, to_index, item):
return false
var displaced := destination[to_index]
if displaced != null and displaced.definition != null:
if from_kind == "weapon" and displaced.definition.category != ItemDefinition.Category.WEAPON:
return false
if from_kind == "module" and displaced.definition.category == ItemDefinition.Category.WEAPON:
if not _accepts_item(from_kind, from_index, displaced):
return false
return true
func _accepts_item(kind: String, index: int, item: ItemInstance) -> bool:
if kind == "weapon":
return item.definition.category == ItemDefinition.Category.WEAPON and (item.definition.weapon_kind == ItemDefinition.WeaponKind.MISSILE) == (index == MISSILE_SLOT)
return kind != "module" or item.definition.category != ItemDefinition.Category.WEAPON
func get_missile_count() -> int:
return missile_ammunition
func get_missile_cooldown(item: ItemInstance) -> float:
var reduction := item.get_stat_total("missile_cooldown_reduction")
for equipped in weapon_slots + module_slots:
if equipped != null and equipped != item:
reduction += equipped.get_stat_total("missile_cooldown_reduction")
return maxf(1.0, 5.0 - reduction)
func consume_missile() -> bool:
if missile_ammunition <= 0:
return false
missile_ammunition -= 1
ammunition_changed.emit()
return true
func can_craft_missile() -> bool:
return GameState.captain.parts >= MISSILE_CRAFT_PARTS_COST
func get_missile_buy_cost() -> int:
return MISSILE_BUY_BASE_COST * maxi(1, GameState.system_level)
func can_buy_missile() -> bool:
return GameState.captain.credits >= get_missile_buy_cost()
func craft_missile() -> bool:
if not can_craft_missile():
return false
GameState.captain.parts -= MISSILE_CRAFT_PARTS_COST
missile_ammunition += 1
GameState.captain.emit_changed()
ammunition_changed.emit()
return true
func buy_missile() -> bool:
if not can_buy_missile():
return false
GameState.captain.credits -= get_missile_buy_cost()
missile_ammunition += 1
GameState.captain.emit_changed()
ammunition_changed.emit()
return true
func move_item(from_kind: String, from_index: int, to_kind: String, to_index: int) -> bool:
if not can_move_item(from_kind, from_index, to_kind, to_index):
return false
@ -161,7 +215,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", "hull_percent", "shield_percent", "damage_percent", "speed_percent", "experience_percent", "credits_percent", "critical_chance_percent", "critical_damage_percent"]:
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", "critical_chance_percent", "critical_damage_percent", "shield_on_damage_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

@ -32,6 +32,7 @@ func _ready() -> void:
_inventory = get_tree().get_first_node_in_group("ship_inventory") as ShipInventory
if _inventory:
_inventory.inventory_changed.connect(_refresh)
_inventory.ammunition_changed.connect(_refresh)
match slot_kind:
"weapon":
custom_minimum_size = Vector2(90, 38)
@ -52,7 +53,7 @@ func _ready() -> void:
slot_label.text_overrun_behavior = TextServer.OVERRUN_NO_TRIMMING
var binding := Label.new()
binding.mouse_filter = Control.MOUSE_FILTER_IGNORE
binding.text = ("LPM" if slot_index == 0 else "PPM") if slot_kind == "weapon" else str(slot_index + 1)
binding.text = (["LPM", "PPM", "ŚPM"][slot_index]) if slot_kind == "weapon" else str(slot_index + 1)
binding.position = Vector2(2, -14)
binding.add_theme_font_size_override("font_size", 9)
binding.add_theme_color_override("font_color", Color("a0b2c4"))
@ -63,17 +64,33 @@ func _ready() -> void:
marker.add_child(binding)
_refresh()
func _process(_delta: float) -> void:
if slot_kind == "weapon" and slot_index == ShipInventory.MISSILE_SLOT:
queue_redraw()
func _draw() -> void:
if slot_kind != "weapon" or slot_index != ShipInventory.MISSILE_SLOT or _inventory == null:
return
var weapons := _inventory.ship.get_node("WeaponController")
var remaining := float(weapons.get("missile_cooldown_remaining"))
var duration := float(weapons.get("missile_cooldown_duration"))
if remaining > 0.0:
var height := (size.y - 4.0) * clampf(remaining / duration, 0.0, 1.0)
draw_rect(Rect2(2.0, size.y - 2.0 - height, size.x - 4.0, height), Color(1.0, 0.5, 0.1, 0.45))
func _refresh() -> void:
if not is_node_ready() or not _inventory:
return
var item: ItemInstance = _inventory.get_item(slot_kind, slot_index)
_apply_slot_style(item)
tooltip_text = item.get_tooltip_text() if item != null else ""
tooltip_text = item.get_tooltip_text() if item != null else ("WYRZUTNIA [ŚPM] — przeciągnij wyrzutnię z magazynu." if slot_kind == "weapon" and slot_index == ShipInventory.MISSILE_SLOT else "")
if item != null and slot_kind == "weapon" and slot_index == ShipInventory.MISSILE_SLOT:
tooltip_text += "\nCooldown po addonach: %.2f s\nZapas rakiet: %d" % [_inventory.get_missile_cooldown(item), _inventory.get_missile_count()]
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 = ""
slot_label.text = "RAKIETY" if slot_kind == "weapon" and slot_index == ShipInventory.MISSILE_SLOT else ""
return
var definition := item.definition
slot_label.add_theme_color_override("font_color", Color("f1f4f8"))

View File

@ -12,14 +12,27 @@ signal next_level_requested
var _inventory: ShipInventory
var planet: Node2D
var _open_tween: Tween
var _missile_button: Button
func _ready() -> void:
next_button.pressed.connect(func() -> void: next_level_requested.emit())
buy_ammunition_button.pressed.connect(_buy_ammunition)
reset_skills_button.pressed.connect(_reset_skills)
_missile_button = Button.new()
_missile_button.custom_minimum_size.y = 36
_missile_button.add_theme_font_size_override("font_size", 12)
_missile_button.focus_mode = Control.FOCUS_NONE
_missile_button.tooltip_text = "Jedna sztuka amunicji rakietowej do dowolnej wyrzutni."
_missile_button.pressed.connect(func() -> void:
if visible and _inventory:
_inventory.buy_missile()
)
$Center/Panel/Contents.add_child(_missile_button)
$Center/Panel/Contents.move_child(_missile_button, reset_skills_button.get_index())
_inventory = get_tree().get_first_node_in_group("ship_inventory") as ShipInventory
if _inventory:
_inventory.ammunition_changed.connect(_refresh_ammunition_service)
_inventory.inventory_changed.connect(_refresh_ammunition_service)
GameState.captain.changed.connect(_refresh_ammunition_service)
_refresh_ammunition_service()
@ -61,6 +74,8 @@ func _position_at_planet() -> void:
position = Vector2(clampf(target.x, 8.0, maxf(8.0, screen_right - size.x)), clampf(target.y, 8.0, maxf(8.0, viewport_size.y - size.y - 80.0)))
func _refresh_ammunition_service() -> void:
_missile_button.disabled = _inventory == null or not _inventory.can_buy_missile()
_missile_button.text = "KUP +1 RAKIETĘ — %d KREDYTÓW" % (_inventory.get_missile_buy_cost() if _inventory else ShipInventory.MISSILE_BUY_BASE_COST)
reset_skills_button.disabled = not GameState.captain.can_reset_skills()
reset_skills_button.text = "RESET SKILLI — %d KREDYTÓW" % GameState.captain.get_skill_reset_cost()
reset_skills_button.tooltip_text = "Zwraca wszystkie %d wydanych punktów nauki. Koszt: 100 kredytów × poziom kapitana." % GameState.captain.get_spent_learning_points()

View File

@ -12,12 +12,23 @@ const CARGO_SIZE := 20
var _inventory: ShipInventory
var _drawer_open := false
var _slide_tween: Tween
var _missile_button: Button
var drawer_width := 780.0
func _ready() -> void:
close_button.pressed.connect(close_drawer)
craft_button.pressed.connect(_craft_ammunition)
craft_button.text = "TWÓRZ +%d AMUNICJI (%.0f części)" % [ShipInventory.AMMUNITION_CRAFT_AMOUNT, ShipInventory.AMMUNITION_CRAFT_PARTS_COST]
var missile_row := HBoxContainer.new()
$Contents.add_child(missile_row)
$Contents.move_child(missile_row, $Contents/CargoGrid.get_index())
_missile_button = Button.new()
_missile_button.text = "TWÓRZ +1 RAKIETĘ (%.0f części)" % ShipInventory.MISSILE_CRAFT_PARTS_COST
_missile_button.tooltip_text = "Amunicja do obu typów wyrzutni rakiet. Wyrzutnie zdobywasz jako loot."
_missile_button.add_theme_font_size_override("font_size", 11)
_missile_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_missile_button.pressed.connect(func() -> void: _inventory.craft_missile())
missile_row.add_child(_missile_button)
_inventory = get_tree().get_first_node_in_group("ship_inventory") as ShipInventory
if _inventory:
_inventory.inventory_changed.connect(_refresh_title)
@ -32,9 +43,10 @@ func _ready() -> void:
func _refresh_title() -> void:
craft_button.disabled = _inventory == null or not _inventory.can_craft_ammunition()
_missile_button.disabled = _inventory == null or not _inventory.can_craft_missile()
if _inventory:
title_label.text = "MAGAZYN (%d/%d) · Części: %.1f" % [_inventory.get_cargo_used(), CARGO_SIZE, GameState.captain.parts]
ammunition_label.text = "AMUNICJA: %d" % _inventory.ammunition
ammunition_label.text = "AMUNICJA: %d · RAKIETY: %d" % [_inventory.ammunition, _inventory.get_missile_count()]
func _craft_ammunition() -> void:
if _inventory:

View File

@ -0,0 +1,107 @@
extends Node2D
const PLAYER := preload("res://scenes/game/player_ship.tscn")
const PROJECTILE := preload("res://scenes/game/weapon_projectile.tscn")
func _ready() -> void:
GameState.start_new_run()
var ship := PLAYER.instantiate() as CharacterBody2D
add_child(ship)
ship.set_physics_process(false)
var weapons := ship.get_node("WeaponController")
weapons.set_physics_process(false)
var inventory := ship.get_node("ShipInventory") as ShipInventory
assert(inventory.weapon_slots.size() == 3)
assert(inventory.get_missile_count() == 2 and inventory.get_cargo_used() == 0)
var launcher := ItemInstance.new()
launcher.definition = ShipInventory.MISSILE_DEFINITIONS[0]
assert(inventory.add_item(launcher))
assert(not inventory.move_item("cargo", 0, "weapon", 0))
assert(not inventory.move_item("weapon", 0, "weapon", 2))
assert(inventory.move_item("cargo", 0, "weapon", 2))
assert(not inventory.move_item("weapon", 2, "weapon", 0))
assert(not inventory.move_item("weapon", 2, "module", 0))
var energy_before := float(ship.get("energy"))
var missile := inventory.weapon_slots[2]
assert(missile.get_energy_cost() == 0.0)
var addon := ItemStatModifier.new()
addon.stat_key = "missile_cooldown_reduction"
addon.value = 0.1
missile.additional_modifiers.append(addon)
assert(is_equal_approx(inventory.get_missile_cooldown(missile), 4.9))
var middle := InputEventMouseButton.new()
middle.button_index = MOUSE_BUTTON_MIDDLE
middle.pressed = true
assert(InputMap.action_has_event("ship_fire_missile", middle))
weapons.call("_unhandled_input", middle)
weapons.call("_physics_process", 0.1)
assert(ship.get("energy") == energy_before and inventory.get_missile_count() == 1)
assert(inventory.weapon_slots[2] == missile and missile.additional_modifiers[0] == addon)
assert(is_equal_approx(weapons.get("missile_cooldown_remaining"), 4.9))
assert(not weapons.call("try_fire_missile"))
weapons.call("_physics_process", 2.0)
assert(is_equal_approx(weapons.get("missile_cooldown_remaining"), 2.9))
weapons.call("_physics_process", 3.0)
assert(weapons.call("try_fire_missile"))
assert(inventory.get_missile_count() == 0 and inventory.weapon_slots[2] == missile)
weapons.set("missile_cooldown_remaining", 0.0)
assert(not weapons.call("try_fire_missile"))
assert(inventory.weapon_slots[2] == missile and ship.get("energy") == energy_before)
GameState.captain.parts = 49.0
assert(not inventory.craft_missile())
GameState.captain.parts = 50.0
assert(inventory.craft_missile() and GameState.captain.parts == 0.0)
var unguided := ItemInstance.new()
unguided.definition = ShipInventory.MISSILE_DEFINITIONS[1]
assert(inventory.add_item(unguided))
assert(not unguided.definition.missile_homing)
assert(unguided.get_damage_range().x > missile.get_damage_range().y)
assert(inventory.move_item("cargo", 0, "weapon", 2))
weapons.set("missile_cooldown_remaining", 0.0)
assert(weapons.call("try_fire_missile"))
var found_unguided := false
for child in get_children():
if child.scene_file_path == "res://scenes/game/weapon_projectile.tscn" and not child.get("missile_homing"):
found_unguided = true
assert(found_unguided)
var target := Node2D.new()
target.position = Vector2(0, 100)
add_child(target)
var straight := PROJECTILE.instantiate() as Area2D
straight.call("configure", Vector2.ZERO, Vector2.RIGHT, ItemDefinition.WeaponKind.MISSILE, 480.0, 100.0, 1000.0, target)
straight.set("missile_homing", false)
straight.collision_mask = 0
add_child(straight)
straight.set_physics_process(false)
straight.call("_physics_process", 0.5)
assert(straight.position.is_equal_approx(Vector2(240, 0)))
GameState.system_level = 3
GameState.captain.credits = 29
assert(not inventory.buy_missile())
GameState.captain.credits = 30
assert(inventory.buy_missile() and GameState.captain.credits == 0)
GameState.captain.parts = 100.0
GameState.captain.credits = 100
for index in inventory.cargo.size():
if inventory.cargo[index] == null:
inventory.cargo[index] = missile
var ammo_before := inventory.missile_ammunition
assert(inventory.craft_missile() and GameState.captain.parts == 50.0)
assert(inventory.buy_missile() and GameState.captain.credits == 70)
assert(inventory.missile_ammunition == ammo_before + 2)
addon.value = 100.0
assert(inventory.get_missile_cooldown(missile) == 1.0)
var affix := load("res://resources/items/affixes/missile_cooldown.tres") as ItemAffixDefinition
assert(affix.can_roll(ShipInventory.MISSILE_DEFINITIONS[0], 1))
assert(affix.roll_value(1) >= 0.1 and affix.roll_value(1000) >= 1.0)
inventory.preserve_for_restart()
assert(GameState.restart_inventory["weapon"].size() == 3)
var restored := PLAYER.instantiate() as CharacterBody2D
add_child(restored)
restored.set_physics_process(false)
restored.get_node("WeaponController").set_physics_process(false)
var restored_inventory := restored.get_node("ShipInventory") as ShipInventory
assert(restored_inventory.missile_ammunition == inventory.missile_ammunition)
assert(restored_inventory.weapon_slots[2] == unguided)
print("MISSILE_SYSTEM_TEST: PASS (slots, permanent launchers, separate ammo, cooldown, no energy, variants, crafting, purchases, affix, restart)")
get_tree().quit()

View File

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

View File

@ -0,0 +1,4 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/abilities/missile_system_test.gd" id="1"]
[node name="MissileSystemTest" type="Node2D"]
script = ExtResource("1")

View File

@ -51,6 +51,8 @@ func _ready() -> void:
enemy.call("take_weapon_hit", 1000000.0, true)
enemy.call("take_damage", 1000000.0)
assert(profile.level == 2 and profile.experience == 130 and profile.learning_points == 1)
assert(profile.credits == 0)
await _collect_credits(game.get_node("PlayerShip") as CharacterBody2D)
assert(profile.credits == 180)
var panel := game.get_node("HUD/HUDRoot/CaptainPanel")
panel.get_node("Contents/Skills/gunner/Train_gunner").emit_signal("pressed")
@ -117,6 +119,7 @@ func _ready() -> void:
assert(player.get_node("ShipInventory").call("get_item", "weapon", 0) == weapon)
for enemy in get_tree().get_nodes_in_group("enemies"):
enemy.call("take_damage", 1000000.0)
await _collect_credits(player)
assert(profile.level == 4 and profile.experience == 68 and profile.credits == 452)
var planet: Node2D = game.get("protected_planet")
player.position = planet.position
@ -131,12 +134,20 @@ func _ready() -> void:
game = get_tree().current_scene as Node2D
_freeze(game)
assert(GameState.captain == profile and GameState.system_level == 2)
player = game.get_node("PlayerShip") as CharacterBody2D
assert(profile.get_skill_rank("gunner") == 1 and profile.get_skill_rank("sharp_eye") == 20)
var enemy := get_tree().get_nodes_in_group("enemies")[0] as CharacterBody2D
enemy.call("take_damage", 1000000.0)
await _collect_credits(player)
assert(profile.level == 5 and profile.experience == 15 and profile.credits == 641)
print("CAPTAIN_SYSTEM_TEST: PASS (XP, multiple levels, learning UI, rewards once, dodge, reduction, critical addons, projectile metadata, death and next system)")
func _collect_credits(player: CharacterBody2D) -> void:
await get_tree().process_frame
for pickup in get_tree().get_nodes_in_group("credit_pickups"):
player.global_position = pickup.global_position
pickup.call("_physics_process", 0.0)
func _test_skill_balance_and_reset() -> void:
assert(is_equal_approx(BALANCE.skill_bonus("mechanic", 1), 0.5 / 11.0))
assert(is_equal_approx(BALANCE.skill_bonus("mechanic", 5), 1.0 / 6.0))

View File

@ -0,0 +1,51 @@
extends Node2D
const PLAYER := preload("res://scenes/game/player_ship.tscn")
const ENEMY := preload("res://scenes/game/enemy_ship.tscn")
const BALANCE := preload("res://scripts/captain/captain_balance.gd")
func _ready() -> void:
GameState.start_new_run()
GameState.system_level = 4
var ship := PLAYER.instantiate() as CharacterBody2D
ship.name = "PlayerShip"
add_child(ship)
ship.set_physics_process(false)
ship.get_node("WeaponController").set_physics_process(false)
ship.set("credits_multiplier", 1.5)
var inventory := ship.get_node("ShipInventory") as ShipInventory
assert(inventory.ammunition == 50)
var enemy := ENEMY.instantiate() as CharacterBody2D
enemy.position = Vector2(700, 0)
add_child(enemy)
enemy.set_physics_process(false)
enemy.set("_player", ship)
enemy.call("apply_level_settings", 4, 2)
var expected := roundi(BALANCE.KILL_CREDITS[2] * 1.15 * 1.5)
enemy.call("take_damage", enemy.get("max_hull"))
assert(GameState.captain.credits == 0)
assert(GameState.captain.level > 1 or GameState.captain.experience > 0)
await get_tree().process_frame
var pickups := get_tree().get_nodes_in_group("credit_pickups")
assert(pickups.size() == 1)
var pickup := pickups[0] as Node2D
pickup.set_physics_process(false)
assert(pickup.get("amount") == expected)
pickup.call("_physics_process", 0.1)
assert(GameState.captain.credits == 0)
# Credits can be collected even when cargo is full.
var item := ItemInstance.new()
item.definition = preload("res://resources/items/plasma_cannon.tres")
inventory.cargo.fill(item)
ship.position = pickup.position
pickup.call("_physics_process", 0.1)
assert(GameState.captain.credits == expected)
pickup.call("_physics_process", 0.1)
assert(GameState.captain.credits == expected)
var found_popup := false
for child in get_children():
if child is Label and child.text == "+%d KREDYTÓW" % expected:
found_popup = true
assert(found_popup)
print("CREDIT_PICKUP_TEST: PASS (drop, deferred credits, XP, gear bonus, proximity, full cargo, popup, single collection)")
get_tree().quit()

View File

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

View File

@ -0,0 +1,4 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/items/credit_pickup_test.gd" id="1"]
[node name="CreditPickupTest" type="Node2D"]
script = ExtResource("1")

View File

@ -0,0 +1,99 @@
extends Node2D
const PLAYER := preload("res://scenes/game/player_ship.tscn")
const ENEMY := preload("res://scenes/game/enemy_ship.tscn")
class AimedGun:
extends "res://scripts/game/weapon_controller.gd"
func _aim_direction_from(_origin: Vector2) -> Vector2:
return Vector2.RIGHT
func _ready() -> void:
get_tree().create_timer(20.0).timeout.connect(func() -> void:
push_error("MACHINE_GUN_SHIELD_RECOVERY_TEST: timed out")
Input.action_release("ship_fire_primary")
get_tree().quit(1)
)
seed(12345)
GameState.start_new_run()
var ship := PLAYER.instantiate() as CharacterBody2D
ship.name = "PlayerShip"
add_child(ship)
ship.set_physics_process(false)
ship.get_node("WeaponController").set_physics_process(false)
ship.set("shields", 0.0)
ship.set("energy", 0.0)
var inventory := ship.get_node("ShipInventory") as ShipInventory
var gun := ItemInstance.new()
gun.definition = preload("res://resources/items/machine_gun.tres")
assert(inventory.add_item(gun))
assert(inventory.move_item("cargo", 0, "weapon", 0))
var module := ItemInstance.new()
module.definition = preload("res://resources/items/engine_extension.tres")
var addon := ItemStatModifier.new()
addon.affix = preload("res://resources/items/affixes/shield_on_damage.tres")
addon.stat_key = addon.affix.stat_key
addon.value = addon.affix.roll_value(1)
module.additional_modifiers.append(addon)
assert(inventory.add_item(module))
assert(inventory.move_item("cargo", inventory.cargo.find(module), "module", 0))
assert(ship.get("shield_on_damage_percent") == 1.0)
var indicator := Node2D.new()
indicator.set_script(preload("res://scripts/game/ship_energy_indicator.gd"))
indicator.set("player", ship)
ship.add_child(indicator)
var enemy := ENEMY.instantiate() as CharacterBody2D
enemy.position = Vector2(240, -28)
add_child(enemy)
enemy.set_physics_process(false)
enemy.set("_player", ship)
enemy.set("max_hull", 10000.0)
enemy.set("hull", 10000.0)
var controller := AimedGun.new()
ship.add_child(controller)
await get_tree().physics_frame
await get_tree().physics_frame
# Fire a real projectile with the smallest level-1 damage: 2 DMG.
gun.damage_min = 2.0
gun.damage_max = 2.0
Input.action_press("ship_fire_primary")
for frame in 60:
await get_tree().physics_frame
if inventory.ammunition < 50:
break
Input.action_release("ship_fire_primary")
assert(inventory.ammunition == 49)
for frame in 60:
await get_tree().physics_frame
if enemy.get("hull") < 10000.0:
break
assert(is_equal_approx(enemy.get("hull"), 9998.0))
assert(is_equal_approx(ship.get("shields"), 0.02))
print("MACHINE_GUN_RECOVERY: single physical hit: 2 DMG -> +%.4f shields" % ship.get("shields"))
# Restore the normal starter roll (3–5 DMG), then empty the remaining magazine.
gun.damage_min = -1.0
gun.damage_max = -1.0
Input.action_press("ship_fire_primary")
for frame in 600:
await get_tree().physics_frame
if inventory.ammunition == 0:
break
Input.action_release("ship_fire_primary")
assert(inventory.ammunition == 0)
for frame in 45:
await get_tree().physics_frame
var actual_damage := 10000.0 - float(enemy.get("hull"))
var recovered := float(ship.get("shields"))
assert(is_equal_approx(recovered, actual_damage * 0.01))
assert(recovered > 1.0 and ship.get("energy") == 0.0)
indicator.call("_process", 1.0)
assert(is_equal_approx(indicator.get("_display_shield_ratio"), recovered / float(ship.get("max_shields"))))
print("MACHINE_GUN_RECOVERY: 50 physical hits, %.0f total DMG -> +%.4f shields; white bar: %.4f%% (%.3f px)" % [actual_damage, recovered, recovered / float(ship.get("max_shields")) * 100.0, recovered / float(ship.get("max_shields")) * 88.0])
# Empty ammo cannot keep firing or generate any further shields.
Input.action_press("ship_fire_primary")
for frame in 30:
await get_tree().physics_frame
Input.action_release("ship_fire_primary")
assert(is_equal_approx(ship.get("shields"), recovered))
print("MACHINE_GUN_SHIELD_RECOVERY_TEST: PASS (real firing input, projectile flight, collisions, 2-DMG fractions, accumulated recovery, ammo consumption, zero energy, shield bar, empty ammo)")
get_tree().quit()

View File

@ -0,0 +1,4 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/items/machine_gun_shield_recovery_test.gd" id="1"]
[node name="MachineGunShieldRecoveryTest" type="Node2D"]
script = ExtResource("1")

View File

@ -0,0 +1,76 @@
extends Node2D
const PLAYER := preload("res://scenes/game/player_ship.tscn")
const ENEMY := preload("res://scenes/game/enemy_ship.tscn")
const PROJECTILE := preload("res://scenes/game/weapon_projectile.tscn")
func _ready() -> void:
GameState.start_new_run()
var ship := PLAYER.instantiate() as CharacterBody2D
ship.name = "PlayerShip"
add_child(ship)
ship.set_physics_process(false)
var weapons := ship.get_node("WeaponController")
weapons.set_physics_process(false)
var inventory := ship.get_node("ShipInventory") as ShipInventory
var modifier := ItemStatModifier.new()
modifier.stat_key = "shield_on_damage_percent"
modifier.value = 1.0
var module := ItemInstance.new()
module.definition = preload("res://resources/items/engine_extension.tres")
module.additional_modifiers.append(modifier)
assert(inventory.add_item(module))
assert(inventory.move_item("cargo", 0, "module", 0))
assert(ship.get("shield_on_damage_percent") == 1.0)
ship.set("shields", 0.0)
var enemy := _enemy(ship, 100.0)
# A gun without the addon still benefits from the global module stat.
weapons.call("_apply_damage", enemy, 20.0, false)
assert(is_equal_approx(ship.get("shields"), 0.2))
var projectile := PROJECTILE.instantiate() as Area2D
projectile.set("damage", 10.0)
projectile.set("is_critical", true)
add_child(projectile)
projectile.set_physics_process(false)
projectile.call("_apply_damage_once", enemy)
projectile.call("_apply_damage_once", enemy)
assert(is_equal_approx(ship.get("shields"), 0.3))
enemy.call("take_weapon_hit", 1000.0, true)
assert(is_equal_approx(ship.get("shields"), 1.0)) # Only remaining 70 HP.
enemy.call("take_weapon_hit", 1000.0, true)
assert(is_equal_approx(ship.get("shields"), 1.0))
# Addons on weapons and modules sum globally.
inventory.weapon_slots[0].additional_modifiers.append(modifier)
inventory.call("_recalculate_ship_stats")
assert(ship.get("shield_on_damage_percent") == 2.0)
enemy = _enemy(ship, 100.0)
enemy.call("take_damage", 10.0)
assert(is_equal_approx(ship.get("shields"), 1.0))
enemy.call("take_weapon_hit", -5.0, false)
assert(is_equal_approx(ship.get("shields"), 1.0))
enemy.call("take_weapon_hit", 50.0, false)
assert(is_equal_approx(ship.get("shields"), 2.0))
ship.set("shields", ship.get("max_shields") - 0.1)
enemy.call("take_weapon_hit", 10.0, false)
assert(ship.get("shields") == ship.get("max_shields"))
assert(inventory.move_item("module", 0, "cargo", 0))
inventory.weapon_slots[0].additional_modifiers.clear()
inventory.call("_recalculate_ship_stats")
ship.set("shields", 0.0)
enemy.call("take_weapon_hit", 10.0, false)
assert(ship.get("shields") == 0.0)
var affix := load("res://resources/items/affixes/shield_on_damage.tres") as ItemAffixDefinition
assert(affix.roll_value(1) == 1.0 and affix.roll_value(1000) == 5.0)
assert(affix.can_roll(module.definition, 1))
assert(affix.can_roll(inventory.weapon_slots[0].definition, 1))
print("SHIELD_ON_DAMAGE_TEST: PASS (global addon, laser/projectile hits, stacking, actual HP, overkill, duplicate hits, caps, unequipping, scaling)")
get_tree().quit()
func _enemy(ship: CharacterBody2D, hp: float) -> CharacterBody2D:
var enemy := ENEMY.instantiate() as CharacterBody2D
enemy.position = Vector2(700, 0)
add_child(enemy)
enemy.set_physics_process(false)
enemy.set("_player", ship)
enemy.set("hull", hp)
return enemy

View File

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

View File

@ -0,0 +1,4 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/items/shield_on_damage_test.gd" id="1"]
[node name="ShieldOnDamageTest" type="Node2D"]
script = ExtResource("1")

View File

@ -32,7 +32,7 @@ func _ready() -> void:
addon.stat_key = "damage_percent"
addon.value = 10.0
stronger.additional_modifiers.append(addon)
if definition.weapon_kind == ItemDefinition.WeaponKind.MACHINE_GUN:
if definition.weapon_kind in [ItemDefinition.WeaponKind.MACHINE_GUN, ItemDefinition.WeaponKind.MISSILE]:
assert(stronger.get_energy_cost() == 0.0)
else:
assert(stronger.get_energy_cost() > cost_before_addon)
@ -77,7 +77,7 @@ func _ready() -> void:
player.add_child(probe)
probe.set_physics_process(false)
var inventory := player.get_node("ShipInventory") as ShipInventory
assert(inventory.ammunition == 300)
assert(inventory.ammunition == ShipInventory.STARTING_AMMUNITION)
var machine_gun := ItemInstance.new()
machine_gun.definition = preload("res://resources/items/machine_gun.tres")
player.set("energy", 0.0)
@ -92,13 +92,14 @@ func _ready() -> void:
assert(inventory.craft_ammunition())
assert(inventory.ammunition == 100 and GameState.captain.parts == 0.0)
GameState.system_level = 3
assert(inventory.get_ammunition_buy_cost() == 300)
GameState.captain.credits = 299
var buy_cost := ShipInventory.AMMUNITION_BUY_BASE_COST * 3
assert(inventory.get_ammunition_buy_cost() == buy_cost)
GameState.captain.credits = buy_cost - 1
assert(not inventory.buy_ammunition())
assert(inventory.ammunition == 100 and GameState.captain.credits == 299)
GameState.captain.credits = 600
assert(inventory.ammunition == 100 and GameState.captain.credits == buy_cost - 1)
GameState.captain.credits = buy_cost * 2
assert(inventory.buy_ammunition())
assert(inventory.ammunition == 200 and GameState.captain.credits == 300)
assert(inventory.ammunition == 200 and GameState.captain.credits == buy_cost)
assert(inventory.buy_ammunition())
assert(inventory.ammunition == 300 and GameState.captain.credits == 0)
assert(not inventory.buy_ammunition())