Compare commits

...

2 Commits

Author SHA1 Message Date
72925fd557 Zmiany tubylców 2026-10-17 21:28:08 +02:00
a74319d01e balans 2026-10-17 19:46:45 +02:00
32 changed files with 441 additions and 88 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cdy2tv4him7u4"
path="res://.godot/imported/planet_services_preview.png-30cacc45a7eb1c9740376c061502ca8d.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://artifacts/planet_services_preview.png"
dest_files=["res://.godot/imported/planet_services_preview.png-30cacc45a7eb1c9740376c061502ca8d.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View File

@ -7,7 +7,7 @@ icon = ExtResource("icon")
item_id = "gamma_cannon"
display_name = "Działko gamma"
category = 0
description = "Radiacyjna kula. Napromieniowanie: 3 s, 25% obrażeń trafienia/s. Kolejne trafienia odświeżają czas. Zużywa napromieniowaną amunicję."
description = "Radiacyjna kula. Napromieniowanie: 10 s, 25% obrażeń trafienia/s. Kolejne trafienia odświeżają czas. Zużywa napromieniowaną amunicję."
primary_stat = "damage"
primary_value = 15.0
stat_suffix = " DMG"

View File

@ -3,16 +3,16 @@
[ext_resource type="Script" path="res://scripts/ui/planet_services_menu.gd" id="1_services"]
[sub_resource type="StyleBoxFlat" id="PanelStyle"]
bg_color = Color(0.045, 0.075, 0.11, 0.64)
bg_color = Color(0.025, 0.045, 0.065, 0.94)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.28, 0.63, 0.54, 1)
content_margin_left = 14.0
content_margin_top = 14.0
content_margin_right = 14.0
content_margin_bottom = 14.0
content_margin_left = 10.0
content_margin_top = 10.0
content_margin_right = 10.0
content_margin_bottom = 10.0
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_left = 8
@ -21,7 +21,7 @@ corner_radius_bottom_right = 8
[node name="PlanetServicesMenu" type="Control"]
visible = false
layout_mode = 0
offset_right = 350.0
offset_right = 320.0
offset_bottom = 250.0
mouse_filter = 2
script = ExtResource("1_services")
@ -34,18 +34,18 @@ anchor_bottom = 1.0
mouse_filter = 2
[node name="Panel" type="PanelContainer" parent="Center"]
custom_minimum_size = Vector2(350, 250)
custom_minimum_size = Vector2(320, 0)
layout_mode = 2
theme_override_styles/panel = SubResource("PanelStyle")
[node name="Contents" type="VBoxContainer" parent="Center/Panel"]
layout_mode = 2
theme_override_constants/separation = 12
theme_override_constants/separation = 7
[node name="Title" type="Label" parent="Center/Panel/Contents"]
layout_mode = 2
theme_override_colors/font_color = Color(0.47, 0.91, 0.74, 1)
theme_override_font_sizes/font_size = 17
theme_override_font_sizes/font_size = 13
text = "USŁUGI TUBYLCÓW"
horizontal_alignment = 1

View File

@ -174,7 +174,7 @@ func activate_skill(skill: String, ship: CharacterBody2D) -> bool:
if not skill_unavailable_reason(skill, ship).is_empty():
return false
skill_cooldowns[skill] = SKILLS.COOLDOWN
skill_durations[skill] = SKILLS.DURATION
skill_durations[skill] = SKILLS.duration(skill)
if skill == "power_redirect":
ship.set("shields", 0.0)
ship.call("_update_shield_visual")

View File

@ -13,7 +13,7 @@ const DATA := {
"tactical_maneuver": {"name": "Manewr taktyczny", "active": true, "values": [0.30, 0.60, 1.00], "color": "b9a0ef"},
"efficient_magazine": {"name": "Oszczędny magazynek", "active": false, "values": [0.10, 0.20, 0.50], "color": "a9d69a"},
"miner": {"name": "Górnik", "active": false, "values": [3, 6, 10], "color": "e5ac76"},
"power_redirect": {"name": "Przekierowanie zasilania", "active": true, "values": [0.50, 0.90, 1.50], "color": "f28b6e"},
"power_redirect": {"name": "Przekierowanie zasilania", "active": true, "values": [1.00, 1.80, 3.00], "color": "f28b6e"},
}
static func value(skill: String, rank: int) -> float:
@ -21,6 +21,9 @@ static func value(skill: String, rank: int) -> float:
return 0.0
return float(DATA[skill]["values"][clampi(rank, 1, MAX_RANK) - 1])
static func duration(skill: String) -> float:
return 15.0 if skill == "reactor_overload" else (7.0 if skill == "power_redirect" else DURATION)
static func icon(skill: String) -> Texture2D:
return load("res://assets/icons/captain/%s.png" % skill) as Texture2D
@ -36,7 +39,7 @@ static func description(skill: String, rank: int) -> String:
"business_shark":
return "Zakupy za kredyty tańsze o %d%%." % amount
"reactor_overload":
return "Regeneracja energii +%d%% przez 5 sekund." % amount
return "Regeneracja energii +%d%% przez 15 sekund." % amount
"tactical_maneuver":
return "Szansa uniku wynosi %d%% przez 5 sekund. Unik anuluje całe trafienie." % amount
"efficient_magazine":
@ -44,5 +47,5 @@ static func description(skill: String, rank: int) -> String:
"miner":
return "+%d szt. surowca z każdej zniszczonej asteroidy." % roundi(value(skill, rank))
"power_redirect":
return "Regeneracja energii +%d%% przez 5 sekund. Zużywa wszystkie tarcze i blokuje ich odzysk na czas działania. Wymaga min. 25%% tarcz." % amount
return "Regeneracja energii +%d%% przez 7 sekund. Zużywa wszystkie tarcze i blokuje ich odzysk na czas działania. Wymaga min. 25%% tarcz." % amount
return ""

