diff --git a/assets/icons/captain/ARTWORK.md b/assets/icons/captain/ARTWORK.md
new file mode 100644
index 0000000..8e3323b
--- /dev/null
+++ b/assets/icons/captain/ARTWORK.md
@@ -0,0 +1,27 @@
+# Captain skill artwork
+
+Generated with the built-in imagegen tool. Files: business_shark.png, reactor_overload.png, tactical_maneuver.png, efficient_magazine.png, miner.png, power_redirect.png.
+
+## business_shark
+
+Use case: stylized-concept. Final production square ability icon for a realistic painterly space RPG. Highly polished hand-painted digital game art, cinematic material detail, strong simple silhouette readable at 40 pixels, centered composition with 12% safe margin. Dark navy outer corners, restrained luminous accent, no text, no letters, no numbers, no interface, no border. One iconic subject, not a scene. Business Shark passive skill: an elegant gold metallic shark emblem curling around a small stack of futuristic credit coins, warm amber highlights, brushed gold and deep charcoal materials, confident mercantile space captain mood.
+
+## reactor_overload
+
+Use case: stylized-concept. Final production square ability icon for a realistic painterly space RPG. Highly polished hand-painted digital game art, cinematic material detail, strong simple silhouette readable at 40 pixels, centered composition with 12% safe margin. Dark navy outer corners, restrained luminous accent, no text, no letters, no numbers, no interface, no border. One iconic subject, not a scene. Reactor Overload active skill: a compact circular spaceship fusion reactor with a brilliant cyan-white energy core inside heavy gunmetal containment rings, vivid electric blue arcs escaping the core, teal glow illuminating machined metal, dynamic energy, clear large central reactor silhouette.
+
+## tactical_maneuver
+
+Use case: stylized-concept. Final production square ability icon for a realistic painterly space RPG. Highly polished hand-painted digital game art, cinematic material detail, strong simple silhouette readable at 40 pixels, centered composition with 12% safe margin. Dark navy outer corners, restrained luminous accent, no text, no letters, no numbers, no interface, no border. One iconic subject, not a scene. Tactical Maneuver active skill: a sleek silver spacecraft banking sharply diagonally upward, two curved violet-blue contrails sweeping behind it to show an evasive turn, a faint ghost silhouette of its former position, purple light on metallic hull. Strong clear ship shape and dramatic diagonal movement.
+
+## efficient_magazine
+
+Use case: stylized-concept. Final production square ability icon for a realistic painterly space RPG. Highly polished hand-painted digital game art, cinematic material detail, strong simple silhouette readable at 40 pixels, centered composition with 12% safe margin. Dark navy outer corners, restrained luminous accent, no text, no letters, no numbers, no interface, no border. One iconic subject, not a scene. Efficient Magazine passive skill: a robust gunmetal ammunition magazine with three large brass-tipped futuristic kinetic cartridges, a luminous green circular recycling arrow wrapping around the cartridge group, sage-green accent light, realistic brushed metal, unmistakable ammo conservation emblem.
+
+## miner
+
+Use case: stylized-concept. Final production square ability icon for a realistic painterly space RPG. Highly polished hand-painted digital game art, cinematic material detail, strong simple silhouette readable at 40 pixels, centered composition with 12% safe margin. Dark navy outer corners, restrained luminous accent, no text, no letters, no numbers, no interface, no border. One iconic subject, not a scene. Miner passive skill: a rugged futuristic plasma mining pick striking a small jagged asteroid rich with large bright titanium and copper mineral veins, a few amber sparks at impact, dark metal tool with worn industrial edges, warm copper and steel-blue material contrast, clear bold pickaxe-and-rock silhouette.
+
+## power_redirect
+
+Use case: stylized-concept. Final production square ability icon for a realistic painterly space RPG. Highly polished hand-painted digital game art, cinematic material detail, strong simple silhouette readable at 40 pixels, centered composition with 12% safe margin. Dark navy outer corners, restrained luminous accent, no text, no letters, no numbers, no interface, no border. One iconic subject, not a scene. Power Redirect active skill: a heavy metallic spaceship shield emitter on the left splitting open and releasing a powerful curved orange-gold energy stream into a bright compact cyan reactor core on the right. A bold luminous flow of power visually connects the two components. Burnt orange, pale gold, electric teal highlights, realistic sci-fi machinery, strong simple two-part silhouette.
diff --git a/assets/icons/captain/business_shark.png b/assets/icons/captain/business_shark.png
new file mode 100644
index 0000000..0d43b09
Binary files /dev/null and b/assets/icons/captain/business_shark.png differ
diff --git a/assets/icons/captain/business_shark.png.import b/assets/icons/captain/business_shark.png.import
new file mode 100644
index 0000000..d461228
--- /dev/null
+++ b/assets/icons/captain/business_shark.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cqgv8uxur4ilq"
+path="res://.godot/imported/business_shark.png-5d3a96ae879adf7dc656217f25b40537.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/icons/captain/business_shark.png"
+dest_files=["res://.godot/imported/business_shark.png-5d3a96ae879adf7dc656217f25b40537.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
diff --git a/assets/icons/captain/business_shark.svg b/assets/icons/captain/business_shark.svg
new file mode 100644
index 0000000..21073d1
--- /dev/null
+++ b/assets/icons/captain/business_shark.svg
@@ -0,0 +1,6 @@
+
diff --git a/assets/icons/captain/business_shark.svg.import b/assets/icons/captain/business_shark.svg.import
new file mode 100644
index 0000000..7ce1861
--- /dev/null
+++ b/assets/icons/captain/business_shark.svg.import
@@ -0,0 +1,43 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bavcivfj1hvam"
+path="res://.godot/imported/business_shark.svg-89848d8b572ca882ea2021aa89d320aa.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/icons/captain/business_shark.svg"
+dest_files=["res://.godot/imported/business_shark.svg-89848d8b572ca882ea2021aa89d320aa.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/assets/icons/captain/efficient_magazine.png b/assets/icons/captain/efficient_magazine.png
new file mode 100644
index 0000000..f2d458e
Binary files /dev/null and b/assets/icons/captain/efficient_magazine.png differ
diff --git a/assets/icons/captain/efficient_magazine.png.import b/assets/icons/captain/efficient_magazine.png.import
new file mode 100644
index 0000000..7881bfd
--- /dev/null
+++ b/assets/icons/captain/efficient_magazine.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bpqtxf51nyry3"
+path="res://.godot/imported/efficient_magazine.png-5217444fb6fcaeb4761f635d1dd088a2.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/icons/captain/efficient_magazine.png"
+dest_files=["res://.godot/imported/efficient_magazine.png-5217444fb6fcaeb4761f635d1dd088a2.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
diff --git a/assets/icons/captain/efficient_magazine.svg b/assets/icons/captain/efficient_magazine.svg
new file mode 100644
index 0000000..d07327e
--- /dev/null
+++ b/assets/icons/captain/efficient_magazine.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/icons/captain/efficient_magazine.svg.import b/assets/icons/captain/efficient_magazine.svg.import
new file mode 100644
index 0000000..152878f
--- /dev/null
+++ b/assets/icons/captain/efficient_magazine.svg.import
@@ -0,0 +1,43 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://sf0b8u85a7fn"
+path="res://.godot/imported/efficient_magazine.svg-a8dbaabf81ed2aef576d5ea758c66386.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/icons/captain/efficient_magazine.svg"
+dest_files=["res://.godot/imported/efficient_magazine.svg-a8dbaabf81ed2aef576d5ea758c66386.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/assets/icons/captain/miner.png b/assets/icons/captain/miner.png
new file mode 100644
index 0000000..46c2732
Binary files /dev/null and b/assets/icons/captain/miner.png differ
diff --git a/assets/icons/captain/miner.png.import b/assets/icons/captain/miner.png.import
new file mode 100644
index 0000000..5d0b56d
--- /dev/null
+++ b/assets/icons/captain/miner.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cevaxidi3ydxu"
+path="res://.godot/imported/miner.png-212675e08e370691f2ba34f1899b904d.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/icons/captain/miner.png"
+dest_files=["res://.godot/imported/miner.png-212675e08e370691f2ba34f1899b904d.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
diff --git a/assets/icons/captain/miner.svg b/assets/icons/captain/miner.svg
new file mode 100644
index 0000000..5ec5576
--- /dev/null
+++ b/assets/icons/captain/miner.svg
@@ -0,0 +1,6 @@
+
diff --git a/assets/icons/captain/miner.svg.import b/assets/icons/captain/miner.svg.import
new file mode 100644
index 0000000..a603a0d
--- /dev/null
+++ b/assets/icons/captain/miner.svg.import
@@ -0,0 +1,43 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bpp1j7oclr0oj"
+path="res://.godot/imported/miner.svg-bf2e3ad4c379dbe2e9f9e031795889ba.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/icons/captain/miner.svg"
+dest_files=["res://.godot/imported/miner.svg-bf2e3ad4c379dbe2e9f9e031795889ba.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/assets/icons/captain/power_redirect.png b/assets/icons/captain/power_redirect.png
new file mode 100644
index 0000000..a6092b3
Binary files /dev/null and b/assets/icons/captain/power_redirect.png differ
diff --git a/assets/icons/captain/power_redirect.png.import b/assets/icons/captain/power_redirect.png.import
new file mode 100644
index 0000000..5399f12
--- /dev/null
+++ b/assets/icons/captain/power_redirect.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://c1g80e3fq87ac"
+path="res://.godot/imported/power_redirect.png-1e3d664cc98261556d6bfef217ca50a0.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/icons/captain/power_redirect.png"
+dest_files=["res://.godot/imported/power_redirect.png-1e3d664cc98261556d6bfef217ca50a0.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
diff --git a/assets/icons/captain/power_redirect.svg b/assets/icons/captain/power_redirect.svg
new file mode 100644
index 0000000..8d1fcb8
--- /dev/null
+++ b/assets/icons/captain/power_redirect.svg
@@ -0,0 +1,7 @@
+
diff --git a/assets/icons/captain/power_redirect.svg.import b/assets/icons/captain/power_redirect.svg.import
new file mode 100644
index 0000000..3e72178
--- /dev/null
+++ b/assets/icons/captain/power_redirect.svg.import
@@ -0,0 +1,43 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b8wfl8vnm8bh2"
+path="res://.godot/imported/power_redirect.svg-ab8d0be7673fec276bd174a0e425f4cc.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/icons/captain/power_redirect.svg"
+dest_files=["res://.godot/imported/power_redirect.svg-ab8d0be7673fec276bd174a0e425f4cc.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/assets/icons/captain/reactor_overload.png b/assets/icons/captain/reactor_overload.png
new file mode 100644
index 0000000..60dde5c
Binary files /dev/null and b/assets/icons/captain/reactor_overload.png differ
diff --git a/assets/icons/captain/reactor_overload.png.import b/assets/icons/captain/reactor_overload.png.import
new file mode 100644
index 0000000..13e9e72
--- /dev/null
+++ b/assets/icons/captain/reactor_overload.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cjca12kt2v6vd"
+path="res://.godot/imported/reactor_overload.png-dbe9ee0414658af8e7b0396a3bca12f9.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/icons/captain/reactor_overload.png"
+dest_files=["res://.godot/imported/reactor_overload.png-dbe9ee0414658af8e7b0396a3bca12f9.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
diff --git a/assets/icons/captain/reactor_overload.svg b/assets/icons/captain/reactor_overload.svg
new file mode 100644
index 0000000..a35c89b
--- /dev/null
+++ b/assets/icons/captain/reactor_overload.svg
@@ -0,0 +1,7 @@
+
diff --git a/assets/icons/captain/reactor_overload.svg.import b/assets/icons/captain/reactor_overload.svg.import
new file mode 100644
index 0000000..eb4f901
--- /dev/null
+++ b/assets/icons/captain/reactor_overload.svg.import
@@ -0,0 +1,43 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dbm86lpa2e35s"
+path="res://.godot/imported/reactor_overload.svg-af2e2a6c8e1071e550b60db938d47304.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/icons/captain/reactor_overload.svg"
+dest_files=["res://.godot/imported/reactor_overload.svg-af2e2a6c8e1071e550b60db938d47304.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/assets/icons/captain/tactical_maneuver.png b/assets/icons/captain/tactical_maneuver.png
new file mode 100644
index 0000000..4088758
Binary files /dev/null and b/assets/icons/captain/tactical_maneuver.png differ
diff --git a/assets/icons/captain/tactical_maneuver.png.import b/assets/icons/captain/tactical_maneuver.png.import
new file mode 100644
index 0000000..2640f22
--- /dev/null
+++ b/assets/icons/captain/tactical_maneuver.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://gknhk6bd2kxy"
+path="res://.godot/imported/tactical_maneuver.png-c777f3e535ae44e1d2d91a50b6bb904d.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/icons/captain/tactical_maneuver.png"
+dest_files=["res://.godot/imported/tactical_maneuver.png-c777f3e535ae44e1d2d91a50b6bb904d.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
diff --git a/assets/icons/captain/tactical_maneuver.svg b/assets/icons/captain/tactical_maneuver.svg
new file mode 100644
index 0000000..2bd2eb3
--- /dev/null
+++ b/assets/icons/captain/tactical_maneuver.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/icons/captain/tactical_maneuver.svg.import b/assets/icons/captain/tactical_maneuver.svg.import
new file mode 100644
index 0000000..365a009
--- /dev/null
+++ b/assets/icons/captain/tactical_maneuver.svg.import
@@ -0,0 +1,43 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dxuw3d6gfvcfd"
+path="res://.godot/imported/tactical_maneuver.svg-332c20dd68467e5c2748d2183a9c7da9.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/icons/captain/tactical_maneuver.svg"
+dest_files=["res://.godot/imported/tactical_maneuver.svg-332c20dd68467e5c2748d2183a9c7da9.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/resources/ui/skill_icon.gdshader b/resources/ui/skill_icon.gdshader
new file mode 100644
index 0000000..16ca4d2
--- /dev/null
+++ b/resources/ui/skill_icon.gdshader
@@ -0,0 +1,21 @@
+shader_type canvas_item;
+render_mode unshaded;
+
+uniform vec2 icon_size = vec2(32.0);
+uniform float corner_radius = 4.0;
+uniform float cooldown : hint_range(0.0, 1.0) = 0.0;
+uniform float dimming : hint_range(0.0, 1.0) = 0.0;
+
+void fragment() {
+ vec4 artwork = texture(TEXTURE, UV);
+ vec2 p = abs((UV - 0.5) * icon_size) - icon_size * 0.5 + corner_radius;
+ float distance_to_edge = length(max(p, vec2(0.0))) + min(max(p.x, p.y), 0.0) - corner_radius;
+ float aa = max(fwidth(distance_to_edge), 0.5);
+ float mask = 1.0 - smoothstep(-aa * 0.5, aa * 0.5, distance_to_edge);
+ float luminance = dot(artwork.rgb, vec3(0.2126, 0.7152, 0.0722));
+ artwork.rgb = mix(artwork.rgb, vec3(luminance), dimming * 0.65);
+ artwork.rgb *= 1.0 - dimming * 0.38;
+ float cooling = (1.0 - step(cooldown, UV.y)) * step(0.001, cooldown);
+ artwork.rgb *= 1.0 - cooling * 0.6;
+ COLOR = vec4(artwork.rgb, artwork.a * mask);
+}
diff --git a/resources/ui/skill_icon.gdshader.uid b/resources/ui/skill_icon.gdshader.uid
new file mode 100644
index 0000000..ec10adc
--- /dev/null
+++ b/resources/ui/skill_icon.gdshader.uid
@@ -0,0 +1 @@
+uid://dprgc6tmi7yoh
diff --git a/scripts/abilities/shield_charge_ability.gd b/scripts/abilities/shield_charge_ability.gd
index 59d596e..9876bb7 100644
--- a/scripts/abilities/shield_charge_ability.gd
+++ b/scripts/abilities/shield_charge_ability.gd
@@ -3,11 +3,15 @@ extends ActiveAbility
@export var shield_amount: float = 100.0
func unavailable_reason(ship: CharacterBody2D) -> String:
+ if GameState.captain.is_skill_active("power_redirect"):
+ return "Tarcze wyłączone przez Przekierowanie zasilania"
if ship.get("shields") >= ship.get("max_shields"):
return "Tarcze są pełne"
return "Wymagana pełna energia" if ship.get("energy") < ship.get("max_energy") else ""
func activate(ship: CharacterBody2D) -> void:
+ if GameState.captain.is_skill_active("power_redirect"):
+ return
ship.set("energy", 0.0)
ship.set("shields", minf(ship.get("max_shields"), ship.get("shields") + shield_amount))
ship.call("_update_shield_visual")
diff --git a/scripts/captain/captain_progress.gd b/scripts/captain/captain_progress.gd
index 1b0f0ed..e878f4b 100644
--- a/scripts/captain/captain_progress.gd
+++ b/scripts/captain/captain_progress.gd
@@ -2,6 +2,8 @@ extends Resource
class_name CaptainProgress
const BALANCE := preload("res://scripts/captain/captain_balance.gd")
+const SKILLS := preload("res://scripts/captain/captain_skills.gd")
+const ACTIVE_SKILL_SLOTS := 5
@export var level: int = 1
@export var experience: int = 0
@@ -10,55 +12,152 @@ const BALANCE := preload("res://scripts/captain/captain_balance.gd")
@export var minerals: Dictionary = {"uranium": 0, "titanium": 0, "iron": 0, "aluminium": 0}
@export var learning_points: int = 0
@export var skills: Dictionary = {}
+@export var equipped_skills: Array[String] = ["", "", "", "", ""]
+var skill_offers: Array[String] = []
+var skill_cooldowns: Dictionary = {}
+var skill_durations: Dictionary = {}
+var _ammo_credit: Dictionary = {}
+var _skill_rng := RandomNumberGenerator.new()
+
+func _init() -> void:
+ _skill_rng.randomize()
func get_xp_required() -> int:
return BALANCE.xp_required(level)
func get_skill_rank(skill: String) -> int:
- return clampi(int(skills.get(skill, 0)), 0, BALANCE.MAX_SKILL_RANK)
+ return clampi(int(skills.get(skill, 0)), 0, SKILLS.MAX_RANK) if SKILLS.DATA.has(skill) else 0
-func get_skill_bonus(_skill: String) -> float:
- return 0.0 # The old captain skill system has been retired.
+func get_skill_bonus(skill: String) -> float:
+ return SKILLS.value(skill, get_skill_rank(skill))
func normalize_skill_ranks() -> void:
- # Refund legacy ranks for the future captain skill system.
- var refunded := get_spent_learning_points()
- if refunded > 0:
- learning_points += refunded
- skills.clear()
- emit_changed()
- return
+ for skill in skills.keys():
+ if not SKILLS.DATA.has(skill):
+ skills.erase(skill)
+ else:
+ skills[skill] = get_skill_rank(skill)
+ normalize_equipped_skills()
+
+func normalize_equipped_skills() -> void:
+ equipped_skills.resize(ACTIVE_SKILL_SLOTS)
+ var seen: Array[String] = []
+ for index in ACTIVE_SKILL_SLOTS:
+ var skill := equipped_skills[index]
+ if not SKILLS.DATA.has(skill) or not bool(SKILLS.DATA[skill]["active"]) or get_skill_rank(skill) <= 0 or skill in seen:
+ equipped_skills[index] = ""
+ else:
+ seen.append(skill)
+
+func equip_skill(slot: int, skill: String) -> bool:
+ if slot < 0 or slot >= ACTIVE_SKILL_SLOTS:
+ return false
+ if not skill.is_empty() and (not SKILLS.DATA.has(skill) or not bool(SKILLS.DATA[skill]["active"]) or get_skill_rank(skill) <= 0):
+ return false
+ normalize_equipped_skills()
+ # Moving a skill between slots never resets its cooldown or active duration.
+ var previous_slot := equipped_skills.find(skill) if not skill.is_empty() else -1
+ if previous_slot >= 0:
+ equipped_skills[previous_slot] = ""
+ equipped_skills[slot] = skill
+ emit_changed()
+ return true
func get_spent_learning_points() -> int:
var spent := 0
- for skill in BALANCE.SKILL_NAMES:
- spent += maxi(0, int(skills.get(skill, 0)))
+ for skill in SKILLS.IDS:
+ spent += get_skill_rank(skill)
return spent
func get_skill_reset_cost() -> int:
return BALANCE.SKILL_RESET_COST_PER_LEVEL * maxi(1, level)
func can_reset_skills() -> bool:
- return get_spent_learning_points() > 0 and credits >= get_skill_reset_cost()
+ return false # Skills belong to the entire run; choices cannot be rerolled at a shop.
func reset_skills() -> bool:
- if not can_reset_skills():
- return false
- credits -= get_skill_reset_cost()
- learning_points += get_spent_learning_points()
- for skill in BALANCE.SKILL_NAMES:
- skills[skill] = 0
- emit_changed()
- return true
-
-func can_train(_skill: String) -> bool:
return false
+func can_train(skill: String) -> bool:
+ return learning_points > 0 and skill in skill_offers and get_skill_rank(skill) < SKILLS.MAX_RANK
+
func train_skill(skill: String) -> bool:
if not can_train(skill):
return false
skills[skill] = get_skill_rank(skill) + 1
+ if get_skill_rank(skill) == 1 and bool(SKILLS.DATA[skill]["active"]):
+ normalize_equipped_skills()
+ var empty := equipped_skills.find("")
+ if empty >= 0:
+ equipped_skills[empty] = skill
learning_points -= 1
+ skill_offers.clear()
+ prepare_skill_offers()
+ emit_changed()
+ return true
+
+func prepare_skill_offers() -> void:
+ if learning_points <= 0 or not skill_offers.is_empty():
+ return
+ var pool: Array[String] = []
+ for skill in SKILLS.IDS:
+ if get_skill_rank(skill) < SKILLS.MAX_RANK:
+ pool.append(skill)
+ if pool.is_empty():
+ learning_points = 0
+ return
+ while skill_offers.size() < 3 and not pool.is_empty():
+ var index := _skill_rng.randi_range(0, pool.size() - 1)
+ skill_offers.append(pool[index])
+ pool.remove_at(index)
+
+func discounted_price(base_price: int) -> int:
+ return maxi(1, roundi(base_price * (1.0 - get_skill_bonus("business_shark"))))
+
+func should_consume_ammunition(ammo_kind: String) -> bool:
+ # Accumulate fractions for an exact saving rate instead of a lucky streak.
+ var credit := float(_ammo_credit.get(ammo_kind, 0.0)) + get_skill_bonus("efficient_magazine")
+ var saved := credit >= 1.0 - 0.000001
+ _ammo_credit[ammo_kind] = maxf(0.0, credit - 1.0) if saved else credit
+ return not saved
+
+func tick_skills(delta: float) -> void:
+ for skill in skill_cooldowns:
+ skill_cooldowns[skill] = maxf(0.0, float(skill_cooldowns[skill]) - delta)
+ for skill in skill_durations:
+ skill_durations[skill] = maxf(0.0, float(skill_durations[skill]) - delta)
+
+func is_skill_active(skill: String) -> bool:
+ return float(skill_durations.get(skill, 0.0)) > 0.0
+
+func energy_regeneration_multiplier() -> float:
+ var bonus := 1.0
+ for skill in ["reactor_overload", "power_redirect"]:
+ if is_skill_active(skill):
+ bonus += get_skill_bonus(skill)
+ return bonus
+
+func skill_unavailable_reason(skill: String, ship: CharacterBody2D) -> String:
+ if not SKILLS.DATA.has(skill) or not bool(SKILLS.DATA[skill]["active"]) or get_skill_rank(skill) <= 0:
+ return "Umiejętność nie jest odblokowana"
+ if skill not in equipped_skills:
+ return "Przypisz umiejętność do slotu F1–F5 w panelu kapitana"
+ if bool(ship.get("_is_destroyed")) or bool(ship.get("is_docked")):
+ return "Wymagany aktywny lot"
+ if float(skill_cooldowns.get(skill, 0.0)) > 0.0:
+ return "Odnowienie: %.1f s" % float(skill_cooldowns[skill])
+ if skill == "power_redirect" and (float(ship.get("max_shields")) <= 0.0 or float(ship.get("shields")) < float(ship.get("max_shields")) * 0.25):
+ return "Wymagane min. 25% tarcz"
+ return ""
+
+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
+ if skill == "power_redirect":
+ ship.set("shields", 0.0)
+ ship.call("_update_shield_visual")
emit_changed()
return true
@@ -69,6 +168,7 @@ func add_rewards(xp_amount: int, credit_amount: int) -> void:
experience -= get_xp_required()
level += 1
learning_points += BALANCE.LEARNING_POINTS_PER_LEVEL
+ prepare_skill_offers()
emit_changed()
func reward_enemy(system_level: int, tier: int, xp_multiplier: float, credit_multiplier: float, grant_credits: bool = true) -> Vector2i:
diff --git a/scripts/captain/captain_skills.gd b/scripts/captain/captain_skills.gd
new file mode 100644
index 0000000..2f8a45b
--- /dev/null
+++ b/scripts/captain/captain_skills.gd
@@ -0,0 +1,39 @@
+extends RefCounted
+
+const MAX_RANK := 3
+const COOLDOWN := 30.0
+const DURATION := 5.0
+const IDS := ["business_shark", "reactor_overload", "tactical_maneuver", "efficient_magazine", "miner", "power_redirect"]
+const DATA := {
+ "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"},
+ "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"},
+}
+
+static func value(skill: String, rank: int) -> float:
+ if not DATA.has(skill) or rank <= 0:
+ return 0.0
+ return float(DATA[skill]["values"][clampi(rank, 1, MAX_RANK) - 1])
+
+static func icon(skill: String) -> Texture2D:
+ return load("res://assets/icons/captain/%s.png" % skill) as Texture2D
+
+static func description(skill: String, rank: int) -> String:
+ var amount := roundi(value(skill, rank) * 100.0)
+ match skill:
+ "business_shark":
+ return "Zakupy za kredyty tańsze o %d%%." % amount
+ "reactor_overload":
+ return "Regeneracja energii +%d%% przez 5 sekund." % amount
+ "tactical_maneuver":
+ return "Szansa uniku wynosi %d%% przez 5 sekund. Unik anuluje całe trafienie." % amount
+ "efficient_magazine":
+ return "Zużycie amunicji mniejsze o %d%%. Obejmuje zwykłą i napromieniowaną amunicję, bez rakiet." % amount
+ "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 ""
diff --git a/scripts/captain/captain_skills.gd.uid b/scripts/captain/captain_skills.gd.uid
new file mode 100644
index 0000000..2ec613c
--- /dev/null
+++ b/scripts/captain/captain_skills.gd.uid
@@ -0,0 +1 @@
+uid://cl3wkhfx6hl6r
diff --git a/scripts/game/flight_hud.gd b/scripts/game/flight_hud.gd
index 886e20e..0485af1 100644
--- a/scripts/game/flight_hud.gd
+++ b/scripts/game/flight_hud.gd
@@ -9,8 +9,7 @@ extends CanvasLayer
var _debug_expanded := false
var _ability_buttons: Dictionary = {}
var _ability_bar: HBoxContainer
-var _ability_ready_style: StyleBoxFlat
-var _ability_blocked_style: StyleBoxFlat
+var _passive_skills: VBoxContainer
var _inventory_clip: Control
var _sidebar: PanelContainer
var _sidebar_toggle: Button
@@ -25,7 +24,7 @@ var _debug_item_status: Label
var _captain_panel: PanelContainer
var _cockpit: Control
var _crafting_window: PanelContainer
-var _skills_window: PanelContainer
+var _captain_skills_ui: CanvasLayer
func _create_navigation() -> void:
var navigation := HBoxContainer.new()
@@ -43,12 +42,11 @@ func _create_navigation() -> void:
crafting.set_script(preload("res://scripts/ui/ship_crafting_panel.gd"))
crafting.set("standalone", true)
_crafting_window.get_node("Contents").add_child(crafting)
- _skills_window = _create_window("CaptainSkillsWindow", "UMIEJĘTNOŚCI KAPITANA", Vector2(480, 300))
- var empty := Control.new()
- empty.name = "SkillsContent"
- empty.custom_minimum_size = Vector2(440, 230)
- _skills_window.get_node("Contents").add_child(empty)
- for entry in [["Twórz", _crafting_window, "res://assets/icons/ui/crafting.svg", Color("efb568")], ["Umiejętności kapitana", _skills_window, "res://assets/icons/ui/captain_skills.svg", Color("b9a0ef")]]:
+ _captain_skills_ui = CanvasLayer.new()
+ _captain_skills_ui.set_script(preload("res://scripts/ui/captain_skills_ui.gd"))
+ _captain_skills_ui.set("player", player)
+ add_child(_captain_skills_ui)
+ for entry in [["Twórz", _crafting_window, "res://assets/icons/ui/crafting.svg", Color("efb568")], ["Umiejętności kapitana", null, "res://assets/icons/ui/captain_skills.svg", Color("b9a0ef")]]:
var button := Button.new()
button.tooltip_text = entry[0]
button.icon = load(entry[2]) as Texture2D
@@ -63,7 +61,14 @@ func _create_navigation() -> void:
style.set_content_margin_all(8)
button.add_theme_stylebox_override(state, style)
navigation.add_child(button)
- button.pressed.connect(_toggle_window.bind(entry[1]))
+ if entry[1] == null:
+ button.name = "OpenCaptainSkills"
+ button.pressed.connect(func() -> void:
+ _crafting_window.hide()
+ _captain_skills_ui.call("open_management")
+ )
+ else:
+ button.pressed.connect(_toggle_window.bind(entry[1]))
func _create_window(node_name: String, title: String, dimensions: Vector2) -> PanelContainer:
var window := PanelContainer.new()
@@ -105,7 +110,6 @@ func _create_window(node_name: String, title: String, dimensions: Vector2) -> Pa
func _toggle_window(window: PanelContainer) -> void:
var opening := not window.visible
_crafting_window.hide()
- _skills_window.hide()
window.visible = opening
func _ready() -> void:
@@ -152,6 +156,13 @@ func _create_sidebar() -> void:
_captain_panel.position = Vector2(12, 12)
_captain_panel.size = Vector2(320, _captain_panel.get_combined_minimum_size().y)
_captain_panel.resized.connect(_layout_debug)
+ _passive_skills = VBoxContainer.new()
+ _passive_skills.name = "PassiveCaptainSkills"
+ _passive_skills.add_theme_constant_override("separation", 6)
+ $HUDRoot.add_child(_passive_skills)
+ _passive_skills.minimum_size_changed.connect(func() -> void: _layout_debug.call_deferred())
+ GameState.captain.changed.connect(_refresh_passive_skills)
+ _refresh_passive_skills()
_debug_actions = VBoxContainer.new()
_debug_actions.name = "DebugActions"
_debug_actions.add_theme_constant_override("separation", 6)
@@ -229,6 +240,11 @@ func _layout_debug() -> void:
if not is_instance_valid(_debug_scroll) or not is_instance_valid(_captain_panel):
return
var top := _captain_panel.position.y + _captain_panel.size.y + 6.0
+ if is_instance_valid(_passive_skills):
+ _passive_skills.position = Vector2(_captain_panel.position.x, top)
+ _passive_skills.size = Vector2(320, _passive_skills.get_combined_minimum_size().y)
+ if _passive_skills.visible:
+ top += _passive_skills.size.y + 8.0
debug_toggle.position = Vector2(12, top)
_debug_actions.position = Vector2(18, top + 30)
_debug_actions.size = Vector2(310, _debug_actions.get_combined_minimum_size().y)
@@ -265,8 +281,11 @@ func _layout_sidebar() -> void:
_cockpit.position = Vector2(($HUDRoot.size.x - _cockpit.size.x * 0.85) * 0.5, $HUDRoot.size.y - _cockpit.size.y * 0.85 - 12)
if _ability_bar != null:
var abilities := _ability_bar.get_parent() as Control
- abilities.offset_bottom = _cockpit.position.y - $HUDRoot.size.y - 14.0
- abilities.offset_top = abilities.offset_bottom - 44.0
+ abilities.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
+ abilities.position = Vector2(_cockpit.position.x + _cockpit.size.x * _cockpit.scale.x - 204.0, _cockpit.position.y - 40.0)
+ abilities.size = Vector2(204, 36)
+ if is_instance_valid(_captain_skills_ui):
+ _captain_skills_ui.call_deferred("_layout")
_layout_debug()
_sidebar.position = Vector2($HUDRoot.size.x - _sidebar.size.x * EQUIPMENT_UI_SCALE - 8 if _sidebar_open else $HUDRoot.size.x, 136)
_sidebar.visible = _sidebar_open
@@ -301,7 +320,7 @@ Tarcze: %.1f pkt/s
GameState.system_level,
int(game.call("get_remaining_enemies")),
player.velocity.length(),
- float(player.get("energy_regeneration")) + float(player.call("get_solar_regeneration")),
+ float(player.call("get_energy_regeneration")),
float(player.get("shield_regeneration")),
_get_bonus_summary(),
]
@@ -382,8 +401,6 @@ Kredyty: %+.1f%%""".replace("\r", "") % [
]
func _create_ability_bar() -> void:
- _ability_ready_style = _make_ability_style(true)
- _ability_blocked_style = _make_ability_style(false)
var panel := PanelContainer.new()
panel.name = "ActiveAbilities"
$HUDRoot.add_child(panel)
@@ -395,22 +412,22 @@ func _create_ability_bar() -> void:
panel.add_theme_stylebox_override("panel", StyleBoxEmpty.new())
_ability_bar = HBoxContainer.new()
_ability_bar.name = "AbilityButtons"
- _ability_bar.alignment = BoxContainer.ALIGNMENT_CENTER
- _ability_bar.add_theme_constant_override("separation", 4)
+ _ability_bar.alignment = BoxContainer.ALIGNMENT_END
+ _ability_bar.add_theme_constant_override("separation", 6)
panel.add_child(_ability_bar)
for index in ShipInventory.EXTENSION_SLOT_COUNT:
var button := Button.new()
+ button.set_script(preload("res://scripts/ui/captain_skill_button.gd"))
button.name = "Ability%d" % (index + 1)
- button.custom_minimum_size = Vector2(44, 44)
- button.expand_icon = true
- button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
- button.add_theme_constant_override("icon_max_width", 30)
- button.focus_mode = Control.FOCUS_NONE
+ button.call("configure", null, Color("91dbea"))
var shortcut := Label.new()
shortcut.text = str(index + 1)
- shortcut.position = Vector2(3, 1)
+ shortcut.position = Vector2(4, 2)
shortcut.mouse_filter = Control.MOUSE_FILTER_IGNORE
shortcut.add_theme_font_size_override("font_size", 9)
+ shortcut.add_theme_font_override("font", ThemeDB.fallback_font)
+ shortcut.add_theme_color_override("font_outline_color", Color("030810"))
+ shortcut.add_theme_constant_override("outline_size", 4)
button.add_child(shortcut)
button.add_theme_font_size_override("font_size", 11)
button.pressed.connect(func() -> void: player.call("activate_ability", index))
@@ -430,22 +447,69 @@ func _update_ability_bar() -> void:
var reason: String = player.call("ability_unavailable_reason", index)
button.disabled = not reason.is_empty()
var item: ItemInstance = player.get_node("ShipInventory").get_item("extension", index)
- button.icon = item.definition.icon
- button.text = ""
- button.add_theme_color_override("icon_disabled_color", Color(0.45, 0.5, 0.55, 0.65))
- var style := _ability_ready_style if reason.is_empty() else _ability_blocked_style
- button.add_theme_stylebox_override("normal", style)
- button.add_theme_stylebox_override("disabled", style)
- button.add_theme_stylebox_override("hover", style)
- button.add_theme_color_override("font_color", Color("a5ffda"))
- button.add_theme_color_override("font_disabled_color", Color("a3aebb"))
+ button.call("configure", item.definition.icon, Color("91dbea"))
+ button.call("set_visual_state", 0.0, false, not reason.is_empty())
button.tooltip_text = "[%d] %s\n" % [index + 1, ability.display_name] + ability.description + ("\n" + reason if not reason.is_empty() else "\nGotowe")
_ability_bar.get_parent().visible = any_equipped
-func _make_ability_style(ready: bool) -> StyleBoxFlat:
- var style := StyleBoxFlat.new()
- style.bg_color = Color("174237") if ready else Color("202731")
- style.border_color = Color("77e8bc") if ready else Color("526071")
- style.set_border_width_all(2 if ready else 1)
- style.set_content_margin_all(6)
- return style
+func _refresh_passive_skills() -> void:
+ if not is_instance_valid(_passive_skills):
+ return
+ for child in _passive_skills.get_children():
+ _passive_skills.remove_child(child)
+ child.queue_free()
+ var catalog := preload("res://scripts/captain/captain_skills.gd")
+ for skill in catalog.IDS:
+ var rank := GameState.captain.get_skill_rank(skill)
+ if rank <= 0 or bool(catalog.DATA[skill]["active"]):
+ continue
+ var accent := Color(catalog.DATA[skill]["color"])
+ var card := PanelContainer.new()
+ card.name = skill
+ card.custom_minimum_size = Vector2(320, 54)
+ card.tooltip_text = catalog.description(skill, rank)
+ var style := StyleBoxFlat.new()
+ style.bg_color = Color(0.025, 0.042, 0.064, 0.94)
+ style.border_color = accent.darkened(0.6)
+ style.set_border_width_all(1)
+ style.border_width_left = 2
+ style.set_corner_radius_all(7)
+ style.set_content_margin_all(8)
+ card.add_theme_stylebox_override("panel", style)
+ _passive_skills.add_child(card)
+ var row := HBoxContainer.new()
+ row.add_theme_constant_override("separation", 10)
+ card.add_child(row)
+ var icon := Button.new()
+ icon.set_script(preload("res://scripts/ui/captain_skill_button.gd"))
+ icon.call("configure", catalog.icon(skill), accent)
+ icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ icon.disabled = true
+ row.add_child(icon)
+ var contents := VBoxContainer.new()
+ contents.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ contents.add_theme_constant_override("separation", 3)
+ row.add_child(contents)
+ var header := HBoxContainer.new()
+ contents.add_child(header)
+ var title := _passive_label(catalog.DATA[skill]["name"], 14, Color("dfedf4"))
+ title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ header.add_child(title)
+ header.add_child(_passive_label("●".repeat(rank) + "○".repeat(3 - rank), 10, accent))
+ var value := roundi(GameState.captain.get_skill_bonus(skill) * 100)
+ var summary := ""
+ match skill:
+ "business_shark": summary = "−%d%% kosztów zakupów" % value
+ "efficient_magazine": summary = "−%d%% zużycia amunicji" % value
+ "miner": summary = "+%d surowca / asteroidę" % roundi(GameState.captain.get_skill_bonus(skill))
+ contents.add_child(_passive_label(summary, 12, accent))
+ _passive_skills.visible = _passive_skills.get_child_count() > 0
+ _layout_debug.call_deferred()
+
+func _passive_label(text: String, font_size: int, color: Color) -> Label:
+ var label := Label.new()
+ label.text = text
+ label.add_theme_font_override("font", ThemeDB.fallback_font)
+ label.add_theme_font_size_override("font_size", font_size)
+ label.add_theme_color_override("font_color", color)
+ return label
diff --git a/scripts/game/game.gd b/scripts/game/game.gd
index 50cadb7..a241d87 100644
--- a/scripts/game/game.gd
+++ b/scripts/game/game.gd
@@ -137,6 +137,12 @@ func get_remaining_enemies() -> int:
remaining += 1
return remaining
+func is_boss_alive() -> bool:
+ for enemy in get_tree().get_nodes_in_group("enemies"):
+ if is_ancestor_of(enemy) and not enemy.is_queued_for_deletion() and int(enemy.get("loot_tier")) == 2 and float(enemy.get("hull")) > 0.0:
+ return true
+ return false
+
func _physics_process(_delta: float) -> void:
if _transitioning or bool(player.get("_is_destroyed")):
services_menu.call("hide_services")
diff --git a/scripts/game/ship_controller.gd b/scripts/game/ship_controller.gd
index 29abb33..5f3f9a3 100644
--- a/scripts/game/ship_controller.gd
+++ b/scripts/game/ship_controller.gd
@@ -166,13 +166,16 @@ func take_damage(amount: float, _critical: bool = false, damage_type: int = Dama
_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:
+ if _is_destroyed or actual_damage <= 0.0 or shield_on_damage_percent <= 0.0 or GameState.captain.is_skill_active("power_redirect"):
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")
+ return GameState.captain.get_skill_bonus("tactical_maneuver") if GameState.captain.is_skill_active("tactical_maneuver") else 0.0
+
+func get_energy_regeneration() -> float:
+ return (energy_regeneration + get_solar_regeneration()) * GameState.captain.energy_regeneration_multiplier()
func get_damage_reduction() -> float:
return GameState.captain.get_skill_bonus("mechanic")
@@ -254,11 +257,15 @@ func _restart_level_after_destruction() -> void:
get_tree().reload_current_scene.call_deferred()
func _physics_process(delta: float) -> void:
+ if not _is_destroyed:
+ GameState.captain.tick_skills(delta)
+ if GameState.captain.is_skill_active("power_redirect"):
+ shields = 0.0
if _is_destroyed or is_docked:
_mini_boost_active = false
return
_contact_damage_timer = maxf(0.0, _contact_damage_timer - delta)
- energy = minf(max_energy, energy + (energy_regeneration + get_solar_regeneration()) * delta)
+ energy = minf(max_energy, energy + get_energy_regeneration() * delta)
var was_mini_boosting := _mini_boost_active
_update_mini_boost(delta)
if _mini_boost_active:
@@ -277,7 +284,7 @@ func _physics_process(delta: float) -> void:
_boost_speed = move_toward(_boost_speed, 0.0, space_drag * delta)
if was_boosting:
velocity = velocity.move_toward(Vector2.ZERO, space_drag * delta)
- if shield_regeneration > 0.0 and shields < max_shields:
+ if shield_regeneration > 0.0 and shields < max_shields and not GameState.captain.is_skill_active("power_redirect"):
var previous_shields := shields
shields = minf(max_shields, shields + shield_regeneration * delta)
if shields != previous_shields:
diff --git a/scripts/items/ship_inventory.gd b/scripts/items/ship_inventory.gd
index f7f5b40..85b0efd 100644
--- a/scripts/items/ship_inventory.gd
+++ b/scripts/items/ship_inventory.gd
@@ -75,7 +75,8 @@ func preserve_for_restart() -> void:
func consume_ammunition() -> bool:
if ammunition <= 0:
return false
- ammunition -= 1
+ if GameState.captain.should_consume_ammunition("ordinary"):
+ ammunition -= 1
ammunition_changed.emit()
return true
@@ -98,7 +99,7 @@ func get_item(slot_kind: String, index: int) -> ItemInstance:
return slots[index]
func get_ammunition_buy_cost() -> int:
- return AMMUNITION_BUY_BASE_COST * maxi(1, GameState.system_level)
+ return GameState.captain.discounted_price(AMMUNITION_BUY_BASE_COST * maxi(1, GameState.system_level))
func can_buy_ammunition() -> bool:
return GameState.captain.credits >= get_ammunition_buy_cost()
@@ -182,7 +183,7 @@ 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)
+ return GameState.captain.discounted_price(MISSILE_BUY_BASE_COST * maxi(1, GameState.system_level))
func can_buy_missile() -> bool:
return GameState.captain.credits >= get_missile_buy_cost()
@@ -345,7 +346,8 @@ func _weapon_dps(item: ItemInstance, bonuses: Dictionary) -> float:
func consume_irradiated_ammunition() -> bool:
if irradiated_ammunition <= 0:
return false
- irradiated_ammunition -= 1
+ if GameState.captain.should_consume_ammunition("irradiated"):
+ irradiated_ammunition -= 1
ammunition_changed.emit()
return true
@@ -363,12 +365,15 @@ func craft_irradiated_ammunition() -> bool:
return true
func can_buy_irradiated_ammunition() -> bool:
- return GameState.captain.credits >= 15 * maxi(1, GameState.system_level)
+ return GameState.captain.credits >= get_irradiated_ammunition_buy_cost()
+
+func get_irradiated_ammunition_buy_cost() -> int:
+ return GameState.captain.discounted_price(15 * maxi(1, GameState.system_level))
func buy_irradiated_ammunition() -> bool:
if not can_buy_irradiated_ammunition():
return false
- GameState.captain.credits -= 15 * maxi(1, GameState.system_level)
+ GameState.captain.credits -= get_irradiated_ammunition_buy_cost()
irradiated_ammunition += 10
GameState.captain.emit_changed()
ammunition_changed.emit()
diff --git a/scripts/levels/exploration_poi.gd b/scripts/levels/exploration_poi.gd
index bd9acd4..5999d33 100644
--- a/scripts/levels/exploration_poi.gd
+++ b/scripts/levels/exploration_poi.gd
@@ -113,7 +113,7 @@ func claim() -> bool:
var summary := ""
var scale := reward_scale()
if kind == "asteroid":
- var amount := maxi(1, roundi(_rng.randi_range(1, 3) * scale))
+ var amount := maxi(1, roundi(_rng.randi_range(1, 3) * scale)) + roundi(GameState.captain.get_skill_bonus("miner"))
GameState.captain.minerals[_mineral] = int(GameState.captain.minerals.get(_mineral, 0)) + amount
summary = "+%d %s" % [amount, {"iron": "żelaza", "aluminium": "aluminium", "titanium": "tytanu", "uranium": "uranu"}[_mineral]]
elif kind == "ruin":
diff --git a/scripts/ui/captain_skill_button.gd b/scripts/ui/captain_skill_button.gd
new file mode 100644
index 0000000..2909ace
--- /dev/null
+++ b/scripts/ui/captain_skill_button.gd
@@ -0,0 +1,74 @@
+extends Button
+
+const ICON_SHADER := preload("res://resources/ui/skill_icon.gdshader")
+var _icon_material: ShaderMaterial
+var _normal: StyleBoxFlat
+var _disabled: StyleBoxFlat
+var _active: StyleBoxFlat
+var _was_active := false
+var _artwork: Texture2D
+
+func configure(artwork: Texture2D, accent: Color) -> void:
+ if _normal != null and _artwork == artwork:
+ return
+ _artwork = artwork
+ _was_active = false
+ text = ""
+ var old_picture := get_node_or_null("RoundedArtwork")
+ if old_picture != null:
+ remove_child(old_picture)
+ old_picture.queue_free()
+ custom_minimum_size = Vector2(36, 36)
+ focus_mode = Control.FOCUS_NONE
+ mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
+ _normal = _frame(Color("405c70"), Color("0a1420"))
+ _disabled = _frame(Color("2d4253"), Color("09111c"))
+ _active = _frame(Color("a9efb4"), Color("142b28"))
+ add_theme_stylebox_override("normal", _normal)
+ add_theme_stylebox_override("disabled", _disabled)
+ add_theme_stylebox_override("hover", _frame(accent.lightened(0.3), Color("162638")))
+ add_theme_stylebox_override("pressed", _frame(Color("e5f6ff"), Color("1b3445")))
+ add_theme_stylebox_override("focus", StyleBoxEmpty.new())
+ if artwork == null:
+ text = "·"
+ add_theme_color_override("font_disabled_color", Color("4a6376"))
+ return
+ var picture := TextureRect.new()
+ picture.name = "RoundedArtwork"
+ picture.texture = artwork
+ picture.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
+ picture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
+ picture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
+ picture.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ _icon_material = ShaderMaterial.new()
+ _icon_material.shader = ICON_SHADER
+ picture.material = _icon_material
+ add_child(picture)
+ move_child(picture, 0)
+ picture.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ picture.offset_left = 2
+ picture.offset_top = 2
+ picture.offset_right = -2
+ picture.offset_bottom = -2
+ picture.resized.connect(func() -> void: _icon_material.set_shader_parameter("icon_size", picture.size))
+
+func set_visual_state(cooldown_fraction: float, active: bool, blocked: bool) -> void:
+ if _icon_material != null:
+ _icon_material.set_shader_parameter("cooldown", clampf(cooldown_fraction, 0.0, 1.0))
+ _icon_material.set_shader_parameter("dimming", 0.0 if active else (0.6 if blocked else 0.0))
+ if active != _was_active:
+ _was_active = active
+ add_theme_stylebox_override("normal", _active if active else _normal)
+ add_theme_stylebox_override("disabled", _active if active else _disabled)
+
+func _frame(border: Color, background: Color) -> StyleBoxFlat:
+ var style := StyleBoxFlat.new()
+ style.bg_color = background
+ style.border_color = border
+ style.set_border_width_all(1)
+ style.set_corner_radius_all(6)
+ style.set_content_margin_all(0)
+ style.shadow_color = Color(0.0, 0.0, 0.0, 0.35)
+ style.shadow_size = 2
+ style.shadow_offset = Vector2(0, 1)
+ return style
diff --git a/scripts/ui/captain_skill_button.gd.uid b/scripts/ui/captain_skill_button.gd.uid
new file mode 100644
index 0000000..cfecf37
--- /dev/null
+++ b/scripts/ui/captain_skill_button.gd.uid
@@ -0,0 +1 @@
+uid://b6cgtrcb6cg2m
diff --git a/scripts/ui/captain_skills_ui.gd b/scripts/ui/captain_skills_ui.gd
new file mode 100644
index 0000000..10180e2
--- /dev/null
+++ b/scripts/ui/captain_skills_ui.gd
@@ -0,0 +1,324 @@
+extends CanvasLayer
+
+const CATALOG := preload("res://scripts/captain/captain_skills.gd")
+const BUTTON_STYLE := preload("res://scripts/ui/hud_button_style.gd")
+var player: CharacterBody2D
+var _captain: CaptainProgress
+var _screen: Control
+var _panel: PanelContainer
+var _contents: VBoxContainer
+var _bar: HBoxContainer
+var _buttons: Dictionary = {}
+var _was_paused := false
+var _owns_pause := false
+var _refresh_pending := false
+
+func _ready() -> void:
+ name = "CaptainSkills"
+ layer = 40
+ process_mode = Node.PROCESS_MODE_ALWAYS
+ _captain = GameState.captain
+ _captain.normalize_equipped_skills()
+ _screen = Control.new()
+ _screen.name = "SkillManagement"
+ add_child(_screen)
+ _screen.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ _screen.hide()
+ var dim := ColorRect.new()
+ dim.color = Color(0.015, 0.025, 0.045, 0.9)
+ _screen.add_child(dim)
+ dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ _panel = PanelContainer.new()
+ _panel.name = "Panel"
+ _panel.add_theme_stylebox_override("panel", _style(Color("567b99"), Color("0a1422"), 22))
+ _screen.add_child(_panel)
+ _panel.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
+ _contents = VBoxContainer.new()
+ _contents.add_theme_constant_override("separation", 14)
+ _panel.add_child(_contents)
+ _bar = HBoxContainer.new()
+ _bar.name = "CaptainActiveSkills"
+ _bar.add_theme_constant_override("separation", 6)
+ add_child(_bar)
+ _captain.changed.connect(_queue_refresh)
+ get_viewport().size_changed.connect(_layout)
+ _queue_refresh()
+
+func _queue_refresh() -> void:
+ if not _refresh_pending:
+ _refresh_pending = true
+ _refresh.call_deferred()
+
+func _label(text: String, font_size: int, color: Color = Color("dae8ef")) -> Label:
+ var label := Label.new()
+ label.text = text
+ label.add_theme_font_override("font", ThemeDB.fallback_font)
+ label.add_theme_font_size_override("font_size", font_size)
+ label.add_theme_color_override("font_color", color)
+ return label
+
+func _style(border: Color, background: Color, margin: int) -> StyleBoxFlat:
+ var style := StyleBoxFlat.new()
+ style.bg_color = background
+ style.border_color = border
+ style.set_border_width_all(1)
+ style.set_corner_radius_all(10)
+ style.set_content_margin_all(margin)
+ return style
+
+func _refresh() -> void:
+ _refresh_pending = false
+ _captain.prepare_skill_offers()
+ _rebuild_bar()
+ if not _captain.skill_offers.is_empty() and not bool(player.get("_is_destroyed")):
+ open_management()
+ elif _screen.visible:
+ _build_management()
+
+func open_management() -> void:
+ if bool(player.get("_is_destroyed")):
+ return
+ if not _screen.visible:
+ _was_paused = get_tree().paused
+ _owns_pause = true
+ get_tree().paused = true
+ _screen.show()
+ _build_management()
+
+func _close() -> void:
+ if not _captain.skill_offers.is_empty():
+ return
+ _screen.hide()
+ if _owns_pause:
+ get_tree().paused = _was_paused
+ _owns_pause = false
+
+func _build_management() -> void:
+ for child in _contents.get_children():
+ _contents.remove_child(child)
+ child.queue_free()
+ var choosing := not _captain.skill_offers.is_empty()
+ var heading := HBoxContainer.new()
+ _contents.add_child(heading)
+ var title := _label("AWANS KAPITANA" if choosing else "UMIEJĘTNOŚCI KAPITANA", 25, Color("91dbea"))
+ title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ heading.add_child(title)
+ if not choosing:
+ var close := Button.new()
+ close.name = "Close"
+ close.text = "WRÓĆ DO LOTU"
+ close.pressed.connect(_close)
+ heading.add_child(close)
+ _contents.add_child(_label("POZIOM %d · GRA WSTRZYMANA" % _captain.level, 13, Color("91aabd")))
+ if choosing:
+ _contents.add_child(_label("Wybierz jedną umiejętność. Wybór obowiązuje przez całą rozgrywkę.", 16))
+ var choices := HBoxContainer.new()
+ choices.name = "Choices"
+ choices.add_theme_constant_override("separation", 14)
+ _contents.add_child(choices)
+ for skill in _captain.skill_offers:
+ choices.add_child(_choice_card(skill))
+ if _captain.learning_points > 1:
+ _contents.add_child(_label("Pozostałe wybory po tym awansie: %d" % (_captain.learning_points - 1), 13, Color("efb568")))
+ _contents.add_child(HSeparator.new())
+ _contents.add_child(_label("POSIADANE UMIEJĘTNOŚCI", 14, Color("91dbea")))
+ var owned := GridContainer.new()
+ owned.columns = 3
+ owned.add_theme_constant_override("h_separation", 12)
+ owned.add_theme_constant_override("v_separation", 10)
+ _contents.add_child(owned)
+ for skill in CATALOG.IDS:
+ var rank := _captain.get_skill_rank(skill)
+ if rank <= 0:
+ continue
+ var row := HBoxContainer.new()
+ row.custom_minimum_size.x = 260
+ row.tooltip_text = CATALOG.description(skill, rank)
+ owned.add_child(row)
+ row.add_child(_icon(skill, 38))
+ var info := VBoxContainer.new()
+ row.add_child(info)
+ info.add_child(_label(CATALOG.DATA[skill]["name"], 13))
+ info.add_child(_label("%s · %s" % ["●".repeat(rank) + "○".repeat(3 - rank), "AKTYWNA" if CATALOG.DATA[skill]["active"] else "PASYWNA"], 11, Color(CATALOG.DATA[skill]["color"])))
+ if owned.get_child_count() == 0:
+ _contents.add_child(_label("Nie masz jeszcze umiejętności. To początek Twojej specjalizacji.", 14, Color("91aabd")))
+ _build_loadout()
+ _layout.call_deferred()
+
+func _build_loadout() -> void:
+ _contents.add_child(HSeparator.new())
+ _contents.add_child(_label("AKTYWNE SLOTY F1–F5 · MAKSYMALNIE 5 UMIEJĘTNOŚCI", 13, Color("91dbea")))
+ var row := HBoxContainer.new()
+ row.name = "ActiveSkillLoadout"
+ row.add_theme_constant_override("separation", 10)
+ _contents.add_child(row)
+ for slot in CaptainProgress.ACTIVE_SKILL_SLOTS:
+ var column := VBoxContainer.new()
+ column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ row.add_child(column)
+ column.add_child(_label("F%d" % (slot + 1), 12, Color("efb568")))
+ var picker := OptionButton.new()
+ picker.name = "SlotF%d" % (slot + 1)
+ picker.fit_to_longest_item = false
+ picker.custom_minimum_size = Vector2(155, 36)
+ picker.add_theme_font_override("font", ThemeDB.fallback_font)
+ picker.add_theme_font_size_override("font_size", 12)
+ picker.add_theme_constant_override("icon_max_width", 26)
+ picker.get_popup().add_theme_constant_override("icon_max_width", 28)
+ picker.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
+ picker.add_item("Pusty slot")
+ picker.set_item_metadata(0, "")
+ for skill in CATALOG.IDS:
+ if CATALOG.DATA[skill]["active"] and _captain.get_skill_rank(skill) > 0:
+ picker.add_icon_item(CATALOG.icon(skill), CATALOG.DATA[skill]["name"])
+ var index := picker.item_count - 1
+ picker.set_item_metadata(index, skill)
+ if _captain.equipped_skills[slot] == skill:
+ picker.select(index)
+ picker.tooltip_text = "Wybierz aktywną umiejętność dla F%d. Pasywne działają bez zajmowania slotu." % (slot + 1)
+ picker.item_selected.connect(func(index: int) -> void: _captain.equip_skill(slot, str(picker.get_item_metadata(index))))
+ column.add_child(picker)
+
+func _icon(skill: String, dimensions: float) -> TextureRect:
+ var icon := TextureRect.new()
+ icon.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
+ icon.texture = CATALOG.icon(skill)
+ icon.custom_minimum_size = Vector2.ONE * dimensions
+ icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
+ icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
+ icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ return icon
+
+func _choice_card(skill: String) -> PanelContainer:
+ var rank := _captain.get_skill_rank(skill)
+ var next_rank := rank + 1
+ var data: Dictionary = CATALOG.DATA[skill]
+ var color := Color(data["color"])
+ var card := PanelContainer.new()
+ card.name = skill
+ card.custom_minimum_size = Vector2(266, 340)
+ card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ card.add_theme_stylebox_override("panel", _style(color.darkened(0.4), Color("101e2e"), 16))
+ var body := VBoxContainer.new()
+ body.add_theme_constant_override("separation", 10)
+ card.add_child(body)
+ body.add_child(_label("NOWA UMIEJĘTNOŚĆ" if rank == 0 else "ULEPSZENIE %s → %s" % [str(rank), str(next_rank)], 11, color))
+ body.add_child(_icon(skill, 76))
+ var title := _label(data["name"], 19)
+ title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ body.add_child(title)
+ body.add_child(_label("POZIOM %s / III · %s" % [["I", "II", "III"][next_rank - 1], "AKTYWNA" if data["active"] else "PASYWNA"], 11, color))
+ var description := _label(CATALOG.description(skill, next_rank), 14)
+ description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ 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")))
+ else:
+ body.add_child(_label("STAŁY EFEKT", 11, Color("91aabd")))
+ var select := Button.new()
+ select.name = "Choose"
+ select.text = "WYBIERZ" if rank == 0 else "ULEPSZ DO %s" % ["I", "II", "III"][next_rank - 1]
+ select.custom_minimum_size.y = 38
+ select.focus_mode = Control.FOCUS_NONE
+ BUTTON_STYLE.apply(select, color)
+ select.pressed.connect(_choose.bind(skill))
+ body.add_child(select)
+ return card
+
+func _choose(skill: String) -> void:
+ if not _captain.train_skill(skill):
+ return
+ # Clear any held mouse fire state before resuming the simulation.
+ player.get_node("WeaponController").set("_fire_buttons_down", [false, false, false])
+ player.get_node("WeaponController").set("_fire_just_pressed", [false, false, false])
+ if _captain.skill_offers.is_empty():
+ _close()
+
+func _rebuild_bar() -> void:
+ for child in _bar.get_children():
+ _bar.remove_child(child)
+ child.queue_free()
+ _buttons.clear()
+ _captain.normalize_equipped_skills()
+ for index in CaptainProgress.ACTIVE_SKILL_SLOTS:
+ var skill := _captain.equipped_skills[index]
+ var column := VBoxContainer.new()
+ _bar.add_child(column)
+ var button := Button.new()
+ button.set_script(preload("res://scripts/ui/captain_skill_button.gd"))
+ button.name = "SlotF%d" % (index + 1)
+ button.call("configure", CATALOG.icon(skill) if not skill.is_empty() else null, Color(CATALOG.DATA[skill]["color"]) if not skill.is_empty() else Color("344657"))
+ button.disabled = skill.is_empty()
+ button.pressed.connect(_activate.bind(skill))
+ column.add_child(button)
+ var binding := _label("F%d" % (index + 1), 9)
+ binding.position = Vector2(4, 2)
+ binding.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ binding.add_theme_color_override("font_outline_color", Color("030810"))
+ binding.add_theme_constant_override("outline_size", 4)
+ button.add_child(binding)
+ var status := _label("", 9)
+ status.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
+ status.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ status.add_theme_color_override("font_outline_color", Color("030810"))
+ status.add_theme_constant_override("outline_size", 4)
+ button.add_child(status)
+ status.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_RIGHT)
+ status.offset_left = -33
+ status.offset_top = -14
+ status.offset_right = -3
+ status.offset_bottom = -1
+ _buttons[index] = {"button": button, "status": status, "skill": skill, "key": KEY_F1 + index, "binding": "F%d" % (index + 1)}
+ _layout.call_deferred()
+
+func _activate(skill: String) -> void:
+ if not get_tree().paused:
+ _captain.activate_skill(skill, player)
+
+func _unhandled_key_input(event: InputEvent) -> void:
+ if event is InputEventKey and event.pressed and not event.echo:
+ if event.keycode == KEY_ESCAPE and _screen.visible:
+ _close()
+ get_viewport().set_input_as_handled()
+ elif not get_tree().paused:
+ for index in _buttons:
+ if event.physical_keycode == _buttons[index]["key"] or event.keycode == _buttons[index]["key"]:
+ _activate(_buttons[index]["skill"])
+ get_viewport().set_input_as_handled()
+
+func _process(_delta: float) -> void:
+ for index in _buttons:
+ var entry: Dictionary = _buttons[index]
+ var skill: String = entry["skill"]
+ var button: Button = entry["button"]
+ var status: Label = entry["status"]
+ if skill.is_empty():
+ button.tooltip_text = "[%s] Pusty slot — przypisz skill w panelu kapitana" % entry["binding"]
+ continue
+ var duration := float(_captain.skill_durations.get(skill, 0.0))
+ var cooldown := float(_captain.skill_cooldowns.get(skill, 0.0))
+ var reason := _captain.skill_unavailable_reason(skill, player)
+ button.disabled = get_tree().paused or not reason.is_empty()
+ button.call("set_visual_state", cooldown / CATALOG.COOLDOWN, duration > 0.0, button.disabled)
+ button.tooltip_text = "%s · poziom %d\n%s\n[%s] · Odnowienie 30 s%s" % [CATALOG.DATA[skill]["name"], _captain.get_skill_rank(skill), CATALOG.description(skill, _captain.get_skill_rank(skill)), entry["binding"], "\n" + reason if not reason.is_empty() else ""]
+ status.text = "%.1f" % duration if duration > 0.0 else (str(ceili(cooldown)) if cooldown > 0.0 else "")
+ status.modulate = Color("a9ef83") if duration > 0.0 else Color("dae8ef")
+ _bar.visible = not bool(player.get("_is_destroyed")) and not _screen.visible
+
+func _layout() -> void:
+ var viewport_size := get_viewport().get_visible_rect().size
+ var base_size := _panel.get_combined_minimum_size().max(Vector2(884, 180))
+ var ui_scale := minf(1.0, minf((viewport_size.x - 32) / base_size.x, (viewport_size.y - 32) / base_size.y))
+ _panel.size = base_size
+ _panel.scale = Vector2.ONE * ui_scale
+ _panel.position = (viewport_size - base_size * ui_scale) * 0.5
+ var cockpit := get_parent().get("_cockpit") as Control
+ if is_instance_valid(cockpit):
+ _bar.position = cockpit.position + Vector2(0, -40)
+ else:
+ _bar.position = Vector2(16, viewport_size.y - 190)
+
+func _exit_tree() -> void:
+ if _owns_pause and is_inside_tree():
+ get_tree().paused = _was_paused
diff --git a/scripts/ui/captain_skills_ui.gd.uid b/scripts/ui/captain_skills_ui.gd.uid
new file mode 100644
index 0000000..d917099
--- /dev/null
+++ b/scripts/ui/captain_skills_ui.gd.uid
@@ -0,0 +1 @@
+uid://cs7ixgwu5l0qw
diff --git a/scripts/ui/flight_cockpit.gd b/scripts/ui/flight_cockpit.gd
index 836eebc..458568a 100644
--- a/scripts/ui/flight_cockpit.gd
+++ b/scripts/ui/flight_cockpit.gd
@@ -48,6 +48,7 @@ func _draw() -> void:
var cell := (size.x - 238) / 3.0
var values := [_inventory.missile_ammunition, _inventory.ammunition, int(game.call("get_remaining_enemies"))]
var captions := ["RAKIETY", "AMUNICJA", "WROGOWIE"]
+ var boss_alive := bool(game.call("is_boss_alive"))
for index in 3:
var x := 218.0 + index * cell
var color := AMBER if index == 0 else (CYAN if index == 1 else Color("ff8989"))
@@ -55,6 +56,8 @@ func _draw() -> void:
if index == 1:
_text("ZWYKŁE %02d" % _inventory.ammunition, Vector2(x, 35), cell, 11, CYAN)
_text("GAMMA %02d" % _inventory.irradiated_ammunition, Vector2(x, 49), cell, 11, Color("a9ef83"))
+ elif index == 2 and boss_alive:
+ _text("BOSS", Vector2(x, 45), cell, 21, Color("ff8989"))
else:
_text(str(values[index]).pad_zeros(2), Vector2(x, 45), cell, 21, color)
if index < 2:
diff --git a/scripts/ui/planet_services_menu.gd b/scripts/ui/planet_services_menu.gd
index 0a0f16c..3e68630 100644
--- a/scripts/ui/planet_services_menu.gd
+++ b/scripts/ui/planet_services_menu.gd
@@ -85,7 +85,7 @@ func _position_at_planet() -> void:
func _refresh_ammunition_service() -> void:
if _gamma_buy != null:
- _gamma_buy.text = "KUP 10 NAPROMIENIOWANEJ — %d KREDYTÓW" % (15 * maxi(1, GameState.system_level))
+ _gamma_buy.text = "KUP 10 NAPROMIENIOWANEJ — %d KREDYTÓW" % (_inventory.get_irradiated_ammunition_buy_cost() if _inventory else 15 * 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)
@@ -95,7 +95,7 @@ func _refresh_ammunition_service() -> void:
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\nCena: %d kredytów × poziom układu (%d)" % [_inventory.ammunition, GameState.captain.credits, ShipInventory.AMMUNITION_BUY_BASE_COST, GameState.system_level]
+ 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)]
func _buy_ammunition() -> void:
if visible and _inventory:
diff --git a/tests/captain/captain_skills_test.gd b/tests/captain/captain_skills_test.gd
new file mode 100644
index 0000000..aa73ead
--- /dev/null
+++ b/tests/captain/captain_skills_test.gd
@@ -0,0 +1,207 @@
+extends Node
+
+const CATALOG := preload("res://scripts/captain/captain_skills.gd")
+const PROGRESS := preload("res://scripts/captain/captain_progress.gd")
+const POI := preload("res://scripts/levels/exploration_poi.gd")
+
+func _ready() -> void:
+ _test_offers()
+ GameState.start_new_run()
+ var game := preload("res://scenes/game/game.tscn").instantiate()
+ add_child(game)
+ game.set_physics_process(false)
+ var ship: CharacterBody2D = game.get_node("PlayerShip")
+ ship.set_physics_process(false)
+ ship.get_node("WeaponController").set_physics_process(false)
+ for enemy in get_tree().get_nodes_in_group("enemies"):
+ enemy.set_physics_process(false)
+ var captain := GameState.captain
+ var inventory: ShipInventory = ship.get_node("ShipInventory")
+ for rank in range(1, 4):
+ captain.skills["business_shark"] = rank
+ assert(inventory.get_ammunition_buy_cost() == [18, 14, 8][rank - 1])
+ assert(inventory.get_missile_buy_cost() == [9, 7, 4][rank - 1])
+ assert(inventory.get_irradiated_ammunition_buy_cost() == [14, 11, 6][rank - 1])
+ captain.skills["efficient_magazine"] = rank
+ captain._ammo_credit.clear()
+ inventory.ammunition = 100
+ inventory.irradiated_ammunition = 100
+ for shot in 100:
+ assert(inventory.consume_ammunition())
+ assert(inventory.consume_irradiated_ammunition())
+ assert(inventory.ammunition == [10, 20, 50][rank - 1])
+ assert(inventory.irradiated_ammunition == [10, 20, 50][rank - 1])
+ inventory.missile_ammunition = 2
+ assert(inventory.consume_missile())
+ assert(inventory.missile_ammunition == 1)
+ captain.skills["reactor_overload"] = rank
+ captain.skills["tactical_maneuver"] = rank
+ captain.skills["power_redirect"] = rank
+ assert(captain.equip_skill(0, "reactor_overload"))
+ assert(captain.equip_skill(1, "tactical_maneuver"))
+ assert(captain.equip_skill(2, "power_redirect"))
+ captain.skill_cooldowns.clear()
+ captain.skill_durations.clear()
+ var base_regeneration: float = ship.get_energy_regeneration()
+ 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]))
+ ship.energy = 0.0
+ ship._physics_process(0.1)
+ assert(is_equal_approx(ship.energy, base_regeneration * [1.1, 1.2, 1.7][rank - 1] * 0.1))
+ assert(not captain.activate_skill("reactor_overload", ship))
+ assert(captain.activate_skill("tactical_maneuver", ship))
+ assert(is_equal_approx(ship.get_evasion_chance(), [0.3, 0.6, 1.0][rank - 1]))
+ if rank == 3:
+ var before_hit: float = ship.shields
+ ship.take_damage(100.0)
+ assert(ship.shields == before_hit)
+ ship.shields = ship.max_shields * 0.24
+ assert(not captain.activate_skill("power_redirect", ship))
+ assert(not captain.skill_cooldowns.has("power_redirect"))
+ ship.shields = ship.max_shields * 0.25
+ assert(captain.activate_skill("power_redirect", ship))
+ 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]))
+ ship.shield_on_damage_percent = 100.0
+ ship.restore_shields_from_damage(100.0)
+ assert(ship.shields == 0.0)
+ var shield_ability := preload("res://scripts/abilities/shield_charge_ability.gd").new()
+ shield_ability.activate(ship)
+ assert(ship.shields == 0.0)
+ var previous_regeneration: float = ship.shield_regeneration
+ ship.shield_regeneration = 10.0
+ ship._physics_process(0.1)
+ assert(ship.shields == 0.0)
+ 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(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(25.0)
+ assert(captain.skill_cooldowns["reactor_overload"] == 0.0)
+
+ assert(captain.equipped_skills.size() == 5)
+ assert(not captain.equip_skill(5, "reactor_overload"))
+ assert(not captain.equip_skill(-1, "reactor_overload"))
+ assert(not captain.equip_skill(3, "miner"))
+ assert(not captain.equip_skill(3, "unknown"))
+ captain.skill_cooldowns["reactor_overload"] = 12.0
+ assert(captain.equip_skill(4, "reactor_overload"))
+ assert(captain.equipped_skills[0] == "" and captain.equipped_skills[4] == "reactor_overload")
+ assert(captain.skill_cooldowns["reactor_overload"] == 12.0)
+ assert(captain.equip_skill(4, ""))
+ captain.skill_cooldowns.clear()
+ assert(not captain.activate_skill("reactor_overload", ship))
+ assert(captain.equip_skill(4, "reactor_overload"))
+ captain.emit_changed()
+ await get_tree().process_frame
+ await get_tree().process_frame
+ var hotkey_ui := game.get_node("HUD/CaptainSkills")
+ var event := InputEventKey.new()
+ event.physical_keycode = KEY_F5
+ event.pressed = true
+ hotkey_ui._unhandled_key_input(event)
+ assert(captain.is_skill_active("reactor_overload"))
+ assert(captain.skill_cooldowns["reactor_overload"] == 30.0)
+ assert(hotkey_ui.get("_bar").get_child_count() == 5)
+
+ captain.credits = 1000
+ assert(inventory.buy_ammunition())
+ assert(captain.credits == 992)
+ _test_mining(game, ship)
+ inventory.ammunition = 0
+ inventory.irradiated_ammunition = 0
+ assert(not inventory.consume_ammunition() and not inventory.consume_irradiated_ammunition())
+
+ # Real level-up pauses, preserves the offers on reopen, and advances queued choices.
+ captain.skills.clear()
+ captain.normalize_equipped_skills()
+ captain.skill_cooldowns.clear()
+ captain.skill_durations.clear()
+ captain.add_rewards(250, 0)
+ await get_tree().process_frame
+ await get_tree().process_frame
+ var ui := game.get_node("HUD/CaptainSkills")
+ assert(get_tree().paused and captain.learning_points == 2)
+ assert(ui.get("_screen").visible)
+ var offers := captain.skill_offers.duplicate()
+ ui.open_management()
+ assert(offers == captain.skill_offers and offers.size() == 3)
+ ui._close()
+ assert(get_tree().paused)
+ var selected: String = offers[0]
+ ui._choose(selected)
+ assert(captain.get_skill_rank(selected) == 1 and get_tree().paused)
+ await get_tree().process_frame
+ ui._choose(captain.skill_offers[0])
+ assert(not get_tree().paused and captain.learning_points == 0)
+ await get_tree().process_frame
+ ui.open_management()
+ assert(get_tree().paused)
+ captain.skill_cooldowns["reactor_overload"] = 17.0
+ ship.set_physics_process(true)
+ await get_tree().create_timer(0.1, true).timeout
+ assert(captain.skill_cooldowns["reactor_overload"] == 17.0)
+ ship.set_physics_process(false)
+ ui._close()
+ var profile := GameState.captain
+ GameState.system_level = 2
+ game.queue_free()
+ await get_tree().process_frame
+ var next_system := preload("res://scenes/game/game.tscn").instantiate()
+ add_child(next_system)
+ next_system.get_node("PlayerShip").set_physics_process(false)
+ assert(GameState.captain == profile and profile.get_spent_learning_points() == 2)
+ assert(profile.skill_cooldowns["reactor_overload"] == 17.0)
+ GameState.start_new_run()
+ assert(GameState.captain.skills.is_empty() and GameState.captain.skill_cooldowns.is_empty())
+ print("CAPTAIN_SKILLS_TEST: PASS (six skills, three ranks, five equipped slots, F5 input, no cooldown reset, offers, pause, economy, ammo, mining, buffs, shield lock, new run)")
+ get_tree().quit()
+
+func _test_offers() -> void:
+ var profile := PROGRESS.new()
+ profile.add_rewards(250, 0)
+ assert(profile.level == 3 and profile.learning_points == 2)
+ assert(profile.skill_offers.size() == 3)
+ assert(not profile.train_skill("unknown"))
+ var choices := profile.skill_offers.duplicate()
+ profile.prepare_skill_offers()
+ assert(choices == profile.skill_offers)
+ assert(profile.train_skill(choices[0]))
+ assert(profile.get_skill_rank(choices[0]) == 1)
+ for skill in CATALOG.IDS:
+ profile.skills[skill] = 3
+ profile.skills["miner"] = 2
+ profile.skill_offers.clear()
+ profile.prepare_skill_offers()
+ assert(profile.skill_offers == ["miner"])
+ assert(profile.train_skill("miner"))
+ profile.add_rewards(1000, 0)
+ assert(profile.skill_offers.is_empty() and profile.learning_points == 0)
+ assert(not profile.can_reset_skills())
+ profile.normalize_skill_ranks()
+ assert(profile.get_skill_rank("miner") == 3)
+
+func _test_mining(game: Node, ship: CharacterBody2D) -> void:
+ var results: Array[int] = []
+ for rank in range(4):
+ GameState.captain.skills["miner"] = rank
+ GameState.captain.minerals = {"iron": 0, "aluminium": 0, "titanium": 0, "uranium": 0}
+ var asteroid := StaticBody2D.new()
+ asteroid.set_script(POI)
+ asteroid.set("kind", "asteroid")
+ asteroid.set("player", ship)
+ asteroid.set("reward_seed", 123)
+ asteroid.set("poi_id", "captain_skill_test_%d" % rank)
+ game.add_child(asteroid)
+ assert(asteroid.claim())
+ var total := 0
+ for amount in GameState.captain.minerals.values():
+ total += int(amount)
+ results.append(total)
+ assert(results[1] == results[0] + 3 and results[2] == results[0] + 6 and results[3] == results[0] + 10)
diff --git a/tests/captain/captain_skills_test.gd.uid b/tests/captain/captain_skills_test.gd.uid
new file mode 100644
index 0000000..e31464e
--- /dev/null
+++ b/tests/captain/captain_skills_test.gd.uid
@@ -0,0 +1 @@
+uid://bnasxkcqdek5p
diff --git a/tests/captain/captain_skills_test.tscn b/tests/captain/captain_skills_test.tscn
new file mode 100644
index 0000000..5e8fb9f
--- /dev/null
+++ b/tests/captain/captain_skills_test.tscn
@@ -0,0 +1,6 @@
+[gd_scene load_steps=2 format=3]
+
+[ext_resource type="Script" path="res://tests/captain/captain_skills_test.gd" id="1"]
+
+[node name="CaptainSkillsTest" type="Node"]
+script = ExtResource("1")
diff --git a/tests/levels/boss_spawn_test.gd b/tests/levels/boss_spawn_test.gd
index ac78740..75ebb3d 100644
--- a/tests/levels/boss_spawn_test.gd
+++ b/tests/levels/boss_spawn_test.gd
@@ -18,6 +18,7 @@ func _ready() -> void:
enemy.set_physics_process(false)
enemies.append(enemy)
assert(enemies.size() == 4)
+ assert(not game.is_boss_alive())
for index in enemies.size() - 1:
enemies[index].take_damage(100000.0)
game._physics_process(0.0)
@@ -40,6 +41,7 @@ func _ready() -> void:
player.set("_is_destroyed", false)
game._physics_process(0.0)
assert(game._boss_spawned and game.get_remaining_enemies() == 1)
+ assert(game.is_boss_alive())
assert(not game.system_cleared and not game.services_menu.visible)
var boss: CharacterBody2D
for enemy in get_tree().get_nodes_in_group("enemies"):
@@ -55,6 +57,7 @@ func _ready() -> void:
boss.take_damage(100000.0)
game._physics_process(0.0)
assert(game.system_cleared and game.get_remaining_enemies() == 0)
+ assert(not game.is_boss_alive())
game._physics_process(0.0)
assert(game.get_remaining_enemies() == 0)
for definition in preload("res://scripts/items/item_generator.gd").DEFINITIONS: