This commit is contained in:
Tomasz Boruc 2026-10-16 23:36:49 +02:00
parent 72839139eb
commit 458db974c0
29 changed files with 602 additions and 13 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://d4hxo2htne3yy"
path="res://.godot/imported/combat_drone.png-1ee839fafe5ff3881843088267b833ba.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/Ships/combat_drone.png"
dest_files=["res://.godot/imported/combat_drone.png-1ee839fafe5ff3881843088267b833ba.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=true
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://baxcqpl7y3dfd"
path="res://.godot/imported/electrician.png-cd4ace91776ba47a19c9950998a779a9.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/icons/captain/electrician.png"
dest_files=["res://.godot/imported/electrician.png-cd4ace91776ba47a19c9950998a779a9.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://huejfa7inar0"
path="res://.godot/imported/combat_drones_art.png-ce5df9b0b0abb663da961f963a875f37.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/icons/extensions/combat_drones_art.png"
dest_files=["res://.godot/imported/combat_drones_art.png-ce5df9b0b0abb663da961f963a875f37.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

@ -0,0 +1,17 @@
# Elektryk i drony bojowe
Wygenerowano narzędziem wbudowanym `image_gen`, z przezroczystym tłem.
## Elektryk — captain/electrician.png
Use case: stylized-concept. Asset type: square transparent sci-fi spaceship game skill icon, high quality painted game UI art. Subject: Elektryk, futuristic engineer's armored gloved hand holding a compact metallic powerbank with a brilliant cyan lightning core, copper wire contacts and tiny electric arcs. Single strong readable centered silhouette, navy steel copper and electric turquoise palette, crisp bevels, cinematic rim lighting, detailed yet readable at 48px, generous transparent margins. No text, letters, frame, background or watermark. Save the PNG asset for the project.
## Drony bojowe — extensions/combat_drones_art.png
Use case: stylized-concept. Asset type: transparent square sci-fi game equipment icon. Subject three compact autonomous combat drones in triangular formation, metallic gunmetal hulls with angular swept wings, twin small kinetic gun barrels and bright cyan engine cores. Top down three quarter view, polished hand painted realistic spaceship inventory art, crisp strong silhouettes readable at 48px, restrained gold accents and cyan rim lighting, isolated centered with transparent margins. No lettering, frame, background or watermark. Save PNG for game project.
## Dron w rozgrywce — ../Ships/combat_drone.png
Wygenerowano wbudowanym `image_gen`, z przezroczystym tłem. Kadłub jest teksturą; silniki i błyski działek animuje combat_drone.gd.
Use case: stylized-concept. Asset type: production gameplay sprite for a top-down 2D space shooter, transparent PNG. ONE small autonomous combat drone spaceship, perfectly orthographic TOP DOWN view, nose pointing straight UP, bilateral symmetry. Compact gunmetal titanium armored fuselage, swept angular short wings, two small forward-facing mechanical autocannons ending near the nose, two cyan engine exhaust ports at rear, tiny turquoise sensors, restrained brass details. Detailed painted realistic sci-fi spacecraft, crisp edges and clear silhouette readable at 44 pixels tall. Centered entire ship with 12 percent transparent padding. Lighting from upper left, dark panel grooves and bright metallic edge highlights. No pilot, perspective, isometric angle, cast shadow, engine flame, muzzle flash, text, UI, frame, scenery, stars, watermark or other spacecraft. Engines and muzzle flashes are animated separately in the game.

View File

@ -7,7 +7,7 @@ script = ExtResource("2")
ability_id = "cloak" ability_id = "cloak"
cooldown = 60.0 cooldown = 60.0
display_name = "Kamuflaż" display_name = "Kamuflaż"
description = "Przez 10 s statek staje się niemal niewidoczny. Przeciwnicy nie wykrywają go ani nie śledzą. Nie zapewnia odporności na już wystrzelone pociski. Odnowienie: 60 s." description = "Przez 10 s statek staje się niemal niewidoczny. Przeciwnicy nie wykrywają go ani nie śledzą. Oddanie strzału przerywa kamuflaż. Nie zapewnia odporności na już wystrzelone pociski. Odnowienie: 60 s."
kind = 1 kind = 1
[resource] [resource]
script = ExtResource("1") script = ExtResource("1")

View File

@ -0,0 +1,19 @@
[gd_resource type="Resource" script_class="ItemDefinition" load_steps=5 format=3]
[ext_resource type="Script" path="res://scripts/items/item_definition.gd" id="1"]
[ext_resource type="Script" path="res://scripts/abilities/tactical_extension_ability.gd" id="2"]
[ext_resource type="Texture2D" path="res://assets/icons/extensions/combat_drones_art.png" id="icon"]
[sub_resource type="Resource" id="Ability"]
script = ExtResource("2")
ability_id = "combat_drones"
cooldown = 30.0
display_name = "Drony bojowe"
description = "Wypuszcza 3 drony na 10 s. Strzelają działkami mechanicznymi; łącznie zadają 50% DPS statku w momencie startu. Koszt: 50 szt. zwykłej amunicji. Odnowienie: 30 s."
kind = 5
[resource]
script = ExtResource("1")
icon = ExtResource("icon")
item_id = "combat_drones"
display_name = "Drony bojowe"
category = 5
description = "Aktywne rozszerzenie statku. Zamontuj w slocie rozszerzeń i użyj przypisanego klawisza 1–5."
active_ability = SubResource("Ability")

View File

@ -1,9 +1,16 @@
extends ActiveAbility extends ActiveAbility
@export_enum("Teleport", "Cloak", "Mine", "Flares", "Solar shields") var kind := 0 @export_enum("Teleport", "Cloak", "Mine", "Flares", "Solar shields", "Combat drones") var kind := 0
func unavailable_reason(ship: CharacterBody2D) -> String: func unavailable_reason(ship: CharacterBody2D) -> String:
match kind: match kind:
5:
if float(ship.get_node("ExtensionSystems").get("drones_remaining")) > 0.0:
return "Drony już walczą"
if int(ship.get_node("ShipInventory").get("ammunition")) < 50:
return "Wymagane 50 szt. zwykłej amunicji"
if float(ship.get_node("ShipInventory").call("get_total_dps")) <= 0.0:
return "Wymagana zamontowana broń"
0: 0:
if float(ship.get("energy")) <= 0.0: if float(ship.get("energy")) <= 0.0:
return "Brak energii" return "Brak energii"

View File

@ -32,6 +32,9 @@ func get_skill_rank(skill: String) -> int:
func get_skill_bonus(skill: String) -> float: func get_skill_bonus(skill: String) -> float:
return SKILLS.value(skill, get_skill_rank(skill)) return SKILLS.value(skill, get_skill_rank(skill))
func get_energy_bank_fraction() -> float:
return maxf(0.10, get_skill_bonus("electrician"))
func normalize_skill_ranks() -> void: func normalize_skill_ranks() -> void:
for skill in skills.keys(): for skill in skills.keys():
if not SKILLS.DATA.has(skill): if not SKILLS.DATA.has(skill):

View File

@ -3,12 +3,13 @@ extends RefCounted
const MAX_RANK := 3 const MAX_RANK := 3
const COOLDOWN := 30.0 const COOLDOWN := 30.0
const DURATION := 5.0 const DURATION := 5.0
const IDS := ["business_shark", "reactor_overload", "tactical_maneuver", "efficient_magazine", "miner", "power_redirect", "lucky", "explorer"] const IDS := ["business_shark", "reactor_overload", "tactical_maneuver", "efficient_magazine", "miner", "power_redirect", "lucky", "explorer", "electrician"]
const DATA := { const DATA := {
"electrician": {"name": "Elektryk", "active": false, "values": [0.15, 0.20, 0.25], "color": "70f5df"},
"lucky": {"name": "Farciarz", "active": false, "values": [0.05, 0.10, 0.25], "color": "e8c76f"}, "lucky": {"name": "Farciarz", "active": false, "values": [0.05, 0.10, 0.25], "color": "e8c76f"},
"explorer": {"name": "Eksplorator", "active": false, "values": [1, 3, 6], "color": "76d9cc"}, "explorer": {"name": "Eksplorator", "active": false, "values": [1, 3, 6], "color": "76d9cc"},
"business_shark": {"name": "Rekin biznesu", "active": false, "values": [0.10, 0.30, 0.60], "color": "efb568"}, "business_shark": {"name": "Rekin biznesu", "active": false, "values": [0.10, 0.30, 0.60], "color": "efb568"},
"reactor_overload": {"name": "Przeciążenie reaktora", "active": true, "values": [0.10, 0.20, 0.70], "color": "63d9f5"}, "reactor_overload": {"name": "Przeciążenie reaktora", "active": true, "values": [0.20, 0.50, 1.00], "color": "63d9f5"},
"tactical_maneuver": {"name": "Manewr taktyczny", "active": true, "values": [0.30, 0.60, 1.00], "color": "b9a0ef"}, "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"}, "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"}, "miner": {"name": "Górnik", "active": false, "values": [3, 6, 10], "color": "e5ac76"},
@ -27,6 +28,8 @@ static func icon(skill: String) -> Texture2D:
static func description(skill: String, rank: int) -> String: static func description(skill: String, rank: int) -> String:
var amount := roundi(value(skill, rank) * 100.0) var amount := roundi(value(skill, rank) * 100.0)
match skill: match skill:
"electrician":
return "Powerbank przywraca %d%% maksymalnej energii zamiast 10%%. Umiejętność pasywna; premie rang nie sumują się." % amount
"lucky": "lucky":
return "Szczęście: %d%% szans na przedmiot o 1 poziom wyższy niż bazowy loot planszy. Dotyczy lootu z wrogów i znalezisk. Bazowo: 0%%. Sumuje się ze sprzętem do limitu 60%%. Premie rang nie sumują się." % amount return "Szczęście: %d%% szans na przedmiot o 1 poziom wyższy niż bazowy loot planszy. Dotyczy lootu z wrogów i znalezisk. Bazowo: 0%%. Sumuje się ze sprzętem do limitu 60%%. Premie rang nie sumują się." % amount
"explorer": "explorer":

View File

@ -0,0 +1,116 @@
extends Node2D
const PROJECTILE := preload("res://scenes/game/weapon_projectile.tscn")
const FIRE_INTERVAL := 0.25
const HULL_TEXTURE := preload("res://assets/Ships/combat_drone.png")
var mother: CharacterBody2D
var formation_index := 0
var damage_per_shot := 1.0
var remaining := 10.0
var _fire_timer := 0.0
var _elapsed := 0.0
var _flash := 0.0
var velocity := Vector2.ZERO
var _turn_velocity := 0.0
const FORMATION := [Vector2(0, -110), Vector2(-35, -78), Vector2(35, -78)]
func _ready() -> void:
add_to_group("player_combat_drones")
z_index = 4
scale = Vector2.ONE * 0.5
rotation = mother.rotation
velocity = mother.velocity
texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
_fire_timer = formation_index * FIRE_INTERVAL / 3.0
queue_redraw()
func _physics_process(delta: float) -> void:
remaining -= delta
if remaining <= 0.0 or not is_instance_valid(mother) or bool(mother.get("_is_destroyed")) or bool(mother.get("is_docked")):
queue_free()
return
_elapsed += delta
_flash = maxf(0.0, _flash - delta)
_fire_timer = maxf(0.0, _fire_timer - delta)
var target := _find_target()
var destination := formation_position()
var escort_velocity := mother.velocity
if target != null:
var anchor := mother.global_position.lerp(target.global_position, 0.45)
destination = anchor + Vector2.from_angle(TAU * formation_index / 3.0 + _elapsed * 0.7) * 85.0
escort_velocity *= 0.55
else:
# Rotating formation slots have tangential velocity as the mother turns.
var offset := destination - mother.global_position
escort_velocity += Vector2(-offset.y, offset.x) * float(mother.get("angular_velocity"))
var error := destination - global_position
var correction := (error * 3.0).limit_length(260.0)
var desired_velocity := (escort_velocity + correction).limit_length(maxf(360.0, mother.velocity.length() + 200.0))
velocity = velocity.move_toward(desired_velocity, maxf(650.0, mother.velocity.length()) * delta)
global_position += velocity * delta
var desired_rotation := mother.rotation
if target != null:
var aim := target.global_position - global_position
desired_rotation = aim.angle() + PI * 0.5
if _fire_timer <= 0.0 and aim.length() <= 650.0 and _clear_shot(target):
_fire(target)
_fire_timer = FIRE_INTERVAL
elif error.length() > 55.0 and velocity.length() > 30.0:
desired_rotation = velocity.angle() + PI * 0.5
var angle_error := angle_difference(rotation, desired_rotation)
_turn_velocity = move_toward(_turn_velocity, clampf(angle_error * 6.0, -4.5, 4.5), 14.0 * delta)
var turn := _turn_velocity * delta
if absf(turn) >= absf(angle_error) and signf(turn) == signf(angle_error):
rotation = desired_rotation
_turn_velocity = 0.0
else:
rotation += turn
queue_redraw()
func formation_position() -> Vector2:
return mother.global_position + FORMATION[clampi(formation_index, 0, 2)].rotated(mother.rotation)
func _find_target() -> Node2D:
var closest: Node2D
var distance := 800.0
for enemy in get_tree().get_nodes_in_group("enemies"):
if enemy.is_queued_for_deletion() or float(enemy.get("hull")) <= 0.0:
continue
var candidate := global_position.distance_to(enemy.global_position)
if candidate < distance and mother.global_position.distance_to(enemy.global_position) < 1000.0:
distance = candidate
closest = enemy
return closest
func _clear_shot(target: Node2D) -> bool:
var query := PhysicsRayQueryParameters2D.create(global_position, target.global_position, 13)
var hit := get_world_2d().direct_space_state.intersect_ray(query)
return hit.is_empty() or hit.get("collider") == target
func _fire(target: Node2D) -> void:
var direction := (target.global_position - global_position).normalized()
var bullet := PROJECTILE.instantiate()
bullet.call("configure", global_position + direction * 9.0, direction, ItemDefinition.WeaponKind.MACHINE_GUN, 1100.0, damage_per_shot, 650.0)
get_parent().add_child(bullet)
_flash = 0.06
mother.get_node("ExtensionSystems").call("break_cloak_on_fire")
func _draw() -> void:
modulate.a = minf(clampf(_elapsed / 0.2, 0.0, 1.0), clampf(remaining / 0.6, 0.0, 1.0))
var flicker := sin(_elapsed * 33.0 + formation_index * 2.1)
for x in [-5.5, 5.5]:
var exhaust := Vector2(x, 19.5)
draw_circle(exhaust + Vector2(0, 5), 11.0 + flicker, Color(0.08, 0.65, 1.0, 0.08))
draw_circle(exhaust + Vector2(0, 3), 6.0, Color(0.12, 0.8, 1.0, 0.18))
draw_colored_polygon(PackedVector2Array([exhaust + Vector2(-3, 0), exhaust + Vector2(0, 15 + flicker * 2), exhaust + Vector2(3, 0)]), Color(0.15, 0.72, 1.0, 0.65))
draw_colored_polygon(PackedVector2Array([exhaust + Vector2(-1.4, 0), exhaust + Vector2(0, 8 + flicker), exhaust + Vector2(1.4, 0)]), Color("c8fbff"))
var hull_size := HULL_TEXTURE.get_size() * (60.0 / HULL_TEXTURE.get_height())
var hull_rect := Rect2(-hull_size * 0.5, hull_size)
draw_texture_rect(HULL_TEXTURE, Rect2(hull_rect.position + Vector2(3, 5), hull_size), false, Color(0, 0, 0, 0.4))
draw_texture_rect(HULL_TEXTURE, hull_rect, false)
if _flash > 0.0:
for x in [-5.5, 5.5]:
var muzzle := Vector2(x, -20)
draw_circle(muzzle, 8.0, Color(1.0, 0.6, 0.18, 0.15))
draw_colored_polygon(PackedVector2Array([muzzle + Vector2(-3, 0), muzzle + Vector2(-1.5, -4), muzzle + Vector2(0, -12), muzzle + Vector2(1.5, -4), muzzle + Vector2(3, 0)]), Color("ffbd68"))
draw_line(muzzle, muzzle + Vector2(0, -7), Color("fff2cb"), 2.0, true)

View File

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

View File

@ -21,6 +21,10 @@ const LASER_MISS_CHANCE := 0.35
@export var weapon_range := 680.0 @export var weapon_range := 680.0
@export var weapon_cooldown := 1.5 @export var weapon_cooldown := 1.5
@export var weapon_damage := 8.0 @export var weapon_damage := 8.0
enum Tactic { RAM, KEEP_DISTANCE }
@export_enum("Losowa", "Taran", "Dystans") var tactic_choice := 0
var tactic := Tactic.RAM
var _orbit_direction := 1.0
var max_shields := 20.0 var max_shields := 20.0
var shields := 20.0 var shields := 20.0
@ -37,6 +41,8 @@ var _slow_remaining := 0.0
var _combat_target: Node2D var _combat_target: Node2D
func _ready() -> void: func _ready() -> void:
tactic = randi_range(0, 1) if tactic_choice == 0 else tactic_choice - 1
_orbit_direction = -1.0 if randf() < 0.5 else 1.0
hull = max_hull hull = max_hull
_sprite = $Sprite2D _sprite = $Sprite2D
_shield_visual = Node2D.new() _shield_visual = Node2D.new()
@ -108,10 +114,12 @@ func _physics_process(delta: float) -> void:
_activated = true _activated = true
if _activated and to_player.length_squared() > 1.0: if _activated and to_player.length_squared() > 1.0:
var direction := to_player.normalized() var direction := to_player.normalized()
var desired_rotation := direction.angle() + PI * 0.5 var fleeing := is_retreating()
var movement := combat_movement(to_player)
var desired_rotation := (movement if fleeing else direction).angle() + PI * 0.5
rotation = rotate_toward(rotation, desired_rotation, turn_speed * delta) rotation = rotate_toward(rotation, desired_rotation, turn_speed * delta)
velocity = velocity.move_toward(direction * approach_speed * get_slow_multiplier(), approach_acceleration * get_slow_multiplier() * delta) velocity = velocity.move_toward(movement * approach_speed * (1.35 if fleeing else 1.0) * get_slow_multiplier(), approach_acceleration * get_slow_multiplier() * delta)
if to_player.length() <= weapon_range and _weapon_timer <= 0.0: if not fleeing and to_player.length() <= weapon_range and _weapon_timer <= 0.0:
_fire_plasma(direction) _fire_plasma(direction)
_weapon_timer = get_fire_interval() _weapon_timer = get_fire_interval()
else: else:
@ -120,6 +128,29 @@ func _physics_process(delta: float) -> void:
move_and_slide() move_and_slide()
_check_player_collision() _check_player_collision()
func is_retreating() -> bool:
return hull < max_hull * 0.5
func combat_movement(to_target: Vector2) -> Vector2:
var distance := to_target.length()
var direction := to_target.normalized()
if is_retreating():
var escape := -direction
var half := preload("res://scripts/levels/system_generator.gd").WORLD_SIZE * 0.5 - Vector2(120, 120)
if absf(global_position.x) > half.x:
escape.x = -signf(global_position.x)
if absf(global_position.y) > half.y:
escape.y = -signf(global_position.y)
return escape.normalized()
if tactic == Tactic.RAM:
return direction
var preferred := minf(weapon_range * 0.65, 420.0)
if distance < preferred * 0.8:
return -direction
if distance > preferred * 1.15:
return direction
return direction.orthogonal() * _orbit_direction * 0.55
func get_slow_multiplier() -> float: func get_slow_multiplier() -> float:
return 0.5 if _slow_remaining > 0.0 else 1.0 return 0.5 if _slow_remaining > 0.0 else 1.0

View File

@ -5,6 +5,8 @@ const PULSE := preload("res://scripts/game/extension_pulse.gd")
var cooldowns: Dictionary = {} var cooldowns: Dictionary = {}
var cloak_remaining := 0.0 var cloak_remaining := 0.0
var solar_remaining := 0.0 var solar_remaining := 0.0
var drones_remaining := 0.0
var _drones: Array[Node2D] = []
var _cloak_material: ShaderMaterial var _cloak_material: ShaderMaterial
var _old_material: Material var _old_material: Material
var _shadow_was_visible := false var _shadow_was_visible := false
@ -15,11 +17,16 @@ var _has_destination := false
func reset_combat_state() -> void: func reset_combat_state() -> void:
cooldowns.clear() cooldowns.clear()
_clear_drones()
solar_remaining = 0.0 solar_remaining = 0.0
if cloak_remaining > 0.0: if cloak_remaining > 0.0:
_end_cloak() _end_cloak()
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
if drones_remaining > 0.0:
drones_remaining = maxf(0.0, drones_remaining - delta)
if drones_remaining <= 0.0 or bool(ship.get("_is_destroyed")) or bool(ship.get("is_docked")):
_clear_drones()
for id in cooldowns.keys(): for id in cooldowns.keys():
cooldowns[id] = maxf(0.0, float(cooldowns[id]) - delta) cooldowns[id] = maxf(0.0, float(cooldowns[id]) - delta)
if cloak_remaining > 0.0: if cloak_remaining > 0.0:
@ -66,6 +73,25 @@ func has_teleport_destination() -> bool:
func activate(kind: int) -> void: func activate(kind: int) -> void:
match kind: match kind:
5:
var inventory := ship.get_node("ShipInventory") as ShipInventory
if inventory.ammunition < 50 or drones_remaining > 0.0:
return
inventory.ammunition -= 50
inventory.ammunition_changed.emit()
drones_remaining = 10.0
break_cloak_on_fire()
var shared_dps := inventory.get_total_dps() * 0.5
for index in 3:
var drone := Node2D.new()
drone.set_script(preload("res://scripts/game/combat_drone.gd"))
drone.set("mother", ship)
drone.set("formation_index", index)
drone.set("damage_per_shot", shared_dps / 3.0 * 0.25)
drone.position = ship.position + Vector2.from_angle(TAU * index / 3.0) * 48.0
ship.get_parent().add_child(drone)
_drones.append(drone)
_deployment_effect(ship.position, Vector2.DOWN.rotated(ship.rotation), Color("70f5df"))
0: 0:
if not has_teleport_destination(): if not has_teleport_destination():
return return
@ -103,6 +129,16 @@ func activate(kind: int) -> void:
solar_remaining = 5.0 solar_remaining = 5.0
_pulse(ship.global_position, Color("70dfff"), 50.0) _pulse(ship.global_position, Color("70dfff"), 50.0)
func _clear_drones() -> void:
drones_remaining = 0.0
for drone in _drones:
if is_instance_valid(drone):
drone.queue_free()
_drones.clear()
func _exit_tree() -> void:
_clear_drones()
func _teleport_effect(point: Vector2, arriving: bool) -> void: func _teleport_effect(point: Vector2, arriving: bool) -> void:
var effect := Node2D.new() var effect := Node2D.new()
effect.set_script(preload("res://scripts/game/teleport_vortex.gd")) effect.set_script(preload("res://scripts/game/teleport_vortex.gd"))
@ -138,6 +174,10 @@ func _start_cloak() -> void:
visual.modulate = Color(visual.modulate, 0.08) visual.modulate = Color(visual.modulate, 0.08)
_pulse(ship.global_position, Color("81e4ef"), 40.0) _pulse(ship.global_position, Color("81e4ef"), 40.0)
func break_cloak_on_fire() -> void:
if cloak_remaining > 0.0:
_end_cloak()
func _end_cloak() -> void: func _end_cloak() -> void:
cloak_remaining = 0.0 cloak_remaining = 0.0
ship.get_node("Sprite2D").set("material", _old_material) ship.get_node("Sprite2D").set("material", _old_material)

View File

@ -178,6 +178,14 @@ func _create_sidebar() -> void:
preload("res://scripts/ui/hud_button_style.gd").apply(_debug_item_button, Color("a9ef83")) preload("res://scripts/ui/hud_button_style.gd").apply(_debug_item_button, Color("a9ef83"))
_debug_item_button.pressed.connect(_give_debug_item) _debug_item_button.pressed.connect(_give_debug_item)
_debug_actions.add_child(_debug_item_button) _debug_actions.add_child(_debug_item_button)
var ammunition_button := Button.new()
ammunition_button.name = "AddAmmunition"
ammunition_button.text = "Dodaj amunicję"
ammunition_button.tooltip_text = "+10 zwykłej amunicji, +10 radiacyjnej amunicji i +10 rakiet"
ammunition_button.custom_minimum_size.y = 36
preload("res://scripts/ui/hud_button_style.gd").apply(ammunition_button, Color("efb568"))
ammunition_button.pressed.connect(_give_debug_ammunition)
_debug_actions.add_child(ammunition_button)
_debug_item_status = Label.new() _debug_item_status = Label.new()
_debug_item_status.add_theme_font_size_override("font_size", 10) _debug_item_status.add_theme_font_size_override("font_size", 10)
_debug_item_status.add_theme_color_override("font_color", Color("a9ef83")) _debug_item_status.add_theme_color_override("font_color", Color("a9ef83"))
@ -348,7 +356,7 @@ func _update_energy_bank_button() -> void:
var inventory := player.get_node("ShipInventory") as ShipInventory var inventory := player.get_node("ShipInventory") as ShipInventory
var count := inventory.get_energy_bank_count() var count := inventory.get_energy_bank_count()
(_energy_bank_button.get_node("Amount") as Label).text = str(count) (_energy_bank_button.get_node("Amount") as Label).text = str(count)
_energy_bank_button.tooltip_text = "Bank energii: %d\nR • Przywraca 10%% maksymalnej energii" % count _energy_bank_button.tooltip_text = "Bank energii: %d\nR • Przywraca %d%% maksymalnej energii" % [count, roundi(GameState.captain.get_energy_bank_fraction() * 100.0)]
_energy_bank_button.disabled = count == 0 or float(player.get("energy")) >= float(player.get("max_energy")) or bool(player.get("_is_destroyed")) or bool(player.get("is_docked")) _energy_bank_button.disabled = count == 0 or float(player.get("energy")) >= float(player.get("max_energy")) or bool(player.get("_is_destroyed")) or bool(player.get("is_docked"))
_energy_bank_button.call("set_visual_state", 0.0, false, _energy_bank_button.disabled) _energy_bank_button.call("set_visual_state", 0.0, false, _energy_bank_button.disabled)
@ -389,6 +397,14 @@ func _toggle_debug() -> void:
_update_debug_toggle() _update_debug_toggle()
_layout_debug() _layout_debug()
func _give_debug_ammunition() -> void:
var inventory := player.get_node("ShipInventory") as ShipInventory
inventory.ammunition += 10
inventory.irradiated_ammunition += 10
inventory.missile_ammunition += 10
inventory.ammunition_changed.emit()
inventory.inventory_changed.emit()
func _give_debug_item() -> bool: func _give_debug_item() -> bool:
var inventory := player.get_node("ShipInventory") as ShipInventory var inventory := player.get_node("ShipInventory") as ShipInventory
_debug_item_status.show() _debug_item_status.show()

View File

@ -436,7 +436,7 @@ func activate_ability(slot_index: int) -> bool:
func use_energy_bank() -> void: func use_energy_bank() -> void:
if $ShipInventory.use_energy_bank(): if $ShipInventory.use_energy_bank():
var popup := DAMAGE_POPUP_SCENE.instantiate() as Label var popup := DAMAGE_POPUP_SCENE.instantiate() as Label
popup.text = "+10% ENERGII" popup.text = "+%d%% ENERGII" % roundi(GameState.captain.get_energy_bank_fraction() * 100.0)
popup.set("text_color", Color("70f5df")) popup.set("text_color", Color("70f5df"))
popup.position = position + Vector2(-48, -60) popup.position = position + Vector2(-48, -60)
get_parent().add_child(popup) get_parent().add_child(popup)

View File

@ -135,6 +135,7 @@ func try_fire_missile(slot_index: int = 2) -> bool:
return true return true
func _fire_projectile(item: ItemInstance) -> void: func _fire_projectile(item: ItemInstance) -> void:
_break_cloak_on_fire()
var definition := item.definition var definition := item.definition
var hit: Dictionary = ship.call("roll_weapon_hit", item) var hit: Dictionary = ship.call("roll_weapon_hit", item)
var muzzle_position := _muzzle_position() var muzzle_position := _muzzle_position()
@ -194,6 +195,7 @@ func _fire_laser(slot_index: int, item: ItemInstance) -> void:
line.visible = false line.visible = false
return return
WEAPON_AUDIO_SCRIPT.play(ship.get_parent(), muzzle_position, ItemDefinition.WeaponKind.LASER) WEAPON_AUDIO_SCRIPT.play(ship.get_parent(), muzzle_position, ItemDefinition.WeaponKind.LASER)
_break_cloak_on_fire()
if not result.is_empty(): if not result.is_empty():
var hit: Dictionary = ship.call("roll_weapon_hit", item) var hit: Dictionary = ship.call("roll_weapon_hit", item)
_apply_damage(result.get("collider"), float(hit["damage"]), bool(hit["critical"])) _apply_damage(result.get("collider"), float(hit["damage"]), bool(hit["critical"]))
@ -201,6 +203,11 @@ func _fire_laser(slot_index: int, item: ItemInstance) -> void:
_laser_pulse_timers[slot_index] = definition.laser_pulse_duration _laser_pulse_timers[slot_index] = definition.laser_pulse_duration
_shot_timers[slot_index] = definition.fire_interval _shot_timers[slot_index] = definition.fire_interval
func _break_cloak_on_fire() -> void:
var systems := ship.get_node_or_null("ExtensionSystems")
if systems != null:
systems.call("break_cloak_on_fire")
func _aim_direction_from(origin: Vector2) -> Vector2: func _aim_direction_from(origin: Vector2) -> Vector2:
var offset := ship.get_global_mouse_position() - origin var offset := ship.get_global_mouse_position() - origin
return offset.normalized() if offset.length_squared() > 0.001 else Vector2.UP.rotated(ship.rotation) return offset.normalized() if offset.length_squared() > 0.001 else Vector2.UP.rotated(ship.rotation)

View File

@ -1,6 +1,7 @@
extends RefCounted extends RefCounted
const DEFINITIONS := [ const DEFINITIONS := [
preload("res://resources/items/combat_drones.tres"),
preload("res://resources/items/teleport.tres"), preload("res://resources/items/teleport.tres"),
preload("res://resources/items/cloak.tres"), preload("res://resources/items/cloak.tres"),
preload("res://resources/items/mine_layer.tres"), preload("res://resources/items/mine_layer.tres"),

View File

@ -93,7 +93,7 @@ func use_energy_bank() -> bool:
var current := float(ship.get("energy")) var current := float(ship.get("energy"))
if current >= maximum: if current >= maximum:
return false return false
ship.set("energy", minf(maximum, current + maximum * 0.1)) ship.set("energy", minf(maximum, current + maximum * GameState.captain.get_energy_bank_fraction()))
energy_banks -= 1 energy_banks -= 1
inventory_changed.emit() inventory_changed.emit()
return true return true

View File

@ -0,0 +1,111 @@
extends Node2D
const PLAYER := preload("res://scenes/game/player_ship.tscn")
const ENEMY := preload("res://scenes/game/enemy_ship.tscn")
const DRONES := preload("res://resources/items/combat_drones.tres")
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 systems := ship.get_node("ExtensionSystems")
systems.set_physics_process(false)
var inventory := ship.get_node("ShipInventory") as ShipInventory
# Every rank replaces the baseline, respects the energy cap, and uses one bank.
for rank in range(4):
GameState.captain.skills["electrician"] = rank
inventory.energy_banks = 2
ship.set("energy", 0.0)
assert(inventory.use_energy_bank())
assert(is_equal_approx(float(ship.get("energy")), float(ship.get("max_energy")) * [0.10, 0.15, 0.20, 0.25][rank]))
ship.set("energy", float(ship.get("max_energy")) - 1.0)
assert(inventory.use_energy_bank())
assert(ship.get("energy") == ship.get("max_energy"))
assert(inventory.energy_banks == 0)
assert(preload("res://scripts/captain/captain_skills.gd").icon("electrician") != null)
assert(not GameState.captain.equip_skill(0, "electrician"))
# Tactics move toward, away from, or around their target, then flee below 50%.
var enemy := ENEMY.instantiate() as CharacterBody2D
enemy.position = Vector2(250, 0)
add_child(enemy)
enemy.set_physics_process(false)
enemy.set("_player", ship)
enemy.set("tactic", 0)
assert(enemy.call("combat_movement", Vector2(200, 0)).x > 0.0)
enemy.set("tactic", 1)
assert(enemy.call("combat_movement", Vector2(100, 0)).x < 0.0)
assert(enemy.call("combat_movement", Vector2(700, 0)).x > 0.0)
assert(absf(enemy.call("combat_movement", Vector2(420, 0)).y) > 0.0)
enemy.set("hull", float(enemy.get("max_hull")) * 0.5)
assert(not enemy.call("is_retreating"))
enemy.set("hull", float(enemy.get("max_hull")) * 0.49)
for tactic in [0, 1]:
enemy.set("tactic", tactic)
assert(enemy.call("is_retreating"))
assert(enemy.call("combat_movement", Vector2(500, 0)).x < 0.0)
enemy.set("hull", 10000.0)
# Module is lootable, charges exactly 50 ammo even with the ammunition passive.
assert(DRONES in preload("res://scripts/items/item_generator.gd").DEFINITIONS)
var module := ItemInstance.new()
module.definition = DRONES
inventory.extension_slots[0] = module
GameState.captain.skills["efficient_magazine"] = 3
inventory.ammunition = 49
assert(not ship.call("activate_ability", 0))
assert(inventory.ammunition == 49)
inventory.ammunition = 100
var dps := inventory.get_total_dps()
assert(dps > 0.0)
assert(ship.call("activate_ability", 0))
assert(inventory.ammunition == 50)
assert(not ship.call("activate_ability", 0))
var drones := get_tree().get_nodes_in_group("player_combat_drones")
assert(drones.size() == 3)
var total_dps := 0.0
for drone in drones:
drone.set_physics_process(false)
total_dps += float(drone.get("damage_per_shot")) / float(drone.FIRE_INTERVAL)
assert(is_equal_approx(total_dps, dps * 0.5))
assert(is_equal_approx(inventory.get_total_dps(), dps))
await get_tree().physics_frame
await get_tree().process_frame
var before := float(enemy.get("hull")) + float(enemy.get("shields"))
drones[0].position = Vector2(100, 0)
drones[0].call("_fire", enemy)
for bullet in get_children():
if bullet is Area2D and bullet.get_script() == preload("res://scripts/game/weapon_projectile.gd"):
bullet.call("_physics_process", 0.2)
assert(float(enemy.get("hull")) + float(enemy.get("shields")) < before, "Drone bullets must damage enemies")
assert(inventory.ammunition == 50, "No extra ammunition charged per shot")
systems.call("_physics_process", 10.0)
for drone in drones:
assert(drone.is_queued_for_deletion())
await get_tree().process_frame
assert(get_tree().get_nodes_in_group("player_combat_drones").is_empty())
systems.call("reset_combat_state")
assert(ship.call("activate_ability", 0))
var deployed := get_tree().get_nodes_in_group("player_combat_drones")
for drone in deployed:
drone.set_physics_process(false)
get_tree().paused = true
assert(not ship.call("activate_ability", 0))
await get_tree().create_timer(0.05, true).timeout
assert(float(systems.get("drones_remaining")) == 10.0)
get_tree().paused = false
ship.set("is_docked", true)
systems.call("_physics_process", 0.1)
await get_tree().process_frame
assert(get_tree().get_nodes_in_group("player_combat_drones").is_empty())
ship.set("is_docked", false)
systems.call("reset_combat_state")
inventory.ammunition = 50
assert(ship.call("activate_ability", 0))
systems.call("reset_combat_state")
await get_tree().process_frame
assert(get_tree().get_nodes_in_group("player_combat_drones").is_empty())
assert(float(systems.get("drones_remaining")) == 0.0)
print("ELECTRICIAN_DRONES_TACTICS_TEST: PASS (four bank strengths/caps, passive icon, tactics and retreat, loot module, exact ammo cost, 3 drones, shared 50% DPS, actual bullet damage, lifetime and reset)")
get_tree().quit()

View File

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

View File

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

View File

@ -80,6 +80,30 @@ func _ready() -> void:
assert(enemy.call("select_combat_target") == ship) assert(enemy.call("select_combat_target") == ship)
assert(not ship.call("activate_ability", 1)) assert(not ship.call("activate_ability", 1))
# Mine: 5% maximum hull damage despite armor; slow lasts five seconds; one hit only. # Mine: 5% maximum hull damage despite armor; slow lasts five seconds; one hit only.
var weapons := ship.get_node("WeaponController")
for weapon_id in ["plasma_cannon", "machine_gun", "gamma_cannon", "missile_launcher", "beam_laser"]:
systems.get("cooldowns").clear()
assert(ship.call("activate_ability", 1))
var weapon := GENERATOR.generate_for_definition(load("res://resources/items/%s.tres" % weapon_id), 1)
if weapon_id == "beam_laser":
ship.set("energy", 0.0)
weapons.call("_fire_laser", 0, weapon)
assert(ship.call("is_cloaked"), "Failed shot must preserve cloak")
ship.set("energy", ship.get("max_energy"))
weapons.call("_fire_laser", 0, weapon)
elif weapon_id == "missile_launcher":
inventory.weapon_slots[2] = weapon
inventory.missile_ammunition = 0
assert(not weapons.call("try_fire_missile", 2))
assert(ship.call("is_cloaked"), "Empty launcher must preserve cloak")
inventory.missile_ammunition = 1
assert(weapons.call("try_fire_missile", 2))
else:
weapons.call("_fire_projectile", weapon)
assert(not ship.call("is_cloaked"), "Every fired weapon must break cloak")
assert(ship.get_node("Sprite2D").material == original_material)
assert(ship.call("get_ability_cooldown", 1) == 60.0)
assert(enemy.call("select_combat_target") == ship)
assert(ship.call("activate_ability", 2)) assert(ship.call("activate_ability", 2))
var mine: Node2D = get_tree().get_nodes_in_group("player_mines")[0] var mine: Node2D = get_tree().get_nodes_in_group("player_mines")[0]
mine.set_physics_process(false) mine.set_physics_process(false)

View File

@ -44,10 +44,10 @@ func _ready() -> void:
captain.skill_durations.clear() captain.skill_durations.clear()
var base_regeneration: float = ship.get_energy_regeneration() var base_regeneration: float = ship.get_energy_regeneration()
assert(captain.activate_skill("reactor_overload", ship)) assert(captain.activate_skill("reactor_overload", ship))
assert(is_equal_approx(ship.get_energy_regeneration(), base_regeneration * [1.1, 1.2, 1.7][rank - 1])) assert(is_equal_approx(ship.get_energy_regeneration(), base_regeneration * [1.2, 1.5, 2.0][rank - 1]))
ship.energy = 0.0 ship.energy = 0.0
ship._physics_process(0.1) ship._physics_process(0.1)
assert(is_equal_approx(ship.energy, base_regeneration * [1.1, 1.2, 1.7][rank - 1] * 0.1)) assert(is_equal_approx(ship.energy, base_regeneration * [1.2, 1.5, 2.0][rank - 1] * 0.1))
assert(not captain.activate_skill("reactor_overload", ship)) assert(not captain.activate_skill("reactor_overload", ship))
assert(captain.activate_skill("tactical_maneuver", ship)) assert(captain.activate_skill("tactical_maneuver", ship))
assert(is_equal_approx(ship.get_evasion_chance(), [0.3, 0.6, 1.0][rank - 1])) assert(is_equal_approx(ship.get_evasion_chance(), [0.3, 0.6, 1.0][rank - 1]))
@ -61,7 +61,7 @@ func _ready() -> void:
ship.shields = ship.max_shields * 0.25 ship.shields = ship.max_shields * 0.25
assert(captain.activate_skill("power_redirect", ship)) assert(captain.activate_skill("power_redirect", ship))
assert(ship.shields == 0.0) assert(ship.shields == 0.0)
assert(is_equal_approx(captain.energy_regeneration_multiplier(), 1.0 + [0.1, 0.2, 0.7][rank - 1] + [0.5, 0.9, 1.5][rank - 1])) assert(is_equal_approx(captain.energy_regeneration_multiplier(), 1.0 + [0.2, 0.5, 1.0][rank - 1] + [0.5, 0.9, 1.5][rank - 1]))
ship.shield_on_damage_percent = 100.0 ship.shield_on_damage_percent = 100.0
ship.restore_shields_from_damage(100.0) ship.restore_shields_from_damage(100.0)
assert(ship.shields == 0.0) assert(ship.shields == 0.0)

View File

@ -0,0 +1,63 @@
extends Node
func _ready() -> void:
GameState.start_new_run()
var game := preload("res://scenes/game/game.tscn").instantiate()
add_child(game)
game.set_physics_process(false)
var ship := game.get_node("PlayerShip") as CharacterBody2D
ship.set_physics_process(false)
ship.get_node("WeaponController").set_physics_process(false)
var systems := ship.get_node("ExtensionSystems")
systems.set_physics_process(false)
for enemy in get_tree().get_nodes_in_group("enemies"):
enemy.queue_free()
await get_tree().process_frame
var hud := game.get_node("HUD")
hud.call("_toggle_debug")
var inventory := ship.get_node("ShipInventory") as ShipInventory
var before := Vector3i(inventory.ammunition, inventory.irradiated_ammunition, inventory.missile_ammunition)
var button := hud.get_node("HUDRoot/DebugActions/AddAmmunition") as Button
button.pressed.emit()
button.pressed.emit()
assert(Vector3i(inventory.ammunition, inventory.irradiated_ammunition, inventory.missile_ammunition) == before + Vector3i(20, 20, 20))
systems.call("activate", 5)
var drones := get_tree().get_nodes_in_group("player_combat_drones")
assert(drones.size() == 3)
for drone in drones:
drone.set_physics_process(false)
assert(drone.scale == Vector2(0.5, 0.5))
# Stationary formation converges to fixed positions in front, with no idle orbit.
_step(ship, drones, 120)
for drone in drones:
assert(drone.global_position.distance_to(drone.call("formation_position")) < 2.0)
assert((drone.global_position - ship.global_position).dot(Vector2.UP.rotated(ship.rotation)) > 60.0)
# Forward flight and a turn inherit mother velocity without position snapping.
ship.velocity = Vector2(0, -200)
var previous_velocity: Vector2 = drones[0].get("velocity")
_step(ship, drones, 1)
assert((drones[0].get("velocity") as Vector2).distance_to(previous_velocity) <= 650.0 / 60.0 + 0.01)
_step(ship, drones, 119)
for drone in drones:
assert(drone.global_position.distance_to(drone.call("formation_position")) < 12.0)
ship.set("angular_velocity", 0.6)
_step(ship, drones, 120, true)
for drone in drones:
assert(drone.global_position.distance_to(drone.call("formation_position")) < 15.0)
ship.set("angular_velocity", 0.0)
ship.velocity = Vector2.ZERO
_step(ship, drones, 120)
for drone in drones:
assert(drone.global_position.distance_to(drone.call("formation_position")) < 2.0)
assert((drone.get("velocity") as Vector2).length() < 3.0)
print("DEBUG_AMMO_DRONE_FORMATION_TEST: PASS (debug ammo x10 repeatable, half scale, fixed forward formation, acceleration bound, moving/turning mother tracking, smooth stop)")
get_tree().quit()
func _step(ship: CharacterBody2D, drones: Array[Node], frames: int, turning := false) -> void:
for frame in frames:
if turning:
ship.rotation += 0.6 / 60.0
ship.velocity = Vector2.UP.rotated(ship.rotation) * 200.0
ship.position += ship.velocity / 60.0
for drone in drones:
drone.call("_physics_process", 1.0 / 60.0)

View File

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

View File

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