View File

@ -9,7 +9,8 @@ const LOOT_OFFSET := Vector2(0, 36)
const LEVEL_BALANCE := preload("res://scripts/levels/level_balance.gd")
const LASER_COOLDOWN_MULTIPLIER := 2.0
const LASER_MINIMUM_COOLDOWN := 1.4
const LASER_MISS_CHANCE := 0.35
const LASER_MISS_CHANCE := 0.45
const LASER_DAMAGE_MULTIPLIER := 0.8
@export_enum("Normal", "Elite", "Boss") var loot_tier: int = 0
@ -41,9 +42,18 @@ var _weapon_timer := 0.0
var _slow_remaining := 0.0
var _combat_target: Node2D
var virus_controlled := false
var _virus_infection_attempted := false
var _reward_claimed := false
func roll_virus_infection() -> bool:
if loot_tier == 2 or _virus_infection_attempted:
return false
_virus_infection_attempted = true
return randf() < 0.5
func set_virus_controlled(value: bool) -> void:
if value and loot_tier == 2:
return
if virus_controlled == value or is_queued_for_deletion():
return
virus_controlled = value
@ -245,9 +255,21 @@ func select_combat_target() -> Node2D:
closest_distance = distance
if closest != null:
return closest
if is_instance_valid(_player) and not bool(_player.get("_is_destroyed")) and not bool(_player.call("is_cloaked")):
var player_visible := is_instance_valid(_player) and not bool(_player.get("_is_destroyed")) and not bool(_player.call("is_cloaked"))
var nearby_range := maxf(activation_range, weapon_range)
if player_visible and global_position.distance_to(_player.global_position) <= nearby_range:
return _player
return null
# Infected ships become fallback targets only when the player is absent nearby.
for ally in get_tree().get_nodes_in_group("virus_allies"):
if ally.is_queued_for_deletion() or float(ally.get("hull")) <= 0.0:
continue
var distance := global_position.distance_to(ally.global_position)
if distance <= nearby_range and distance < closest_distance:
closest = ally
closest_distance = distance
if closest != null:
return closest
return _player if player_visible else null
func apply_mine_hit() -> void:
if is_queued_for_deletion() or hull <= 0.0:
@ -275,7 +297,7 @@ func _fire_plasma(direction: Vector2) -> void:
endpoint = hit["position"]
var target: Node = hit.get("collider")
if target is CharacterBody2D and target.has_method("take_damage"):
target.take_damage(weapon_damage, false, DamageTypes.Type.ENERGY)
target.take_damage(get_shot_damage(), false, DamageTypes.Type.ENERGY)
var beam := Line2D.new()
beam.set_script(preload("res://scripts/game/laser_beam_visual.gd"))
beam.width = 4.0
@ -335,9 +357,12 @@ func _laser_aim_direction(direction: Vector2) -> Vector2:
return (direction * maxf(1.0, distance) + side * offset).normalized()
return direction.rotated(randf_range(-0.02, 0.02))
func get_shot_damage() -> float:
return weapon_damage * (LASER_DAMAGE_MULTIPLIER if weapon_kind == ItemDefinition.WeaponKind.LASER else 1.0)
func get_weapon_dps() -> float:
var interval := get_fire_interval()
return weapon_damage / maxf(0.001, interval) * (1.0 + 0.25 * minf(3.0, interval) if weapon_kind == ItemDefinition.WeaponKind.GAMMA else 1.0)
return get_shot_damage() / maxf(0.001, interval) * (1.0 + 0.25 * minf(preload("res://scripts/game/radiation_status.gd").DURATION, interval) if weapon_kind == ItemDefinition.WeaponKind.GAMMA else 1.0)
func _reward_captain() -> void:
if is_instance_valid(_player):

View File

@ -1,5 +1,7 @@
extends Node
const DURATION := 10.0
var remaining := 0.0
var tick_damage := 0.0
var credited_to_player := false
@ -16,7 +18,7 @@ static func apply(target: Node, damage_per_second: float, player_hit: bool) -> v
status.set_script(load("res://scripts/game/radiation_status.gd"))
status.name = "RadiationStatus"
target.add_child(status)
status.remaining = 3.0
status.remaining = DURATION
status.tick_damage = maxf(status.tick_damage, damage_per_second)
status.credited_to_player = player_hit

View File

@ -199,6 +199,8 @@ func recycle_asteroid_fragment() -> void:
_update_shield_visual()
func try_control_enemy(enemy: CharacterBody2D) -> bool:
if not is_instance_valid(enemy) or int(enemy.get("loot_tier")) == 2:
return false
var limit := int(get_passive_extension_value("virus"))
if limit <= 0 or _is_destroyed:
return false
@ -208,6 +210,8 @@ func try_control_enemy(enemy: CharacterBody2D) -> bool:
controlled += 1
if controlled >= limit:
return false
if not bool(enemy.call("roll_virus_infection")):
return false
enemy.call("set_virus_controlled", true)
return true

View File

@ -24,7 +24,7 @@ func get_passive_description() -> String:
var value := get_passive_value()
match definition.passive_effect:
"recycler": return "Każdy zebrany odłamek asteroidy odnawia %.1f%% maksymalnych tarcz i energii." % value
"virus": return "Przejmuje uciekających wrogów (poniżej 10%% kadłuba). Zamiast uciekać atakują swoich dawnych sojuszników. Wrogowie nie wybierają ich jako celu. Nadal możesz uszkodzić i zniszczyć je własną bronią. Limit jednocześnie kontrolowanych: %d." % int(value)
"virus": return "Ma 50%% szans na przejęcie uciekającego wroga (poniżej 10%% kadłuba). Jedna próba na statek; losowanie następuje, gdy jest wolne miejsce na przejęcie. Zamiast uciekać atakują swoich dawnych sojuszników. Bossowie są odporni na wirusa. Wrogowie atakują zawirusowane statki, gdy gracza nie ma w pobliżu. Nadal możesz uszkodzić i zniszczyć je własną bronią. Limit jednocześnie kontrolowanych: %d." % int(value)
"titanium_armor": return "Pochłania 50%% otrzymywanych obrażeń, gdy kadłub ma nie więcej niż %d%% maksimum. Próg sprawdzany przed trafieniem." % int(value)
return ""

View File

@ -446,6 +446,6 @@ func get_total_dps() -> float:
for item in weapon_slots:
var value := _weapon_dps(item, bonuses)
if item != null and item.definition.weapon_kind == ItemDefinition.WeaponKind.GAMMA:
value *= 1.0 + 0.25 * minf(3.0, item.definition.fire_interval)
value *= 1.0 + 0.25 * minf(preload("res://scripts/game/radiation_status.gd").DURATION, item.definition.fire_interval)
total += value
return total

View File

@ -6,7 +6,7 @@ const ALIEN_SHIPS := preload("res://scripts/items/alien_ship_catalog.gd")
const WORLD_SIZE := Vector2(5200, 3600)
const PLAYER_START := Vector2(180, 200)
const SAFE_ZONE_RADIUS := 1200.0
const ENEMY_MIN_SEPARATION := 110.0
const ENEMY_MIN_SEPARATION := 420.0
const CELESTIAL_TEXTURES := preload("res://scripts/levels/celestial_texture_catalog.gd")
const STAR_TEXTURES: Array[Texture2D] = CELESTIAL_TEXTURES.STAR_TEXTURES
const PLANET_TEXTURES: Array[Texture2D] = CELESTIAL_TEXTURES.PLANET_TEXTURES
@ -46,23 +46,14 @@ static func generate(level: int, seed_value: int) -> Dictionary:
ship_rng.seed = seed_value ^ (level * 0x53484950)
var enemy_texture_index := ship_rng.randi_range(0, ALIEN_SHIPS.TEXTURES.size() - 1)
var tiers := BALANCE.enemy_tiers(level)
var cursor := 0
while cursor < tiers.size():
var cluster_size := 3 if tiers[cursor] == 2 else rng.randi_range(2, 5)
cluster_size = mini(cluster_size, tiers.size() - cursor)
var planet: Dictionary = planets[rng.randi_range(0, planets.size() - 1)]
var anchor: Vector2 = planet["position"]
anchor += anchor.normalized() * rng.randf_range(250.0, 450.0)
for member in cluster_size:
var position := _find_enemy_position(anchor, planets, enemies, rng)
# Preserve the geometry stream formerly used to choose each ship.
rng.randi_range(0, 10)
enemies.append({
"position": position,
"tier": tiers[cursor],
"texture_index": enemy_texture_index,
})
cursor += 1
# Spread ships across the playable area instead of packing them around planets.
for tier in tiers:
var position := _find_enemy_position(planets, enemies, rng)
enemies.append({
"position": position,
"tier": tier,
"texture_index": enemy_texture_index,
})
return {
"enemy_texture_index": enemy_texture_index,
"star_name": star_name,
@ -74,16 +65,29 @@ static func generate(level: int, seed_value: int) -> Dictionary:
"enemies": enemies,
}
static func _find_enemy_position(anchor: Vector2, planets: Array[Dictionary], enemies: Array[Dictionary], rng: RandomNumberGenerator) -> Vector2:
static func _find_enemy_position(planets: Array[Dictionary], enemies: Array[Dictionary], rng: RandomNumberGenerator) -> Vector2:
var best := Vector2.ZERO
var best_distance := -1.0
var valid_samples := 0
# Prefer the clearest of several random positions to avoid accidental clusters.
for attempt in 3000:
var candidate: Vector2
if attempt < 500:
candidate = anchor + Vector2.RIGHT.rotated(rng.randf_range(0, TAU)) * rng.randf_range(130, 420)
else:
candidate = Vector2(rng.randf_range(-2380, 2380), rng.randf_range(-1580, 1580))
if _is_valid_enemy_position(candidate, planets, enemies):
var candidate := Vector2(rng.randf_range(-2380, 2380), rng.randf_range(-1580, 1580))
if not _is_valid_enemy_position(candidate, planets, enemies):
continue
if enemies.is_empty():
return candidate
# Deterministic fallback for crowded clusters, still respecting the safe area.
var nearest := INF
for enemy in enemies:
nearest = minf(nearest, candidate.distance_squared_to(enemy["position"]))
if nearest > best_distance:
best_distance = nearest
best = candidate
valid_samples += 1
if valid_samples >= 16:
return best
if best_distance >= 0.0:
return best
# Deterministic fallback still respects spacing, planets and the starting area.
for x in range(-2280, 2281, 130):
for y in range(-1500, 1501, 130):
var candidate := Vector2(x, y)

View File

@ -205,7 +205,7 @@ func _choice_card(skill: String) -> PanelContainer:
description.size_flags_vertical = Control.SIZE_EXPAND_FILL
body.add_child(description)
if data["active"]:
body.add_child(_label("DZIAŁANIE 5 s · ODNOWIENIE 30 s", 11, Color("91aabd")))
body.add_child(_label("DZIAŁANIE %d s · ODNOWIENIE 30 s" % int(CATALOG.duration(skill)), 11, Color("91aabd")))
else:
body.add_child(_label("STAŁY EFEKT", 11, Color("91aabd")))
var select := Button.new()

View File

@ -2,6 +2,8 @@ extends Control
signal next_level_requested
const BUTTON_STYLE := preload("res://scripts/ui/hud_button_style.gd")
const CREDIT_ICON := preload("res://assets/icons/resources/credits.svg")
@onready var next_button: Button = $Center/Panel/Contents/NextButton
@onready var buy_ammunition_button: Button = $Center/Panel/Contents/BuyAmmunitionButton
@onready var reset_skills_button: Button = $Center/Panel/Contents/ResetSkillsButton
@ -13,32 +15,30 @@ var _inventory: ShipInventory
var planet: Node2D
var _open_tween: Tween
var _missile_button: Button
var _gamma_buy: Button
var _stock_labels: Dictionary = {}
func _ready() -> void:
get_parent().get_node("FloatingWindows").call("register_window", self, panel, $Center/Panel/Contents/Title)
_gamma_buy = Button.new()
_gamma_buy.name = "BuyIrradiatedAmmunition"
_gamma_buy.custom_minimum_size.y = 36
_gamma_buy.add_theme_font_size_override("font_size", 12)
_gamma_buy.focus_mode = Control.FOCUS_NONE
$Center/Panel/Contents.add_child(_gamma_buy)
_gamma_buy.pressed.connect(func() -> void: _inventory.buy_irradiated_ammunition())
_missile_button = Button.new()
_missile_button.name = "BuyMissile"
var contents := $Center/Panel/Contents
contents.add_child(_gamma_buy)
contents.add_child(_missile_button)
reset_skills_button.hide()
_build_compact_shop()
get_parent().get_node("FloatingWindows").call("register_window", self, panel, contents.get_node("Title"))
next_button.pressed.connect(func() -> void: next_level_requested.emit())
buy_ammunition_button.pressed.connect(_buy_ammunition)
reset_skills_button.hide()
_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."
_gamma_buy.pressed.connect(func() -> void:
if visible and _inventory:
_inventory.buy_irradiated_ammunition()
)
_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)
@ -46,11 +46,100 @@ func _ready() -> void:
GameState.captain.changed.connect(_refresh_ammunition_service)
_refresh_ammunition_service()
func _build_compact_shop() -> void:
var contents := $Center/Panel/Contents
contents.add_theme_constant_override("separation", 7)
status_label.add_theme_font_size_override("font_size", 10)
status_label.add_theme_color_override("font_color", Color("86b7aa"))
services_label.add_theme_font_size_override("font_size", 11)
services_label.add_theme_color_override("font_color", Color("efc982"))
services_label.size_flags_vertical = Control.SIZE_FILL
var wallet := HBoxContainer.new()
wallet.name = "Wallet"
wallet.alignment = BoxContainer.ALIGNMENT_CENTER
wallet.add_theme_constant_override("separation", 6)
contents.add_child(wallet)
contents.move_child(wallet, 2)
var money_icon := TextureRect.new()
money_icon.texture = CREDIT_ICON
money_icon.custom_minimum_size = Vector2(15, 15)
money_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
money_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
money_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
wallet.add_child(money_icon)
services_label.reparent(wallet)
var offers := HBoxContainer.new()
offers.name = "Offers"
offers.add_theme_constant_override("separation", 6)
contents.add_child(offers)
contents.move_child(offers, 3)
_add_offer(offers, buy_ammunition_button, "ammunition", "Zwykła", 100, Color("e8b46f"))
_add_offer(offers, _gamma_buy, "irradiated_ammunition", "Radiacyjna", 10, Color("add580"))
_add_offer(offers, _missile_button, "missile", "Rakieta", 1, Color("82cddd"))
BUTTON_STYLE.apply(next_button, Color("78d7be"))
next_button.custom_minimum_size = Vector2(0, 34)
next_button.add_theme_font_size_override("font_size", 11)
next_button.icon = preload("res://assets/icons/extensions/teleport_art.png")
next_button.expand_icon = true
next_button.add_theme_constant_override("icon_max_width", 21)
contents.move_child(next_button, contents.get_child_count() - 1)
func _add_offer(offers: HBoxContainer, button: Button, item_id: String, caption: String, amount: int, accent: Color) -> void:
var card := PanelContainer.new()
card.name = item_id.capitalize().replace(" ", "") + "Offer"
card.custom_minimum_size.x = 94
card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var style := StyleBoxFlat.new()
style.bg_color = Color("101e2a")
style.border_color = accent.darkened(0.65)
style.set_border_width_all(1)
style.border_width_top = 2
style.set_corner_radius_all(6)
style.set_content_margin_all(6)
card.add_theme_stylebox_override("panel", style)
offers.add_child(card)
var column := VBoxContainer.new()
column.add_theme_constant_override("separation", 4)
card.add_child(column)
var icon := TextureRect.new()
icon.texture = load("res://assets/icons/resources/%s.png" % item_id)
icon.custom_minimum_size = Vector2(36, 36)
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
column.add_child(icon)
var title := Label.new()
title.text = "%s ×%d" % [caption, amount]
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.add_theme_font_size_override("font_size", 10)
title.add_theme_color_override("font_color", accent.lightened(0.2))
column.add_child(title)
var stock := Label.new()
stock.name = "Stock"
stock.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
stock.add_theme_font_size_override("font_size", 9)
stock.add_theme_color_override("font_color", Color("8ba4b7"))
column.add_child(stock)
_stock_labels[item_id] = stock
button.reparent(column)
BUTTON_STYLE.apply(button, accent)
button.custom_minimum_size = Vector2(0, 28)
button.add_theme_font_size_override("font_size", 10)
button.icon = CREDIT_ICON
button.expand_icon = true
button.add_theme_constant_override("icon_max_width", 12)
for state in ["normal", "hover", "pressed", "disabled"]:
var frame := button.get_theme_stylebox(state).duplicate() as StyleBoxFlat
frame.set_content_margin_all(4)
frame.set_corner_radius_all(4)
frame.shadow_size = 0
button.add_theme_stylebox_override(state, frame)
func show_services(at_level_cap: bool, portal_open: bool = false) -> void:
_refresh_ammunition_service()
next_button.disabled = at_level_cap or portal_open
next_button.text = "OSIĄGNIĘTO OSTATNI UKŁAD" if at_level_cap else ("WORMHOLE OTWARTY" if portal_open else "IDŹ DO NASTĘPNEGO POZIOMU")
status_label.text = "Wleć w wormhole, aby przejść dalej." if portal_open else "Układ bezpieczny. Usługi dostępne."
next_button.text = "OSTATNI UKŁAD" if at_level_cap else ("WORMHOLE OTWARTY" if portal_open else "OTWÓRZ WORMHOLE")
status_label.text = "Wleć w wormhole, aby przejść dalej." if portal_open else "Bezpieczny układ · zaopatrzenie"
if visible:
return
show()
@ -81,30 +170,31 @@ func _position_at_planet() -> void:
size = panel.get_combined_minimum_size()
var viewport_size := get_viewport_rect().size
var sidebar := get_parent().get_node_or_null("ShipDrawer") as Control
var screen_right := viewport_size.x - (sidebar.size.x + 8.0 if sidebar != null and sidebar.visible else 8.0)
var screen_right := minf(viewport_size.x - 8.0, sidebar.get_global_rect().position.x - 8.0) if sidebar != null and sidebar.visible else viewport_size.x - 8.0
var target := planet_screen + Vector2(planet_radius + 24.0, -size.y * 0.5)
if target.x + size.x > screen_right:
target.x = planet_screen.x - planet_radius - 24.0 - size.x
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 - 150.0)))
func _refresh_ammunition_service() -> void:
if _gamma_buy != null:
_gamma_buy.text = "KUP 10 NAPROMIENIOWANEJ — %d KREDYTÓW" % (_inventory.get_irradiated_ammunition_buy_cost() if _inventory else ShipInventory.IRRADIATED_AMMUNITION_BUY_BASE_COST * maxi(1, GameState.system_level))
_gamma_buy.disabled = _inventory == null or not _inventory.can_buy_irradiated_ammunition()
_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()
var ordinary_cost := _inventory.get_ammunition_buy_cost() if _inventory else GameState.captain.discounted_price(ShipInventory.AMMUNITION_BUY_BASE_COST * maxi(1, GameState.system_level))
var gamma_cost := _inventory.get_irradiated_ammunition_buy_cost() if _inventory else GameState.captain.discounted_price(ShipInventory.IRRADIATED_AMMUNITION_BUY_BASE_COST * maxi(1, GameState.system_level))
var missile_cost := _inventory.get_missile_buy_cost() if _inventory else GameState.captain.discounted_price(ShipInventory.MISSILE_BUY_BASE_COST * maxi(1, GameState.system_level))
buy_ammunition_button.text = "KUP %d" % ordinary_cost
_gamma_buy.text = "KUP %d" % gamma_cost
_missile_button.text = "KUP %d" % missile_cost
buy_ammunition_button.disabled = _inventory == null or not _inventory.can_buy_ammunition()
if _inventory:
buy_ammunition_button.text = "KUP %d AMUNICJI — %d KREDYTÓW" % [ShipInventory.AMMUNITION_BUY_AMOUNT, _inventory.get_ammunition_buy_cost()]
services_label.text = "Amunicja: %d • Kredyty: %d\nRekin biznesu: −%d%% ceny zakupów" % [_inventory.ammunition, GameState.captain.credits, roundi(GameState.captain.get_skill_bonus("business_shark") * 100)]
_gamma_buy.disabled = _inventory == null or not _inventory.can_buy_irradiated_ammunition()
_missile_button.disabled = _inventory == null or not _inventory.can_buy_missile()
buy_ammunition_button.tooltip_text = "Kup 100 zwykłych pocisków za %d kredytów." % ordinary_cost
_gamma_buy.tooltip_text = "Kup 10 pocisków radiacyjnych za %d kredytów." % gamma_cost
_missile_button.tooltip_text = "Kup 1 rakietę za %d kredytów." % missile_cost
var discount := roundi(GameState.captain.get_skill_bonus("business_shark") * 100)
services_label.text = "%d kredytów%s" % [GameState.captain.credits, " · rabat %d%%" % discount if discount > 0 else ""]
_stock_labels["ammunition"].text = "Zapas: %d" % (_inventory.ammunition if _inventory else 0)
_stock_labels["irradiated_ammunition"].text = "Zapas: %d" % (_inventory.irradiated_ammunition if _inventory else 0)
_stock_labels["missile"].text = "Zapas: %d" % (_inventory.missile_ammunition if _inventory else 0)
func _buy_ammunition() -> void:
if visible and _inventory:
_inventory.buy_ammunition()
func _reset_skills() -> void:
if visible:
GameState.captain.reset_skills()

View File

@ -27,6 +27,14 @@ func enemy_at(point: Vector2) -> CharacterBody2D:
enemy.set("_weapon_timer", 100.0)
return enemy
func seed_successful_infection() -> void:
for value in 100:
seed(value)
if randf() < 0.5:
seed(value)
return
assert(false)
func _ready() -> void:
await get_tree().process_frame
GameState.start_new_run()
@ -108,6 +116,7 @@ func _ready() -> void:
var third := enemy_at(Vector2(450, 0))
first.hull = 1
second.hull = 1
seed_successful_infection()
first.call("_physics_process", 0.01)
second.call("_physics_process", 0.01)
assert(first.virus_controlled and not second.virus_controlled)
@ -135,6 +144,7 @@ func _ready() -> void:
assert(ship.shields == player_shields)
# Increased capacity takes another ship; downgrade and unequip release extras.
virus.passive_value = 2
seed_successful_infection()
second.call("_physics_process", 0.01)
assert(second.virus_controlled)
virus.passive_value = 1

View File

@ -0,0 +1,23 @@
extends Node
func _ready() -> void:
var script := preload("res://scripts/game/enemy_ship.gd")
seed(20261017)
var successes := 0
var failures := 0
for index in 2000:
var enemy := script.new()
var won: bool = enemy.roll_virus_infection()
successes += int(won)
failures += int(not won)
assert(not enemy.roll_virus_infection(), "A second frame must never reroll infection")
assert(not enemy.roll_virus_infection(), "Failed rolls must remain failed")
enemy.free()
assert(successes >= 900 and successes <= 1100)
assert(failures > 0 and successes > 0)
var boss := script.new()
boss.loot_tier = 2
for index in 100:
assert(not boss.roll_virus_infection())
boss.free()
print("VIRUS_CHANCE_TEST: PASS ", successes, "/2000 first attempts, no rerolls, bosses immune")
get_tree().quit()

View File

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

View File

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

View File

@ -25,6 +25,17 @@ func _ready() -> void:
infected.shields = infected.max_shields
assert(hostile.select_combat_target() == ship)
assert(infected.select_combat_target() == hostile)
ship.position = Vector2(-2000, 0)
assert(hostile.select_combat_target() == infected)
ship.position = Vector2.ZERO
assert(hostile.select_combat_target() == ship)
var boss := make_enemy(Vector2(4000, 0))
boss.apply_level_settings(1, 2)
boss.hull = 1.0
assert(not ship.try_control_enemy(boss))
boss.set_virus_controlled(true)
assert(not boss.virus_controlled and boss.is_in_group("enemies"))
boss.queue_free()
var xp_after_conversion := GameState.captain.experience
await get_tree().physics_frame
await get_tree().physics_frame

View File

@ -75,13 +75,17 @@ func _ready() -> void:
captain.tick_skills(4.9)
assert(not captain.is_skill_active("power_redirect"))
assert(ship.get_evasion_chance() == 0.0)
assert(is_equal_approx(captain.energy_regeneration_multiplier(), 1.0))
assert(captain.is_skill_active("reactor_overload"))
assert(is_equal_approx(captain.energy_regeneration_multiplier(), 1.0 + CATALOG.value("reactor_overload", rank)))
assert(is_equal_approx(captain.skill_cooldowns["reactor_overload"], 24.9))
ship._physics_process(0.1)
assert(ship.shields > 0.0)
ship.shield_regeneration = previous_regeneration
ship.restore_shields_from_damage(10.0)
assert(ship.shields > 0.0)
captain.tick_skills(10.0)
assert(not captain.is_skill_active("reactor_overload"))
assert(is_equal_approx(captain.energy_regeneration_multiplier(), 1.0))
captain.tick_skills(25.0)
assert(captain.skill_cooldowns["reactor_overload"] == 0.0)

View File

@ -0,0 +1,39 @@
extends Node
func _ready() -> void:
GameState.start_new_run()
var ship := preload("res://scenes/game/player_ship.tscn").instantiate()
add_child(ship)
ship.set_physics_process(false)
ship.get_node("WeaponController").set_physics_process(false)
var captain := GameState.captain
captain.skills = {"reactor_overload": 1, "tactical_maneuver": 1}
captain.equipped_skills = ["reactor_overload", "tactical_maneuver", "", "", ""]
assert(captain.activate_skill("reactor_overload", ship))
assert(captain.activate_skill("tactical_maneuver", ship))
assert(captain.skill_durations.reactor_overload == 15.0)
assert(captain.skill_cooldowns.reactor_overload == 30.0)
captain.tick_skills(5.0)
assert(captain.is_skill_active("reactor_overload") and not captain.is_skill_active("tactical_maneuver"))
captain.tick_skills(9.9)
assert(captain.is_skill_active("reactor_overload"))
captain.tick_skills(0.11)
assert(not captain.is_skill_active("reactor_overload"))
assert(preload("res://scripts/captain/captain_skills.gd").description("reactor_overload", 1).contains("15 sekund"))
for rank in range(1, 4):
captain.skill_cooldowns.clear()
captain.skills["power_redirect"] = rank
assert(captain.equip_skill(2, "power_redirect"))
ship.shields = ship.max_shields
assert(captain.activate_skill("power_redirect", ship))
assert(ship.shields == 0.0)
assert(captain.skill_durations.power_redirect == 7.0)
assert(captain.skill_cooldowns.power_redirect == 30.0)
assert(is_equal_approx(captain.energy_regeneration_multiplier(), 1.0 + [1.0, 1.8, 3.0][rank - 1]))
captain.tick_skills(6.99)
assert(captain.is_skill_active("power_redirect"))
captain.tick_skills(0.02)
assert(not captain.is_skill_active("power_redirect"))
assert(is_equal_approx(captain.energy_regeneration_multiplier(), 1.0))
assert(preload("res://scripts/captain/captain_skills.gd").description("power_redirect", 3).contains("7 sekund"))
print("REACTOR_DURATION_TEST: PASS overload 15s, redirection 7s at +100/180/300%, 30s cooldown, tactical maneuver 5s")
get_tree().quit()

View File

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

View File

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

View File

@ -69,9 +69,9 @@ func _ready() -> void:
assert(is_equal_approx(enemy.hull, 76))
var status: Node = enemy.get_node("RadiationStatus")
status.set_physics_process(false)
for index in 6:
for index in 20:
status._physics_process(0.5)
assert(is_equal_approx(enemy.hull, 70))
assert(is_equal_approx(enemy.hull, 56))
assert(is_equal_approx(status.remaining, 0))
assert(enemy.get_node("Sprite2D").modulate == Color.WHITE)
for kind in 5:

View File

@ -18,15 +18,17 @@ func _ready() -> void:
enemy.weapon_damage = 1.0
enemy.weapon_cooldown = 1.5
assert(is_equal_approx(enemy.get_fire_interval(), 3.0))
assert(is_equal_approx(enemy.get_weapon_dps(), 1.0 / 3.0))
assert(is_equal_approx(enemy.get_shot_damage(), 0.8))
assert(is_equal_approx(enemy.get_weapon_dps(), 0.8 / 3.0))
assert(is_equal_approx(enemy.LASER_MISS_CHANCE, 0.45))
enemy.weapon_cooldown = 0.275
assert(enemy.get_fire_interval() >= 1.4)
await get_tree().physics_frame
await get_tree().physics_frame
for index in 100:
enemy._fire_plasma(Vector2.LEFT)
var hits := 1000.0 - float(ship.shields)
assert(hits >= 45.0 and hits <= 85.0)
var hits := (1000.0 - float(ship.shields)) / 0.8
assert(hits >= 35.0 and hits <= 75.0)
print("ENEMY_LASER_TEST: PASS (slower cooldown, minimum interval, displayed DPS, %d real hits / 100 shots)" % roundi(hits))
await get_tree().create_timer(0.15).timeout
get_tree().quit()

View File

@ -0,0 +1,30 @@
extends Node
const RADIATION = preload("res://scripts/game/radiation_status.gd")
class Target:
extends CharacterBody2D
var total_damage := 0.0
func take_damage(amount: float, _critical: bool, _kind: int) -> void:
total_damage += amount
func take_weapon_hit(amount: float, critical: bool, kind: int) -> void:
take_damage(amount, critical, kind)
func _ready() -> void:
var target := Target.new()
add_child(target)
RADIATION.apply(target, 2.0, true)
var status := target.get_node("RadiationStatus")
status.set_physics_process(false)
assert(status.remaining == 10.0)
for index in 6:
status._physics_process(0.5)
assert(status.remaining == 7.0 and target.total_damage == 6.0)
RADIATION.apply(target, 1.0, true)
assert(status.remaining == 10.0 and status.tick_damage == 2.0)
for index in 20:
status._physics_process(0.5)
assert(status.remaining == 0.0 and target.total_damage == 26.0)
status._physics_process(1.0)
assert(target.total_damage == 26.0)
assert(preload("res://resources/items/gamma_cannon.tres").description.contains("10 s"))
print("RADIATION_DURATION_TEST: PASS 10s damage, refreshed timer, unchanged per-second damage, expiry and description")
get_tree().quit()

View File

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

View File

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

View File

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

View File

@ -0,0 +1,44 @@
extends "res://scripts/game/game.gd"
func _ready() -> void:
super._ready()
set_physics_process(false)
player.set_physics_process(false)
player.get_node("WeaponController").set_physics_process(false)
for enemy in get_tree().get_nodes_in_group("enemies"):
enemy.set_physics_process(false)
player.position = protected_planet.position + Vector2(float(protected_planet.get("radius")) + 110.0, 0)
player.reset_physics_interpolation()
GameState.captain.credits = 2500
GameState.captain.skills["business_shark"] = 2
var inventory := player.get_node("ShipInventory") as ShipInventory
inventory.ammunition = 240
inventory.irradiated_ammunition = 12
inventory.missile_ammunition = 7
services_menu.show_services(false)
await get_tree().create_timer(0.5).timeout
assert(services_menu.get("panel").size.x <= 340.0)
assert(services_menu.get("panel").size.y <= 290.0)
if "--capture-preview" in OS.get_cmdline_user_args():
await RenderingServer.frame_post_draw
get_viewport().get_texture().get_image().save_png("res://artifacts/planet_services_preview.png")
print("PLANET_SERVICES_PREVIEW: captured ", services_menu.get("panel").size)
var credits := GameState.captain.credits
var cost := inventory.get_ammunition_buy_cost()
services_menu.get("buy_ammunition_button").pressed.emit()
assert(inventory.ammunition == 340 and GameState.captain.credits == credits - cost)
credits = GameState.captain.credits
cost = inventory.get_irradiated_ammunition_buy_cost()
services_menu.get("_gamma_buy").pressed.emit()
assert(inventory.irradiated_ammunition == 22 and GameState.captain.credits == credits - cost)
credits = GameState.captain.credits
cost = inventory.get_missile_buy_cost()
services_menu.get("_missile_button").pressed.emit()
assert(inventory.missile_ammunition == 8 and GameState.captain.credits == credits - cost)
GameState.captain.credits = 0
GameState.captain.emit_changed()
assert(services_menu.get("buy_ammunition_button").disabled and services_menu.get("_gamma_buy").disabled and services_menu.get("_missile_button").disabled)
services_menu.show_services(false, true)
assert(services_menu.get("next_button").disabled)
print("PLANET_SERVICES_TEST: PASS compact dimensions, all purchases, discounts, stock refresh, insufficient credits and portal state")
get_tree().quit()

View File

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

View File

@ -0,0 +1,5 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="PackedScene" path="res://scenes/game/game.tscn" id="1"]
[ext_resource type="Script" path="res://tests/previews/planet_services_preview.gd" id="2"]
[node name="PlanetServicesPreview" instance=ExtResource("1")]
script = ExtResource("2")