diff --git a/assets/icons/captain/explorer.svg b/assets/icons/captain/explorer.svg
new file mode 100644
index 0000000..26c4bfb
--- /dev/null
+++ b/assets/icons/captain/explorer.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/captain/explorer.svg.import b/assets/icons/captain/explorer.svg.import
new file mode 100644
index 0000000..d931d86
--- /dev/null
+++ b/assets/icons/captain/explorer.svg.import
@@ -0,0 +1,43 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://lqmq10ghoawn"
+path="res://.godot/imported/explorer.svg-c2c25b150817ef3cb23b5aec41dcac40.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/icons/captain/explorer.svg"
+dest_files=["res://.godot/imported/explorer.svg-c2c25b150817ef3cb23b5aec41dcac40.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/lucky.svg b/assets/icons/captain/lucky.svg
new file mode 100644
index 0000000..84bde64
--- /dev/null
+++ b/assets/icons/captain/lucky.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/captain/lucky.svg.import b/assets/icons/captain/lucky.svg.import
new file mode 100644
index 0000000..e02f790
--- /dev/null
+++ b/assets/icons/captain/lucky.svg.import
@@ -0,0 +1,43 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cmog75c28nh0v"
+path="res://.godot/imported/lucky.svg-72d8243f6a27dec9318a407bb2dfb6e5.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/icons/captain/lucky.svg"
+dest_files=["res://.godot/imported/lucky.svg-72d8243f6a27dec9318a407bb2dfb6e5.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/extensions/artwork_prompts.json b/assets/icons/extensions/artwork_prompts.json
new file mode 100644
index 0000000..d922061
--- /dev/null
+++ b/assets/icons/extensions/artwork_prompts.json
@@ -0,0 +1,20 @@
+{
+ "tool": "built-in image_gen",
+ "assets": [
+ {
+ "key": "teleport_art",
+ "transparent": false,
+ "prompt": "Use case: stylized-concept. One finished premium square ability icon for a dark realistic space action RPG, no text or frame. A sleek small titanium spacecraft phasing through a circular mechanical hyperspace gate, brilliant violet cyan portal rim, fragmented translucent afterimages and luminous particles suggesting teleportation, a clearly legible ship centered inside the gate. Realistic painted 3D sci-fi inventory art, polished metal, rich deep navy background, restrained cinematic light, strong simple silhouette readable at 36 pixels. 12% safe margins on all sides, dark corners. No words, lettering, numbers, UI, logos, or multiple icons."
+ },
+ {
+ "key": "flares_art",
+ "transparent": false,
+ "prompt": "Use case: stylized-concept. One finished premium square ability icon for a dark realistic space action RPG, no text or frame. Three compact gunmetal countermeasure flare cartridges fanning outward from one small launcher, each ejecting a brilliant orange gold white burning flare, elegant arcing orange spark trails, warm reflected highlights on detailed metal. Dramatic but clean and highly readable at 36 pixels, strong simple triangular silhouette. Realistic painted 3D sci-fi inventory art, deep navy backdrop, 12% safe margins, dark corners, no words, lettering, numbers, UI, logos, or multiple icons."
+ },
+ {
+ "key": "mine_art",
+ "transparent": true,
+ "prompt": "Use case: game sprite. Create a single premium realistic sci-fi proximity mine seen strictly from directly above, orthographic top-down, suitable for a 2D space combat game AND an inventory icon. Compact circular dark gunmetal mechanical body with six short robust radial trigger arms arranged symmetrically, beveled titanium armor panels, copper accents, one glowing orange-red central sensor lens and several tiny amber indicator LEDs. Highly polished realistic painted 3D game asset with crisp readable silhouette at 32 pixels, restrained lighting from upper left, no perspective tilt. Mine fills 80% of square with safe transparent padding. Genuinely transparent background, no ground or cast shadow, no smoke, no explosion, no halo baked outside the object. No text, lettering, symbols, digits, UI, frame, or multiple objects."
+ }
+ ]
+}
diff --git a/assets/icons/extensions/cloak.svg b/assets/icons/extensions/cloak.svg
new file mode 100644
index 0000000..40fb738
--- /dev/null
+++ b/assets/icons/extensions/cloak.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/extensions/cloak.svg.import b/assets/icons/extensions/cloak.svg.import
new file mode 100644
index 0000000..fc04f90
--- /dev/null
+++ b/assets/icons/extensions/cloak.svg.import
@@ -0,0 +1,43 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://c2y3r2bcd76j3"
+path="res://.godot/imported/cloak.svg-1b5aa796b5986a3b2eadf42c7bc6c8f5.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/icons/extensions/cloak.svg"
+dest_files=["res://.godot/imported/cloak.svg-1b5aa796b5986a3b2eadf42c7bc6c8f5.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/extensions/cloak_art.png b/assets/icons/extensions/cloak_art.png
new file mode 100644
index 0000000..76d7fa7
Binary files /dev/null and b/assets/icons/extensions/cloak_art.png differ
diff --git a/assets/icons/extensions/cloak_art.png.import b/assets/icons/extensions/cloak_art.png.import
new file mode 100644
index 0000000..c87945a
--- /dev/null
+++ b/assets/icons/extensions/cloak_art.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://ddqhcasuuecgc"
+path="res://.godot/imported/cloak_art.png-86e932fd023e9e2d1cf22befbd492f1b.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/icons/extensions/cloak_art.png"
+dest_files=["res://.godot/imported/cloak_art.png-86e932fd023e9e2d1cf22befbd492f1b.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
diff --git a/assets/icons/extensions/cloak_shields_prompts.json b/assets/icons/extensions/cloak_shields_prompts.json
new file mode 100644
index 0000000..d380d98
--- /dev/null
+++ b/assets/icons/extensions/cloak_shields_prompts.json
@@ -0,0 +1,3 @@
+{
+ "tool": "built-in image_gen"
+}
diff --git a/assets/icons/extensions/flares.svg b/assets/icons/extensions/flares.svg
new file mode 100644
index 0000000..4265099
--- /dev/null
+++ b/assets/icons/extensions/flares.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/extensions/flares.svg.import b/assets/icons/extensions/flares.svg.import
new file mode 100644
index 0000000..d0cf1c9
--- /dev/null
+++ b/assets/icons/extensions/flares.svg.import
@@ -0,0 +1,43 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cujt4m4cehoqv"
+path="res://.godot/imported/flares.svg-837a92111af09adbbac44fda02b8653b.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/icons/extensions/flares.svg"
+dest_files=["res://.godot/imported/flares.svg-837a92111af09adbbac44fda02b8653b.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/extensions/flares_art.png b/assets/icons/extensions/flares_art.png
new file mode 100644
index 0000000..d70b962
Binary files /dev/null and b/assets/icons/extensions/flares_art.png differ
diff --git a/assets/icons/extensions/flares_art.png.import b/assets/icons/extensions/flares_art.png.import
new file mode 100644
index 0000000..cf4b8b5
--- /dev/null
+++ b/assets/icons/extensions/flares_art.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://ccdxl4xyjwyg3"
+path="res://.godot/imported/flares_art.png-8257018c1bf2262aa2bd54553a07d479.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/icons/extensions/flares_art.png"
+dest_files=["res://.godot/imported/flares_art.png-8257018c1bf2262aa2bd54553a07d479.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
diff --git a/assets/icons/extensions/mine_art.png b/assets/icons/extensions/mine_art.png
new file mode 100644
index 0000000..d7f6bdb
Binary files /dev/null and b/assets/icons/extensions/mine_art.png differ
diff --git a/assets/icons/extensions/mine_art.png.import b/assets/icons/extensions/mine_art.png.import
new file mode 100644
index 0000000..3643e15
--- /dev/null
+++ b/assets/icons/extensions/mine_art.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dwv0fw3dyupj3"
+path="res://.godot/imported/mine_art.png-d9855ade10eaece77e81644f714c4fca.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/icons/extensions/mine_art.png"
+dest_files=["res://.godot/imported/mine_art.png-d9855ade10eaece77e81644f714c4fca.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
diff --git a/assets/icons/extensions/mine_layer.svg b/assets/icons/extensions/mine_layer.svg
new file mode 100644
index 0000000..d1c94c1
--- /dev/null
+++ b/assets/icons/extensions/mine_layer.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/extensions/mine_layer.svg.import b/assets/icons/extensions/mine_layer.svg.import
new file mode 100644
index 0000000..9c48c15
--- /dev/null
+++ b/assets/icons/extensions/mine_layer.svg.import
@@ -0,0 +1,43 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://beubewxxfj0nk"
+path="res://.godot/imported/mine_layer.svg-01b12ba6f174bb2a7cfd0b9cfeb7da19.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/icons/extensions/mine_layer.svg"
+dest_files=["res://.godot/imported/mine_layer.svg-01b12ba6f174bb2a7cfd0b9cfeb7da19.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/extensions/solar_shield_charge.svg b/assets/icons/extensions/solar_shield_charge.svg
new file mode 100644
index 0000000..d2bb25f
--- /dev/null
+++ b/assets/icons/extensions/solar_shield_charge.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/extensions/solar_shield_charge.svg.import b/assets/icons/extensions/solar_shield_charge.svg.import
new file mode 100644
index 0000000..ee72f3c
--- /dev/null
+++ b/assets/icons/extensions/solar_shield_charge.svg.import
@@ -0,0 +1,43 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://y31gpw240xo0"
+path="res://.godot/imported/solar_shield_charge.svg-b342289bf0e77d2c650ef1077b9b60d0.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/icons/extensions/solar_shield_charge.svg"
+dest_files=["res://.godot/imported/solar_shield_charge.svg-b342289bf0e77d2c650ef1077b9b60d0.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/extensions/solar_shield_charge_art.png b/assets/icons/extensions/solar_shield_charge_art.png
new file mode 100644
index 0000000..3f34d4d
Binary files /dev/null and b/assets/icons/extensions/solar_shield_charge_art.png differ
diff --git a/assets/icons/extensions/solar_shield_charge_art.png.import b/assets/icons/extensions/solar_shield_charge_art.png.import
new file mode 100644
index 0000000..3ddc1e2
--- /dev/null
+++ b/assets/icons/extensions/solar_shield_charge_art.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://jemfbex10v14"
+path="res://.godot/imported/solar_shield_charge_art.png-4a285d20cee948e9860ce0789735a22e.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/icons/extensions/solar_shield_charge_art.png"
+dest_files=["res://.godot/imported/solar_shield_charge_art.png-4a285d20cee948e9860ce0789735a22e.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
diff --git a/assets/icons/extensions/teleport.svg b/assets/icons/extensions/teleport.svg
new file mode 100644
index 0000000..30ca9e8
--- /dev/null
+++ b/assets/icons/extensions/teleport.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/extensions/teleport.svg.import b/assets/icons/extensions/teleport.svg.import
new file mode 100644
index 0000000..7b6bd3b
--- /dev/null
+++ b/assets/icons/extensions/teleport.svg.import
@@ -0,0 +1,43 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bdgnd1s2jd1l6"
+path="res://.godot/imported/teleport.svg-42397f6d0e76594345e1c58a2997a16e.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/icons/extensions/teleport.svg"
+dest_files=["res://.godot/imported/teleport.svg-42397f6d0e76594345e1c58a2997a16e.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/extensions/teleport_art.png b/assets/icons/extensions/teleport_art.png
new file mode 100644
index 0000000..5a1309b
Binary files /dev/null and b/assets/icons/extensions/teleport_art.png differ
diff --git a/assets/icons/extensions/teleport_art.png.import b/assets/icons/extensions/teleport_art.png.import
new file mode 100644
index 0000000..87b3f74
--- /dev/null
+++ b/assets/icons/extensions/teleport_art.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://c8emovr704ky4"
+path="res://.godot/imported/teleport_art.png-96d2eb9efda7c053a9325458f6b883ae.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/icons/extensions/teleport_art.png"
+dest_files=["res://.godot/imported/teleport_art.png-96d2eb9efda7c053a9325458f6b883ae.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
diff --git a/assets/icons/resources/aluminium.png b/assets/icons/resources/aluminium.png
index 351634d..5f94e25 100644
Binary files a/assets/icons/resources/aluminium.png and b/assets/icons/resources/aluminium.png differ
diff --git a/assets/icons/resources/aluminium_ore_prompt.json b/assets/icons/resources/aluminium_ore_prompt.json
new file mode 100644
index 0000000..e42dbe1
--- /dev/null
+++ b/assets/icons/resources/aluminium_ore_prompt.json
@@ -0,0 +1,6 @@
+{
+ "tool": "built-in image_gen",
+ "asset": "assets/icons/resources/aluminium.png",
+ "transparent_background": true,
+ "prompt": "Use case: game asset. Generate one premium realistic painted 3D game sprite of raw ALUMINIUM ORE for a sci-fi space mining game, usable both as an inventory resource icon and as an asteroid in top-down 2D gameplay. One rugged irregular charcoal-gray rock with abundant pale SILVERY WHITE mineral seams and light pearl-gray crystalline facets embedded naturally in dark stone. Clearly a raw mined ore nugget, NOT smelted bars, NOT ingots, NOT a manufactured object. Distinct from rusty orange iron, blue titanium, green uranium and yellow sulfur. Match a high-quality resource icon set of chunky dark rocks with colored mineral veins. Compact approximately rounded asymmetric silhouette, rich rock texture, controlled highlights from upper left, crisp and legible at 48 pixels. Object fills about 80 percent of square with safe padding, entirely inside the frame. Genuine transparent background; no scene, no floor, no drop shadow, no frame, no text, letters, numbers or other objects."
+}
diff --git a/assets/icons/ui/captain_skills_art.png b/assets/icons/ui/captain_skills_art.png
new file mode 100644
index 0000000..bd30cfc
Binary files /dev/null and b/assets/icons/ui/captain_skills_art.png differ
diff --git a/assets/icons/ui/captain_skills_art.png.import b/assets/icons/ui/captain_skills_art.png.import
new file mode 100644
index 0000000..a46b488
--- /dev/null
+++ b/assets/icons/ui/captain_skills_art.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cmtfsckdh2f35"
+path="res://.godot/imported/captain_skills_art.png-b442619ccaaf8a841989cf1910d4fb60.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/icons/ui/captain_skills_art.png"
+dest_files=["res://.godot/imported/captain_skills_art.png-b442619ccaaf8a841989cf1910d4fb60.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
diff --git a/assets/icons/ui/crafting_art.png b/assets/icons/ui/crafting_art.png
new file mode 100644
index 0000000..8d8bf70
Binary files /dev/null and b/assets/icons/ui/crafting_art.png differ
diff --git a/assets/icons/ui/crafting_art.png.import b/assets/icons/ui/crafting_art.png.import
new file mode 100644
index 0000000..b6f6c31
--- /dev/null
+++ b/assets/icons/ui/crafting_art.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bfpmuonivd2r4"
+path="res://.godot/imported/crafting_art.png-30b85c072a19ade651aee4403a07600c.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/icons/ui/crafting_art.png"
+dest_files=["res://.godot/imported/crafting_art.png-30b85c072a19ade651aee4403a07600c.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
diff --git a/assets/icons/ui/navigation_art_prompts.json b/assets/icons/ui/navigation_art_prompts.json
new file mode 100644
index 0000000..786c630
--- /dev/null
+++ b/assets/icons/ui/navigation_art_prompts.json
@@ -0,0 +1,13 @@
+{
+ "tool": "built-in image_gen",
+ "prompts": [
+ {
+ "asset": "assets/icons/ui/crafting_art.png",
+ "prompt": "Use case: stylized-concept. Create ONE finished premium square UI button artwork for a dark realistic sci-fi space RPG. Subject: a compact futuristic engineering workbench emblem, a heavy brushed titanium wrench crossing a precise copper mechanical gear, a tiny luminous amber fusion core at the junction. Bold single iconic silhouette, tasteful warm amber light and metallic highlights on dark charcoal navy, realistic painted 3D game icon matching high quality RPG ability icons. Centered object, 12% safe padding, dark navy corners, no border or UI frame. Must be unmistakable and legible when displayed at 54px. No text, letters, digits, labels or multiple icons. Opaque square background."
+ },
+ {
+ "asset": "assets/icons/ui/captain_skills_art.png",
+ "prompt": "Use case: stylized-concept. Create ONE finished premium square UI button artwork for a dark realistic sci-fi space RPG. Subject: a commanding futuristic captain's insignia, a sculpted brushed titanium five-point star with a small violet crystalline core, flanked by two elegant metallic wing/laurel shapes, subtle radiant violet and cyan light. Bold single iconic emblem, realistic painted 3D game icon matching high quality RPG ability icons, deep charcoal navy backdrop, centered composition with 12% safe padding, dark navy corners, no border or UI frame. Must remain unmistakable and legible at 54px. No text, letters, digits, labels or multiple icons. Opaque square background."
+ }
+ ]
+}
diff --git a/assets/sounds/music/music2.mp3.import b/assets/sounds/music/music2.mp3.import
new file mode 100644
index 0000000..c0dd928
--- /dev/null
+++ b/assets/sounds/music/music2.mp3.import
@@ -0,0 +1,19 @@
+[remap]
+
+importer="mp3"
+type="AudioStreamMP3"
+uid="uid://j2yreql05unw"
+path="res://.godot/imported/music2.mp3-01cfceab2822ae9919d6349789949988.mp3str"
+
+[deps]
+
+source_file="res://assets/sounds/music/music2.mp3"
+dest_files=["res://.godot/imported/music2.mp3-01cfceab2822ae9919d6349789949988.mp3str"]
+
+[params]
+
+loop=false
+loop_offset=0
+bpm=0
+beat_count=0
+bar_beats=4
diff --git a/resources/items/affix_database.tres b/resources/items/affix_database.tres
index 64120b0..8fcadd9 100644
--- a/resources/items/affix_database.tres
+++ b/resources/items/affix_database.tres
@@ -1,4 +1,4 @@
-[gd_resource type="Resource" script_class="ItemAffixDatabase" load_steps=18 format=3]
+[gd_resource type="Resource" script_class="ItemAffixDatabase" load_steps=19 format=3]
[ext_resource type="Script" path="res://scripts/items/item_affix_database.gd" id="1_database"]
[ext_resource type="Script" path="res://scripts/items/item_affix_definition.gd" id="2_type"]
@@ -21,6 +21,8 @@
[ext_resource type="Resource" path="res://resources/items/affixes/energy_absorption.tres" id="energy"]
[ext_resource type="Resource" path="res://resources/items/affixes/radiation_absorption.tres" id="radiation"]
+[ext_resource type="Resource" path="res://resources/items/affixes/luck_percent.tres" id="luck"]
+
[resource]
script = ExtResource("1_database")
-affixes = Array[ExtResource("2_type")]([ExtResource("3_affix"), ExtResource("4_affix"), ExtResource("5_affix"), ExtResource("6_affix"), ExtResource("7_affix"), ExtResource("8_affix"), ExtResource("9_affix"), ExtResource("10_affix"), ExtResource("11_affix"), ExtResource("12_affix"), ExtResource("13_affix"), ExtResource("14_affix"), ExtResource("kinetic"), ExtResource("energy"), ExtResource("radiation")])
+affixes = Array[ExtResource("2_type")]([ExtResource("3_affix"), ExtResource("4_affix"), ExtResource("5_affix"), ExtResource("6_affix"), ExtResource("7_affix"), ExtResource("8_affix"), ExtResource("9_affix"), ExtResource("10_affix"), ExtResource("11_affix"), ExtResource("12_affix"), ExtResource("13_affix"), ExtResource("14_affix"), ExtResource("kinetic"), ExtResource("energy"), ExtResource("radiation"), ExtResource("luck")])
diff --git a/resources/items/affixes/luck_percent.tres b/resources/items/affixes/luck_percent.tres
new file mode 100644
index 0000000..8c4f8c1
--- /dev/null
+++ b/resources/items/affixes/luck_percent.tres
@@ -0,0 +1,15 @@
+[gd_resource type="Resource" script_class="ItemAffixDefinition" load_steps=2 format=3]
+[ext_resource type="Script" path="res://scripts/items/item_affix_definition.gd" id="1"]
+[resource]
+script = ExtResource("1")
+affix_id = "luck_percent"
+display_name = "Szczęście"
+description = "Zwiększa szansę na przedmiot o 1 poziom wyższy. Sumuje się z Farciarzem, do wspólnego limitu 60%."
+stat_key = "luck_percent"
+unit = "%"
+selection_weight = 0.7
+category_mask = 95
+minimum_value_level_1 = 0.5
+maximum_value_level_1 = 1.0
+minimum_value_level_1000 = 3.0
+maximum_value_level_1000 = 6.0
diff --git a/resources/items/cloak.tres b/resources/items/cloak.tres
new file mode 100644
index 0000000..08efae2
--- /dev/null
+++ b/resources/items/cloak.tres
@@ -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/cloak_art.png" id="icon"]
+[sub_resource type="Resource" id="Ability"]
+script = ExtResource("2")
+ability_id = "cloak"
+cooldown = 60.0
+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."
+kind = 1
+[resource]
+script = ExtResource("1")
+icon = ExtResource("icon")
+item_id = "cloak"
+display_name = "Kamuflaż"
+category = 5
+description = "Aktywne rozszerzenie statku. Zamontuj w slocie rozszerzeń i użyj przypisanego klawisza 1–5."
+active_ability = SubResource("Ability")
diff --git a/resources/items/flares.tres b/resources/items/flares.tres
new file mode 100644
index 0000000..8efb3df
--- /dev/null
+++ b/resources/items/flares.tres
@@ -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/flares_art.png" id="icon"]
+[sub_resource type="Resource" id="Ability"]
+script = ExtResource("2")
+ability_id = "flares"
+cooldown = 30.0
+display_name = "Flary"
+description = "Wypuszcza trzy flary na 10 s. Wrogowie w zasięgu obierają je za cel zamiast gracza. Odnowienie: 30 s."
+kind = 3
+[resource]
+script = ExtResource("1")
+icon = ExtResource("icon")
+item_id = "flares"
+display_name = "Flary"
+category = 5
+description = "Aktywne rozszerzenie statku. Zamontuj w slocie rozszerzeń i użyj przypisanego klawisza 1–5."
+active_ability = SubResource("Ability")
diff --git a/resources/items/mine_layer.tres b/resources/items/mine_layer.tres
new file mode 100644
index 0000000..982ce9d
--- /dev/null
+++ b/resources/items/mine_layer.tres
@@ -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/mine_art.png" id="icon"]
+[sub_resource type="Resource" id="Ability"]
+script = ExtResource("2")
+ability_id = "mine_layer"
+cooldown = 5.0
+display_name = "Miny"
+description = "Zostawia minę na 60 s. Uzbraja się po 0,7 s. Trafiony wróg otrzymuje obrażenia równe 5% maksymalnego kadłuba (bez redukcji absorpcji) i zwalnia o 50% na 5 s. Zrzut co 5 s."
+kind = 2
+[resource]
+script = ExtResource("1")
+icon = ExtResource("icon")
+item_id = "mine_layer"
+display_name = "Miny"
+category = 5
+description = "Aktywne rozszerzenie statku. Zamontuj w slocie rozszerzeń i użyj przypisanego klawisza 1–5."
+active_ability = SubResource("Ability")
diff --git a/resources/items/solar_shield_charge.tres b/resources/items/solar_shield_charge.tres
new file mode 100644
index 0000000..dad059a
--- /dev/null
+++ b/resources/items/solar_shield_charge.tres
@@ -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/solar_shield_charge_art.png" id="icon"]
+[sub_resource type="Resource" id="Ability"]
+script = ExtResource("2")
+ability_id = "solar_shield_charge"
+cooldown = 30.0
+display_name = "Doładowanie tarcz"
+description = "W zasięgu gwiazdy przywraca 20% maksymalnych tarcz na sekundę przez maksymalnie 5 s. Opuszczenie zasięgu kończy efekt. Odnowienie: 30 s."
+kind = 4
+[resource]
+script = ExtResource("1")
+icon = ExtResource("icon")
+item_id = "solar_shield_charge"
+display_name = "Doładowanie tarcz"
+category = 5
+description = "Aktywne rozszerzenie statku. Zamontuj w slocie rozszerzeń i użyj przypisanego klawisza 1–5."
+active_ability = SubResource("Ability")
diff --git a/resources/items/teleport.tres b/resources/items/teleport.tres
new file mode 100644
index 0000000..0404782
--- /dev/null
+++ b/resources/items/teleport.tres
@@ -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/teleport_art.png" id="icon"]
+[sub_resource type="Resource" id="Ability"]
+script = ExtResource("2")
+ability_id = "teleport"
+cooldown = 60.0
+display_name = "Teleport"
+description = "Teleportuje w losowe, bezpieczne miejsce systemu. Zużywa całą aktualną energię (wymaga dodatniej energii). Odnowienie: 60 s."
+kind = 0
+[resource]
+script = ExtResource("1")
+icon = ExtResource("icon")
+item_id = "teleport"
+display_name = "Teleport"
+category = 5
+description = "Aktywne rozszerzenie statku. Zamontuj w slocie rozszerzeń i użyj przypisanego klawisza 1–5."
+active_ability = SubResource("Ability")
diff --git a/resources/ui/cloak.gdshader b/resources/ui/cloak.gdshader
new file mode 100644
index 0000000..69ccee2
--- /dev/null
+++ b/resources/ui/cloak.gdshader
@@ -0,0 +1,9 @@
+shader_type canvas_item;
+uniform float elapsed = 0.0;
+void fragment() {
+ vec4 tex = texture(TEXTURE, UV);
+ float edge = abs(tex.a - texture(TEXTURE, UV + vec2(TEXTURE_PIXEL_SIZE.x * 2.0, 0.0)).a);
+ edge += abs(tex.a - texture(TEXTURE, UV + vec2(0.0, TEXTURE_PIXEL_SIZE.y * 2.0)).a);
+ float scan = pow(0.5 + 0.5 * sin(UV.y * 140.0 - elapsed * 4.0), 14.0);
+ COLOR = vec4(mix(tex.rgb * vec3(0.3, 0.65, 0.9), vec3(0.4, 0.95, 1.0), clamp(edge + scan * 0.5, 0.0, 1.0)), tex.a * (0.10 + edge * 0.25 + scan * 0.1));
+}
diff --git a/resources/ui/cloak.gdshader.uid b/resources/ui/cloak.gdshader.uid
new file mode 100644
index 0000000..cacc7ce
--- /dev/null
+++ b/resources/ui/cloak.gdshader.uid
@@ -0,0 +1 @@
+uid://bvir23us3og83
diff --git a/scenes/game/enemy_plasma_projectile.tscn b/scenes/game/enemy_plasma_projectile.tscn
index dcecae7..7aa0268 100644
--- a/scenes/game/enemy_plasma_projectile.tscn
+++ b/scenes/game/enemy_plasma_projectile.tscn
@@ -7,7 +7,7 @@ radius = 7.0
[node name="EnemyPlasmaProjectile" type="Area2D"]
collision_layer = 0
-collision_mask = 3
+collision_mask = 51
monitoring = true
monitorable = false
script = ExtResource("1_script")
diff --git a/scenes/game/game.tscn b/scenes/game/game.tscn
index aa2b0e1..0c9cb37 100644
--- a/scenes/game/game.tscn
+++ b/scenes/game/game.tscn
@@ -26,7 +26,7 @@ script = ExtResource("1_game")
[node name="GameMusic" type="AudioStreamPlayer" parent="." unique_id=784369202]
script = ExtResource("8_scene_music")
-track_path = "res://assets/sounds/music/game_music.mp3"
+track_path = "res://assets/sounds/music/music2.mp3"
playback_volume_db = -17.0
[node name="SpaceBackground" type="Node2D" parent="." unique_id=300719476]
@@ -55,6 +55,7 @@ shape = SubResource("RightWall")
[node name="PlayerShip" parent="." unique_id=1837545573 instance=ExtResource("3_player")]
[node name="HUD" type="CanvasLayer" parent="." unique_id=733012261]
+layer = 20
script = ExtResource("4_hud")
[node name="HUDRoot" type="Control" parent="HUD" unique_id=539413585]
diff --git a/scripts/abilities/active_ability.gd b/scripts/abilities/active_ability.gd
index d900b94..7e2bf40 100644
--- a/scripts/abilities/active_ability.gd
+++ b/scripts/abilities/active_ability.gd
@@ -1,6 +1,8 @@
extends Resource
class_name ActiveAbility
+@export var ability_id: String = ""
+@export var cooldown: float = 0.0
@export var display_name: String = ""
@export_multiline var description: String = ""
diff --git a/scripts/abilities/tactical_extension_ability.gd b/scripts/abilities/tactical_extension_ability.gd
new file mode 100644
index 0000000..e1b85fb
--- /dev/null
+++ b/scripts/abilities/tactical_extension_ability.gd
@@ -0,0 +1,25 @@
+extends ActiveAbility
+
+@export_enum("Teleport", "Cloak", "Mine", "Flares", "Solar shields") var kind := 0
+
+func unavailable_reason(ship: CharacterBody2D) -> String:
+ match kind:
+ 0:
+ if float(ship.get("energy")) <= 0.0:
+ return "Brak energii"
+ if not ship.get_node("ExtensionSystems").call("has_teleport_destination"):
+ return "Brak bezpiecznego miejsca na teleport"
+ 1:
+ if ship.call("is_cloaked"):
+ return "Kamuflaż już działa"
+ 4:
+ if GameState.captain.is_skill_active("power_redirect"):
+ return "Tarcze wyłączone przez Przekierowanie zasilania"
+ if float(ship.call("get_solar_regeneration")) <= 0.0:
+ return "Wymagany zasięg gwiazdy"
+ if float(ship.get("shields")) >= float(ship.get("max_shields")):
+ return "Tarcze są pełne"
+ return ""
+
+func activate(ship: CharacterBody2D) -> void:
+ ship.get_node("ExtensionSystems").call("activate", kind)
diff --git a/scripts/abilities/tactical_extension_ability.gd.uid b/scripts/abilities/tactical_extension_ability.gd.uid
new file mode 100644
index 0000000..4b8cd77
--- /dev/null
+++ b/scripts/abilities/tactical_extension_ability.gd.uid
@@ -0,0 +1 @@
+uid://mjofjrs3v7by
diff --git a/scripts/captain/captain_progress.gd b/scripts/captain/captain_progress.gd
index b9ccd6b..6569529 100644
--- a/scripts/captain/captain_progress.gd
+++ b/scripts/captain/captain_progress.gd
@@ -4,6 +4,7 @@ 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
+const MAX_LUCK_CHANCE := 0.60
@export var level: int = 1
@export var experience: int = 0
@@ -111,6 +112,17 @@ func prepare_skill_offers() -> void:
skill_offers.append(pool[index])
pool.remove_at(index)
+func get_luck_chance(equipment_bonus_percent: float = 0.0) -> float:
+ return clampf(get_skill_bonus("lucky") + equipment_bonus_percent / 100.0, 0.0, MAX_LUCK_CHANCE)
+
+func roll_loot_level(base_level: int, roll: float = -1.0, equipment_bonus_percent: float = 0.0) -> int:
+ var level := clampi(base_level, 1, 1000)
+ var chance := get_luck_chance(equipment_bonus_percent)
+ if chance <= 0.0 or level >= 1000:
+ return level
+ var luck_roll := randf() if roll < 0.0 else roll
+ return mini(1000, level + 1) if luck_roll < chance else level
+
func discounted_price(base_price: int) -> int:
return maxi(1, roundi(base_price * (1.0 - get_skill_bonus("business_shark"))))
@@ -121,6 +133,11 @@ func should_consume_ammunition(ammo_kind: String) -> bool:
_ammo_credit[ammo_kind] = maxf(0.0, credit - 1.0) if saved else credit
return not saved
+func reset_combat_state() -> void:
+ skill_cooldowns.clear()
+ skill_durations.clear()
+ emit_changed()
+
func tick_skills(delta: float) -> void:
for skill in skill_cooldowns:
skill_cooldowns[skill] = maxf(0.0, float(skill_cooldowns[skill]) - delta)
diff --git a/scripts/captain/captain_skills.gd b/scripts/captain/captain_skills.gd
index 2f8a45b..cc3654f 100644
--- a/scripts/captain/captain_skills.gd
+++ b/scripts/captain/captain_skills.gd
@@ -3,8 +3,10 @@ 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 IDS := ["business_shark", "reactor_overload", "tactical_maneuver", "efficient_magazine", "miner", "power_redirect", "lucky", "explorer"]
const DATA := {
+ "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"},
"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"},
@@ -19,11 +21,16 @@ static func value(skill: String, rank: int) -> float:
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
+ var extension := "svg" if skill in ["lucky", "explorer"] else "png"
+ return load("res://assets/icons/captain/%s.%s" % [skill, extension]) as Texture2D
static func description(skill: String, rank: int) -> String:
var amount := roundi(value(skill, rank) * 100.0)
match skill:
+ "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
+ "explorer":
+ return "+%d wraków na nowej planszy. Premia ustalana przy rozpoczęciu systemu; nie zmienia bieżącej planszy. Premie rang nie sumują się." % roundi(value(skill, rank))
"business_shark":
return "Zakupy za kredyty tańsze o %d%%." % amount
"reactor_overload":
diff --git a/scripts/game/asteroid_destruction_effect.gd b/scripts/game/asteroid_destruction_effect.gd
index 00a4c2a..ba74a48 100644
--- a/scripts/game/asteroid_destruction_effect.gd
+++ b/scripts/game/asteroid_destruction_effect.gd
@@ -40,6 +40,14 @@ func configure(sprite: Sprite2D) -> void:
outline.append_array(_edge_vertices(vertical[y * 5 + x + 1], false))
outline.append_array(_edge_vertices(horizontal[(y + 1) * 4 + x], true))
outline.append_array(_edge_vertices(vertical[y * 5 + x], true))
+ # Rare jagged cracks can cross at a corner. Repair the silhouette once,
+ # rather than submitting an invalid polygon every rendered frame.
+ if Geometry2D.triangulate_polygon(outline).is_empty():
+ outline = Geometry2D.convex_hull(outline)
+ if outline.size() > 1 and outline[0].is_equal_approx(outline[outline.size() - 1]):
+ outline.remove_at(outline.size() - 1)
+ if outline.size() < 3 or Geometry2D.triangulate_polygon(outline).is_empty():
+ continue
var center := Vector2.ZERO
for vertex in outline:
center += vertex
@@ -54,7 +62,8 @@ func configure(sprite: Sprite2D) -> void:
_fragments.append({"points": points, "uv": uv, "position": origin,
"velocity": direction.rotated(_rng.randf_range(-0.35, 0.35)) * _rng.randf_range(40.0, 95.0),
"angle": 0.0, "spin": _rng.randf_range(-4.0, 4.0)})
- _dust_color = Color("a1c7df") if "ice" in _texture.resource_path else Color("b4a99a")
+ var mineral := _texture.resource_path.get_file().get_basename()
+ _dust_color = {"iron": Color("be9b75"), "titanium": Color("9ecbe9"), "uranium": Color("a9db71"), "aluminium": Color("d3dce0"), "sulfur": Color("e8cd65")}.get(mineral, Color("b4a99a"))
for index in 28:
_dust.append({"direction": Vector2.RIGHT.rotated(_rng.randf_range(0.0, TAU)),
"speed": _rng.randf_range(45.0, 210.0), "size": _rng.randf_range(1.0, 3.0)})
diff --git a/scripts/game/deployed_flare.gd b/scripts/game/deployed_flare.gd
new file mode 100644
index 0000000..1997deb
--- /dev/null
+++ b/scripts/game/deployed_flare.gd
@@ -0,0 +1,71 @@
+extends StaticBody2D
+
+const VFX := preload("res://scripts/game/extension_vfx.gd")
+const LIFETIME := 10.0
+const MAX_PARTICLES := 80
+var drift := Vector2.ZERO
+var _age := 0.0
+var _emission := 0.0
+var _particles: Array[Dictionary] = []
+var _rng := RandomNumberGenerator.new()
+var _launch_direction := Vector2.DOWN
+
+func _ready() -> void:
+ add_to_group("player_flares")
+ collision_layer = 16
+ collision_mask = 0
+ z_index = 23
+ _rng.randomize()
+ _launch_direction = drift.normalized() if drift.length_squared() > 0.01 else Vector2.DOWN
+ var collision := CollisionShape2D.new()
+ var shape := CircleShape2D.new()
+ shape.radius = 9.0
+ collision.shape = shape
+ add_child(collision)
+
+func _physics_process(delta: float) -> void:
+ _age += delta
+ position += drift * delta
+ drift = drift.move_toward(Vector2.ZERO, 35.0 * delta)
+ queue_redraw()
+ if _age >= LIFETIME:
+ queue_free()
+
+func _process(delta: float) -> void:
+ for index in range(_particles.size() - 1, -1, -1):
+ var particle := _particles[index]
+ particle["age"] = float(particle["age"]) + delta
+ particle["position"] = Vector2(particle["position"]) + Vector2(particle["velocity"]) * delta
+ particle["velocity"] = Vector2(particle["velocity"]) * exp(-1.8 * delta)
+ if float(particle["age"]) >= float(particle["life"]):
+ _particles.remove_at(index)
+ _emission += delta
+ while _emission >= 0.018 and _particles.size() < MAX_PARTICLES:
+ _emission -= 0.018
+ var side := Vector2(-_launch_direction.y, _launch_direction.x)
+ _particles.append({"position": global_position, "velocity": -_launch_direction * _rng.randf_range(15, 55) + side * _rng.randf_range(-18, 18), "age": 0.0, "life": _rng.randf_range(0.35, 0.85), "size": _rng.randf_range(1, 2.4)})
+ _emission = minf(_emission, 0.018)
+ queue_redraw()
+
+func take_damage(_amount: float) -> void:
+ # Countermeasure burns for the full ten seconds even when hit.
+ pass
+
+func _draw() -> void:
+ var fade := clampf(LIFETIME - _age, 0.0, 1.0)
+ var flicker := 0.90 + 0.06 * sin(_age * 29.0) + 0.04 * sin(_age * 47.0)
+ var glow := VFX.radial_texture()
+ for particle in _particles:
+ var remaining := 1.0 - float(particle["age"]) / float(particle["life"])
+ var point := to_local(Vector2(particle["position"]))
+ var radius := (5.0 + float(particle["age"]) * 10.0) * remaining
+ draw_texture_rect(glow, Rect2(point - Vector2.ONE * radius, Vector2.ONE * radius * 2), false, Color(0.9, 0.25, 0.04, remaining * 0.16 * fade))
+ var spark_color := Color(1.0, lerpf(0.24, 0.85, remaining), lerpf(0.02, 0.4, remaining), remaining * fade)
+ draw_line(point, point - Vector2(particle["velocity"]).normalized() * 5.0 * remaining, spark_color, float(particle["size"]) * remaining, true)
+ var radius := 48.0 * flicker
+ draw_texture_rect(glow, Rect2(-radius, -radius, radius * 2, radius * 2), false, Color(1.0, 0.28, 0.025, 0.52 * fade))
+ draw_texture_rect(glow, Rect2(-22, -22, 44, 44), false, Color(1.0, 0.66, 0.18, 0.9 * fade))
+ draw_texture_rect(glow, Rect2(-8, -8, 16, 16), false, Color(1.0, 0.97, 0.82, fade))
+ draw_circle(Vector2.ZERO, 2.4 * flicker, Color(1.0, 0.99, 0.9, fade))
+ draw_line(Vector2(-15, 0), Vector2(15, 0), Color(1.0, 0.74, 0.32, fade * 0.34), 1.0, true)
+ draw_line(Vector2(0, -10), Vector2(0, 10), Color(1.0, 0.8, 0.4, fade * 0.22), 1.0, true)
diff --git a/scripts/game/deployed_flare.gd.uid b/scripts/game/deployed_flare.gd.uid
new file mode 100644
index 0000000..bce1511
--- /dev/null
+++ b/scripts/game/deployed_flare.gd.uid
@@ -0,0 +1 @@
+uid://b4lvkvbncex2d
diff --git a/scripts/game/deployed_mine.gd b/scripts/game/deployed_mine.gd
new file mode 100644
index 0000000..bf8e8b4
--- /dev/null
+++ b/scripts/game/deployed_mine.gd
@@ -0,0 +1,61 @@
+extends Node2D
+
+const VFX := preload("res://scripts/game/extension_vfx.gd")
+const MINE_TEXTURE := preload("res://assets/icons/extensions/mine_art.png")
+const LIFETIME := 60.0
+const ARM_TIME := 0.7
+const TRIGGER_RADIUS := 34.0
+var drift := Vector2.ZERO
+var _age := 0.0
+var _triggered := false
+
+func _ready() -> void:
+ add_to_group("player_mines")
+ z_index = 12
+
+func _physics_process(delta: float) -> void:
+ _age += delta
+ position += drift * delta
+ drift = drift.move_toward(Vector2.ZERO, 100.0 * delta)
+ if _age >= LIFETIME:
+ queue_free()
+ return
+ queue_redraw()
+ if _triggered or _age < ARM_TIME:
+ return
+ for enemy in get_tree().get_nodes_in_group("enemies"):
+ if enemy.is_queued_for_deletion() or float(enemy.get("hull")) <= 0.0:
+ continue
+ if global_position.distance_to(enemy.global_position) <= TRIGGER_RADIUS + float(enemy.call("get_visual_size")) * 0.35:
+ _triggered = true
+ enemy.call("apply_mine_hit")
+ var explosion := Node2D.new()
+ explosion.set_script(preload("res://scripts/game/deployment_flash.gd"))
+ explosion.set("color", Color("ffbc75"))
+ explosion.set("blast", true)
+ explosion.position = position
+ get_parent().add_child(explosion)
+ queue_free()
+ return
+
+func _draw() -> void:
+ var armed := _age >= ARM_TIME
+ var deployment := smoothstep(0.0, 0.35, _age)
+ var fade := clampf(LIFETIME - _age, 0.0, 1.0)
+ var accent := Color("ff9957") if armed else Color("8ad7e7")
+ var pulse := 0.65 + 0.35 * sin(_age * 5.0)
+ var glow_radius := 28.0 + pulse * 4.0
+ draw_texture_rect(VFX.radial_texture(), Rect2(-glow_radius, -glow_radius, glow_radius * 2, glow_radius * 2), false, Color(accent, 0.22 * fade))
+ if armed:
+ for segment in 3:
+ var angle := _age * 0.35 + float(segment) * TAU / 3.0
+ draw_arc(Vector2.ZERO, 27.0, angle, angle + 0.6, 16, Color(accent, 0.22 * fade), 1.0, true)
+ else:
+ draw_arc(Vector2.ZERO, 23.0, -PI * 0.5, -PI * 0.5 + TAU * clampf(_age / ARM_TIME, 0.0, 1.0), 48, Color(accent, fade * 0.7), 1.5, true)
+ var scale_factor := lerpf(0.35, 1.0, deployment)
+ var spin := (1.0 - deployment) * -0.85
+ draw_set_transform(Vector2.ZERO, spin, Vector2.ONE * scale_factor)
+ draw_texture_rect(MINE_TEXTURE, Rect2(-21, -21, 42, 42), false, Color(1, 1, 1, fade))
+ draw_set_transform(Vector2.ZERO)
+ var sensor_radius := 7.0 + pulse * 2.0
+ draw_texture_rect(VFX.radial_texture(), Rect2(-sensor_radius, -sensor_radius, sensor_radius * 2, sensor_radius * 2), false, Color(accent, (0.55 + pulse * 0.35) * fade))
diff --git a/scripts/game/deployed_mine.gd.uid b/scripts/game/deployed_mine.gd.uid
new file mode 100644
index 0000000..c2c58e9
--- /dev/null
+++ b/scripts/game/deployed_mine.gd.uid
@@ -0,0 +1 @@
+uid://cb3vd7xbdbu6i
diff --git a/scripts/game/deployment_flash.gd b/scripts/game/deployment_flash.gd
new file mode 100644
index 0000000..c88317c
--- /dev/null
+++ b/scripts/game/deployment_flash.gd
@@ -0,0 +1,36 @@
+extends Node2D
+
+const VFX := preload("res://scripts/game/extension_vfx.gd")
+var color := Color("ffb15c")
+var direction := Vector2.DOWN
+var blast := false
+var _age := 0.0
+var _particles: Array[Dictionary] = []
+
+func _ready() -> void:
+ z_index = 26
+ var rng := RandomNumberGenerator.new()
+ rng.randomize()
+ for index in (28 if blast else 14):
+ var ray := Vector2.RIGHT.rotated(rng.randf_range(0, TAU)) if blast else direction.rotated(rng.randf_range(-1.0, 1.0))
+ _particles.append({"velocity": ray * rng.randf_range(45, 155), "size": rng.randf_range(1.0, 2.3)})
+
+func _process(delta: float) -> void:
+ _age += delta
+ queue_redraw()
+ if _age >= (0.8 if blast else 0.4):
+ queue_free()
+
+func _draw() -> void:
+ var life := 0.8 if blast else 0.4
+ var progress := clampf(_age / life, 0, 1)
+ var fade := pow(1.0 - progress, 2)
+ var radius := (65.0 if blast else 35.0) * (0.4 + progress)
+ draw_texture_rect(VFX.radial_texture(), Rect2(-radius, -radius, radius * 2, radius * 2), false, Color(color, fade * 0.9))
+ if blast:
+ draw_arc(Vector2.ZERO, 8.0 + progress * 80.0, 0, TAU, 64, Color(color, fade * 0.6), 2.0, true)
+ draw_arc(Vector2.ZERO, 5.0 + progress * 55.0, 0, TAU, 64, Color("fff2d3", fade * 0.35), 1.0, true)
+ for particle in _particles:
+ var velocity: Vector2 = particle["velocity"]
+ var point := velocity * _age * (1.0 - progress * 0.35)
+ draw_line(point, point - velocity.normalized() * (3.0 + 7.0 * fade), Color(color, fade), float(particle["size"]), true)
diff --git a/scripts/game/deployment_flash.gd.uid b/scripts/game/deployment_flash.gd.uid
new file mode 100644
index 0000000..30f9f99
--- /dev/null
+++ b/scripts/game/deployment_flash.gd.uid
@@ -0,0 +1 @@
+uid://dpedhxnegxr3s
diff --git a/scripts/game/enemy_plasma_projectile.gd b/scripts/game/enemy_plasma_projectile.gd
index 63ac22a..c1b46ff 100644
--- a/scripts/game/enemy_plasma_projectile.gd
+++ b/scripts/game/enemy_plasma_projectile.gd
@@ -48,7 +48,7 @@ func _hit_target(target: Variant) -> void:
effect.global_position = global_position
get_parent().add_child(effect)
effect.call("configure", false, Color(1.0, 0.32, 0.38))
- if target is Node and target.has_method("take_damage"):
+ if target is Node and target.has_method("take_damage") and not target.is_in_group("asteroids"):
if target is CharacterBody2D:
target.call("take_damage", damage, false, DamageTypes.Type.ENERGY)
else:
diff --git a/scripts/game/enemy_ship.gd b/scripts/game/enemy_ship.gd
index 156ba7e..1c04789 100644
--- a/scripts/game/enemy_ship.gd
+++ b/scripts/game/enemy_ship.gd
@@ -33,6 +33,8 @@ var _player: CharacterBody2D
var _activated := false
var _sprite: Sprite2D
var _weapon_timer := 0.0
+var _slow_remaining := 0.0
+var _combat_target: Node2D
func _ready() -> void:
hull = max_hull
@@ -89,20 +91,26 @@ func get_visual_size() -> float:
return 112.0 if loot_tier == 2 else (68.0 if loot_tier == 1 else 56.0)
func _physics_process(delta: float) -> void:
+ _slow_remaining = maxf(0.0, _slow_remaining - delta)
+ _weapon_timer = maxf(0.0, _weapon_timer - delta)
if not is_instance_valid(_player) or bool(_player.get("_is_destroyed")):
velocity = velocity.move_toward(Vector2.ZERO, approach_acceleration * delta)
move_and_slide()
return
- var to_player := _player.global_position - global_position
+ _combat_target = select_combat_target()
+ if _combat_target == null:
+ velocity = velocity.move_toward(Vector2.ZERO, approach_acceleration * delta)
+ move_and_slide()
+ return
+ var to_player := _combat_target.global_position - global_position
if to_player.length() <= activation_range:
_activated = true
if _activated and to_player.length_squared() > 1.0:
var direction := to_player.normalized()
var desired_rotation := direction.angle() + PI * 0.5
rotation = rotate_toward(rotation, desired_rotation, turn_speed * delta)
- velocity = velocity.move_toward(direction * approach_speed, approach_acceleration * delta)
- _weapon_timer = maxf(0.0, _weapon_timer - delta)
+ velocity = velocity.move_toward(direction * approach_speed * get_slow_multiplier(), approach_acceleration * get_slow_multiplier() * delta)
if to_player.length() <= weapon_range and _weapon_timer <= 0.0:
_fire_plasma(direction)
_weapon_timer = get_fire_interval()
@@ -112,6 +120,32 @@ func _physics_process(delta: float) -> void:
move_and_slide()
_check_player_collision()
+func get_slow_multiplier() -> float:
+ return 0.5 if _slow_remaining > 0.0 else 1.0
+
+func select_combat_target() -> Node2D:
+ var closest: Node2D
+ var closest_distance := INF
+ for flare in get_tree().get_nodes_in_group("player_flares"):
+ if flare.is_queued_for_deletion():
+ continue
+ var distance := global_position.distance_to(flare.global_position)
+ if distance <= maxf(activation_range, weapon_range) and distance < closest_distance:
+ closest = flare
+ closest_distance = distance
+ if closest != null:
+ return closest
+ if is_instance_valid(_player) and not bool(_player.get("_is_destroyed")) and not bool(_player.call("is_cloaked")):
+ return _player
+ return null
+
+func apply_mine_hit() -> void:
+ if is_queued_for_deletion() or hull <= 0.0:
+ return
+ _slow_remaining = 5.0
+ velocity = velocity.limit_length(approach_speed * 0.5)
+ take_damage(max_hull * 0.05, false, DamageTypes.Type.KINETIC, true)
+
func _check_player_collision() -> void:
for collision_index in get_slide_collision_count():
var collision := get_slide_collision(collision_index)
@@ -122,7 +156,7 @@ func _fire_plasma(direction: Vector2) -> void:
var muzzle := global_position + direction * (get_visual_size() * 0.5 + 8.0)
if weapon_kind == ItemDefinition.WeaponKind.LASER:
var endpoint := muzzle + _laser_aim_direction(direction) * weapon_range
- var query := PhysicsRayQueryParameters2D.create(muzzle, endpoint, 3, [get_rid()])
+ var query := PhysicsRayQueryParameters2D.create(muzzle, endpoint, 51, [get_rid()])
var hit := get_world_2d().direct_space_state.intersect_ray(query)
if not hit.is_empty():
endpoint = hit["position"]
@@ -140,7 +174,7 @@ func _fire_plasma(direction: Vector2) -> void:
else:
var projectile := preload("res://scenes/game/weapon_projectile.tscn").instantiate()
projectile.enemy_shot = true
- projectile.collision_mask = 3
+ projectile.collision_mask = 51
projectile.missile_homing = false
var speed := 430.0 if weapon_kind == ItemDefinition.WeaponKind.GAMMA else (950.0 if weapon_kind == ItemDefinition.WeaponKind.MACHINE_GUN else 600.0)
projectile.configure(muzzle, direction, weapon_kind, speed, weapon_damage, weapon_range)
@@ -155,10 +189,10 @@ func take_weapon_hit(amount: float, critical: bool, damage_type: int = DamageTyp
if is_instance_valid(_player):
_player.call("restore_shields_from_damage", maxf(0.0, before - hull - shields))
-func take_damage(amount: float, critical: bool = false, damage_type: int = DamageTypes.Type.KINETIC) -> void:
+func take_damage(amount: float, critical: bool = false, damage_type: int = DamageTypes.Type.KINETIC, bypass_absorption: bool = false) -> void:
if is_queued_for_deletion() or hull <= 0.0:
return
- var actual := maxf(0.0, amount) * (1.0 - absorption[damage_type] / 100.0)
+ var actual := maxf(0.0, amount) * (1.0 if bypass_absorption else 1.0 - absorption[damage_type] / 100.0)
var shield_damage := minf(shields, actual)
shields -= shield_damage
hull = maxf(0.0, hull - actual + shield_damage)
@@ -181,7 +215,7 @@ func get_fire_interval() -> float:
func _laser_aim_direction(direction: Vector2) -> Vector2:
if randf() < LASER_MISS_CHANCE:
var side := Vector2(-direction.y, direction.x)
- var distance := global_position.distance_to(_player.global_position) if is_instance_valid(_player) else weapon_range
+ var distance := global_position.distance_to(_combat_target.global_position) if is_instance_valid(_combat_target) else weapon_range
var offset := randf_range(60.0, 110.0) * (-1.0 if randf() < 0.5 else 1.0)
return (direction * maxf(1.0, distance) + side * offset).normalized()
return direction.rotated(randf_range(-0.02, 0.02))
@@ -216,7 +250,7 @@ func _drop_loot() -> void:
return
var pickup := Node2D.new()
pickup.set_script(LOOT_PICKUP)
- pickup.set("item", ITEM_GENERATOR.generate(GameState.system_level, loot_tier))
+ pickup.set("item", ITEM_GENERATOR.generate_loot(GameState.system_level, loot_tier, null, float(_player.get("luck_percent")) if is_instance_valid(_player) else 0.0))
pickup.position = position + LOOT_OFFSET
get_parent().call_deferred("add_child", pickup)
diff --git a/scripts/game/extension_pulse.gd b/scripts/game/extension_pulse.gd
new file mode 100644
index 0000000..7ad49f4
--- /dev/null
+++ b/scripts/game/extension_pulse.gd
@@ -0,0 +1,23 @@
+extends Node2D
+
+var color := Color("9be7ff")
+var radius := 50.0
+var _time := 0.0
+
+func _ready() -> void:
+ z_index = 28
+
+func _process(delta: float) -> void:
+ _time += delta
+ queue_redraw()
+ if _time >= 0.7:
+ queue_free()
+
+func _draw() -> void:
+ var progress := _time / 0.7
+ var alpha := 1.0 - progress
+ for ring in 3:
+ draw_arc(Vector2.ZERO, 8.0 + radius * progress + float(ring) * 5.0, 0, TAU, 64, Color(color, alpha * (0.5 - float(ring) * 0.12)), 2.0, true)
+ for index in 12:
+ var direction := Vector2.RIGHT.rotated(float(index) * TAU / 12.0)
+ draw_line(direction * (10.0 + radius * progress), direction * (20.0 + radius * progress), Color(color, alpha * 0.6), 1.5, true)
diff --git a/scripts/game/extension_pulse.gd.uid b/scripts/game/extension_pulse.gd.uid
new file mode 100644
index 0000000..471bd3b
--- /dev/null
+++ b/scripts/game/extension_pulse.gd.uid
@@ -0,0 +1 @@
+uid://cb7y8kdev6y4f
diff --git a/scripts/game/extension_systems.gd b/scripts/game/extension_systems.gd
new file mode 100644
index 0000000..152f362
--- /dev/null
+++ b/scripts/game/extension_systems.gd
@@ -0,0 +1,160 @@
+extends Node
+
+const WORLD_SIZE := preload("res://scripts/levels/system_generator.gd").WORLD_SIZE
+const PULSE := preload("res://scripts/game/extension_pulse.gd")
+var cooldowns: Dictionary = {}
+var cloak_remaining := 0.0
+var solar_remaining := 0.0
+var _cloak_material: ShaderMaterial
+var _old_material: Material
+var _shadow_was_visible := false
+var _visual_colors: Dictionary = {}
+var _destination := Vector2.ZERO
+var _has_destination := false
+@onready var ship: CharacterBody2D = get_parent()
+
+func reset_combat_state() -> void:
+ cooldowns.clear()
+ solar_remaining = 0.0
+ if cloak_remaining > 0.0:
+ _end_cloak()
+
+func _physics_process(delta: float) -> void:
+ for id in cooldowns.keys():
+ cooldowns[id] = maxf(0.0, float(cooldowns[id]) - delta)
+ if cloak_remaining > 0.0:
+ cloak_remaining = maxf(0.0, cloak_remaining - delta)
+ if _cloak_material != null:
+ _cloak_material.set_shader_parameter("elapsed", 10.0 - cloak_remaining)
+ if cloak_remaining <= 0.0 or bool(ship.get("_is_destroyed")):
+ _end_cloak()
+ if solar_remaining > 0.0:
+ var available_time := minf(delta, solar_remaining)
+ solar_remaining = maxf(0.0, solar_remaining - delta)
+ if bool(ship.get("_is_destroyed")) or bool(ship.get("is_docked")) or float(ship.call("get_solar_regeneration")) <= 0.0 or GameState.captain.is_skill_active("power_redirect"):
+ solar_remaining = 0.0
+ return
+ var maximum := float(ship.get("max_shields"))
+ ship.set("shields", minf(maximum, float(ship.get("shields")) + maximum * available_time / 5.0))
+ ship.call("_update_shield_visual")
+ ship.get_node("ShieldVisual").call("flash_hit")
+ if float(ship.get("shields")) >= maximum:
+ solar_remaining = 0.0
+
+func _safe_destination(point: Vector2) -> bool:
+ var query := PhysicsShapeQueryParameters2D.new()
+ var shape := CircleShape2D.new()
+ shape.radius = 65.0
+ query.shape = shape
+ query.transform = Transform2D(0.0, point)
+ query.collision_mask = 5
+ query.exclude = [ship.get_rid()]
+ return ship.get_world_2d().direct_space_state.intersect_shape(query, 1).is_empty()
+
+func has_teleport_destination() -> bool:
+ if _has_destination and _destination.distance_to(ship.global_position) >= 400.0 and _safe_destination(_destination):
+ return true
+ var half := WORLD_SIZE * 0.5 - Vector2(160, 160)
+ for attempt in 48:
+ var candidate := Vector2(randf_range(-half.x, half.x), randf_range(-half.y, half.y))
+ if candidate.distance_to(ship.global_position) >= 400.0 and _safe_destination(candidate):
+ _destination = candidate
+ _has_destination = true
+ return true
+ _has_destination = false
+ return false
+
+func activate(kind: int) -> void:
+ match kind:
+ 0:
+ if not has_teleport_destination():
+ return
+ _teleport_effect(ship.global_position, false)
+ ship.global_position = _destination
+ _has_destination = false
+ ship.velocity = Vector2.ZERO
+ ship.set("angular_velocity", 0.0)
+ ship.set("_boost_target", 0.0)
+ ship.set("_boost_speed", 0.0)
+ ship.set("_mini_boost_coast_speed", 0.0)
+ ship.set("energy", 0.0)
+ ship.reset_physics_interpolation()
+ ship.get_node("Camera2D").reset_smoothing()
+ _teleport_effect(ship.global_position, true)
+ 1: _start_cloak()
+ 2:
+ var mine := Node2D.new()
+ mine.set_script(preload("res://scripts/game/deployed_mine.gd"))
+ var drop_direction := Vector2.DOWN.rotated(ship.rotation)
+ mine.position = ship.position + drop_direction * 42.0
+ mine.set("drift", drop_direction * 36.0)
+ _deployment_effect(mine.position, drop_direction, Color("8ad7e7"))
+ ship.get_parent().add_child(mine)
+ 3:
+ _deployment_effect(ship.position + Vector2.DOWN.rotated(ship.rotation) * 28.0, Vector2.DOWN.rotated(ship.rotation), Color("ffb15c"))
+ for index in 3:
+ var flare := StaticBody2D.new()
+ flare.set_script(preload("res://scripts/game/deployed_flare.gd"))
+ var direction := Vector2.DOWN.rotated(ship.rotation + (float(index) - 1.0) * 0.8)
+ flare.position = ship.position + direction * 48.0
+ flare.set("drift", direction * 90.0 + ship.velocity * 0.25)
+ ship.get_parent().add_child(flare)
+ 4:
+ solar_remaining = 5.0
+ _pulse(ship.global_position, Color("70dfff"), 50.0)
+
+func _teleport_effect(point: Vector2, arriving: bool) -> void:
+ var effect := Node2D.new()
+ effect.set_script(preload("res://scripts/game/teleport_vortex.gd"))
+ effect.position = point
+ effect.set("arriving", arriving)
+ effect.set("ship_texture", ship.get_node("Sprite2D").get("texture"))
+ effect.set("ship_rotation", ship.rotation)
+ ship.get_parent().add_child(effect)
+
+func _deployment_effect(point: Vector2, direction: Vector2, color: Color) -> void:
+ var effect := Node2D.new()
+ effect.set_script(preload("res://scripts/game/deployment_flash.gd"))
+ effect.position = point
+ effect.set("direction", direction)
+ effect.set("color", color)
+ ship.get_parent().add_child(effect)
+
+func _start_cloak() -> void:
+ cloak_remaining = 10.0
+ var sprite := ship.get_node("Sprite2D") as Sprite2D
+ _old_material = sprite.material
+ _cloak_material = ShaderMaterial.new()
+ _cloak_material.shader = preload("res://resources/ui/cloak.gdshader")
+ sprite.material = _cloak_material
+ var shadow := sprite.get_node_or_null("ShipShadow") as CanvasItem
+ if shadow != null:
+ _shadow_was_visible = shadow.visible
+ shadow.hide()
+ for name in ["ShieldVisual", "EngineVisual", "SolarEnergyEffect", "EnergyIndicator"]:
+ var visual := ship.get_node_or_null(name) as CanvasItem
+ if visual != null:
+ _visual_colors[name] = visual.modulate
+ visual.modulate = Color(visual.modulate, 0.08)
+ _pulse(ship.global_position, Color("81e4ef"), 40.0)
+
+func _end_cloak() -> void:
+ cloak_remaining = 0.0
+ ship.get_node("Sprite2D").set("material", _old_material)
+ _cloak_material = null
+ var shadow := ship.get_node_or_null("Sprite2D/ShipShadow") as CanvasItem
+ if shadow != null:
+ shadow.visible = _shadow_was_visible
+ for name in _visual_colors:
+ var visual := ship.get_node_or_null(NodePath(name)) as CanvasItem
+ if visual != null:
+ visual.modulate = _visual_colors[name]
+ _visual_colors.clear()
+
+func _pulse(point: Vector2, color: Color, radius: float) -> void:
+ var pulse := Node2D.new()
+ pulse.set_script(PULSE)
+ pulse.position = point
+ pulse.set("color", color)
+ pulse.set("radius", radius)
+ ship.get_parent().add_child(pulse)
diff --git a/scripts/game/extension_systems.gd.uid b/scripts/game/extension_systems.gd.uid
new file mode 100644
index 0000000..52fa325
--- /dev/null
+++ b/scripts/game/extension_systems.gd.uid
@@ -0,0 +1 @@
+uid://dosf61lp5sny1
diff --git a/scripts/game/extension_vfx.gd b/scripts/game/extension_vfx.gd
new file mode 100644
index 0000000..5e9583e
--- /dev/null
+++ b/scripts/game/extension_vfx.gd
@@ -0,0 +1,17 @@
+extends RefCounted
+
+static var _radial: GradientTexture2D
+
+static func radial_texture() -> GradientTexture2D:
+ if _radial == null:
+ var gradient := Gradient.new()
+ gradient.offsets = PackedFloat32Array([0.0, 0.12, 0.35, 0.7, 1.0])
+ gradient.colors = PackedColorArray([Color(1, 1, 1, 1), Color(1, 1, 1, 0.85), Color(1, 1, 1, 0.3), Color(1, 1, 1, 0.04), Color(1, 1, 1, 0)])
+ _radial = GradientTexture2D.new()
+ _radial.gradient = gradient
+ _radial.width = 128
+ _radial.height = 128
+ _radial.fill = GradientTexture2D.FILL_RADIAL
+ _radial.fill_from = Vector2(0.5, 0.5)
+ _radial.fill_to = Vector2(1.0, 0.5)
+ return _radial
diff --git a/scripts/game/extension_vfx.gd.uid b/scripts/game/extension_vfx.gd.uid
new file mode 100644
index 0000000..bf5ee43
--- /dev/null
+++ b/scripts/game/extension_vfx.gd.uid
@@ -0,0 +1 @@
+uid://djhax4e4n5hdh
diff --git a/scripts/game/flight_hud.gd b/scripts/game/flight_hud.gd
index ccb3bd4..504d57d 100644
--- a/scripts/game/flight_hud.gd
+++ b/scripts/game/flight_hud.gd
@@ -1,5 +1,7 @@
extends CanvasLayer
+const RENDER_LAYERS := preload("res://scripts/ui/render_layers.gd")
+
@onready var debug_toggle: Button = $HUDRoot/DebugToggle
@onready var debug_info: Label = $HUDRoot/DebugInfo
@onready var player: CharacterBody2D = get_parent().get_node("PlayerShip")
@@ -32,11 +34,11 @@ func _create_navigation() -> void:
navigation.name = "TopNavigation"
$HUDRoot.add_child(navigation)
navigation.set_anchors_and_offsets_preset(Control.PRESET_CENTER_TOP)
- navigation.offset_left = -60
- navigation.offset_right = 60
+ navigation.offset_left = -70
+ navigation.offset_right = 70
navigation.offset_top = 12
- navigation.offset_bottom = 64
- navigation.add_theme_constant_override("separation", 8)
+ navigation.offset_bottom = 92
+ navigation.add_theme_constant_override("separation", 10)
_crafting_window = _create_window("CraftingWindow", "WARSZTAT", Vector2(704, 420))
var crafting := PanelContainer.new()
crafting.name = "CraftingPanel"
@@ -47,20 +49,11 @@ func _create_navigation() -> void:
_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")]]:
+ for entry in [["Twórz", _crafting_window, "res://assets/icons/ui/crafting_art.png", Color("efb568")], ["Umiejętności kapitana", null, "res://assets/icons/ui/captain_skills_art.png", Color("b9a0ef")]]:
var button := Button.new()
button.tooltip_text = entry[0]
- button.icon = load(entry[2]) as Texture2D
- button.expand_icon = true
- button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
- button.custom_minimum_size = Vector2(56, 52)
- button.add_theme_constant_override("icon_max_width", 36)
- preload("res://scripts/ui/hud_button_style.gd").apply(button, entry[3])
- button.add_theme_constant_override("h_separation", 0)
- for state in ["normal", "hover", "pressed", "disabled"]:
- var style := button.get_theme_stylebox(state).duplicate() as StyleBoxFlat
- style.set_content_margin_all(8)
- button.add_theme_stylebox_override(state, style)
+ button.set_script(preload("res://scripts/ui/navigation_button.gd"))
+ button.call("configure_navigation", load(entry[2]) as Texture2D, entry[3], "TWÓRZ" if entry[1] != null else "KAPITAN")
navigation.add_child(button)
if entry[1] == null:
button.name = "OpenCaptainSkills"
@@ -74,9 +67,9 @@ func _create_navigation() -> void:
func _create_window(node_name: String, title: String, dimensions: Vector2) -> PanelContainer:
var window := PanelContainer.new()
window.name = node_name
- window.z_index = 100
+ window.z_index = RENDER_LAYERS.WINDOW_Z
var style := StyleBoxFlat.new()
- style.bg_color = Color("0b1520fa")
+ style.bg_color = Color("0b1520")
style.border_color = Color("376779")
style.set_border_width_all(1)
style.set_corner_radius_all(8)
@@ -115,6 +108,7 @@ func _toggle_window(window: PanelContainer) -> void:
window.visible = opening
func _ready() -> void:
+ layer = RENDER_LAYERS.HUD
_sidebar_open = bool(GameState.hud_panel_state.get("ship_open", false))
_debug_expanded = bool(GameState.hud_panel_state.get("debug_expanded", false))
debug_info.visible = _debug_expanded
@@ -156,7 +150,7 @@ func _create_sidebar() -> void:
_captain_panel = $HUDRoot/CaptainPanel
_captain_panel.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
_captain_panel.position = Vector2(12, 12)
- _captain_panel.size = Vector2(320, _captain_panel.get_combined_minimum_size().y)
+ _captain_panel.size = Vector2(244, _captain_panel.get_combined_minimum_size().y)
_captain_panel.resized.connect(_layout_debug)
_passive_skills = VBoxContainer.new()
_passive_skills.name = "PassiveCaptainSkills"
@@ -193,7 +187,7 @@ func _create_sidebar() -> void:
_sidebar = PanelContainer.new()
_sidebar.name = "ShipDrawer"
var style := StyleBoxFlat.new()
- style.bg_color = Color(0.025, 0.042, 0.064, 0.97)
+ style.bg_color = Color(0.025, 0.042, 0.064, 1.0)
style.border_color = Color("376779")
style.set_border_width_all(1)
style.set_corner_radius_all(8)
@@ -427,7 +421,8 @@ Energia: %+.1f pkt
Prędkość maks.: %+.1f%%
Skrócenie cooldownu rakiet: %.2f s
Doświadczenie: %+.1f%%
-Kredyty: %+.1f%%""".replace("\r", "") % [
+Kredyty: %+.1f%%
+Szczęście: %.1f%% / 60%%""".replace("\r", "") % [
(float(player.call("get_weapon_damage_multiplier")) - 1.0) * 100.0,
float(player.call("get_critical_chance")) * 100.0,
float(player.get("critical_damage_bonus")) * 100.0,
@@ -443,6 +438,7 @@ Kredyty: %+.1f%%""".replace("\r", "") % [
cooldown_bonus,
(float(player.get("experience_multiplier")) - 1.0) * 100.0,
(float(player.get("credits_multiplier")) - 1.0) * 100.0,
+ float(player.call("get_luck_chance")) * 100.0,
]
func _create_ability_bar() -> void:
@@ -476,6 +472,17 @@ func _create_ability_bar() -> void:
button.add_child(shortcut)
button.add_theme_font_size_override("font_size", 11)
button.pressed.connect(func() -> void: player.call("activate_ability", index))
+ var timer := Label.new()
+ timer.name = "Cooldown"
+ timer.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ timer.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ timer.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ timer.add_theme_font_size_override("font_size", 13)
+ timer.add_theme_font_override("font", ThemeDB.fallback_font)
+ timer.add_theme_color_override("font_outline_color", Color("030810"))
+ timer.add_theme_constant_override("outline_size", 4)
+ button.add_child(timer)
+ timer.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_ability_bar.add_child(button)
_ability_buttons[index] = button
_update_ability_bar()
@@ -493,7 +500,9 @@ func _update_ability_bar() -> void:
button.disabled = not reason.is_empty()
var item: ItemInstance = player.get_node("ShipInventory").get_item("extension", index)
button.call("configure", item.definition.icon, Color("91dbea"))
- button.call("set_visual_state", 0.0, false, not reason.is_empty())
+ var cooldown := float(player.call("get_ability_cooldown", index))
+ button.call("set_visual_state", cooldown / maxf(0.01, ability.cooldown), false, not reason.is_empty())
+ (button.get_node("Cooldown") as Label).text = str(ceili(cooldown)) if cooldown > 0.0 else ""
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
diff --git a/scripts/game/game_state.gd b/scripts/game/game_state.gd
index b055bc2..9126695 100644
--- a/scripts/game/game_state.gd
+++ b/scripts/game/game_state.gd
@@ -8,6 +8,7 @@ var restart_inventory: Dictionary = {}
var system_level: int = 1
var system_seed: int = 0
var explored_pois: Dictionary = {}
+var exploration_wreck_bonuses: Dictionary = {}
var hud_panel_state: Dictionary = {}
func create_system_seed() -> int:
@@ -15,12 +16,20 @@ func create_system_seed() -> int:
rng.randomize()
return maxi(1, rng.randi())
+func reset_current_system_exploration() -> void:
+ # Retrying after death starts a fresh attempt at this board while keeping rewards.
+ var prefix := "%d:" % system_seed
+ for id in explored_pois.keys():
+ if str(id).begins_with(prefix):
+ explored_pois.erase(id)
+
func start_new_run() -> void:
captain = CAPTAIN_PROGRESS.new()
system_level = 1
system_seed = create_system_seed()
restart_inventory.clear()
explored_pois.clear()
+ exploration_wreck_bonuses.clear()
hud_panel_state.clear()
func _ready() -> void:
diff --git a/scripts/game/ship_controller.gd b/scripts/game/ship_controller.gd
index 8a2733a..7e4defb 100644
--- a/scripts/game/ship_controller.gd
+++ b/scripts/game/ship_controller.gd
@@ -14,8 +14,8 @@ const DESTRUCTION_EFFECT := preload("res://scripts/game/player_destruction_effec
@export var reverse_max_speed := 210.0
@export var max_strafe_speed := 210.0
@export var thrust_acceleration := 90.0
-@export var brake_acceleration := 540.0
-@export var space_drag := 24.0
+@export var brake_acceleration := 180.0
+@export var space_drag := 16.0
@export var max_turn_speed := 2.8
@export var turn_acceleration := 7.0
@export var turn_damping := 4.0
@@ -40,6 +40,7 @@ var absorption := Vector3.ZERO
var damage_multiplier := 1.0
var experience_multiplier := 1.0
var credits_multiplier := 1.0
+var luck_percent := 0.0
var shield_on_damage_percent := 0.0
var critical_chance_bonus := 0.0
var critical_damage_bonus := 0.0
@@ -63,6 +64,7 @@ var _mini_boost_coast_speed := 0.0
@onready var _shield_visual: Node2D = $ShieldVisual
func _enter_tree() -> void:
+ GameState.captain.reset_combat_state()
# Set the chassis before inventory children calculate equipment bonuses.
var chassis := SHIP_CATALOG.for_path(GameState.selected_ship_path)
base_max_hull = float(chassis["hull"])
@@ -80,6 +82,7 @@ func apply_equipment_bonuses(bonuses: Dictionary) -> void:
damage_multiplier = 1.0 + float(bonuses.get("damage_percent", 0.0)) / 100.0
experience_multiplier = 1.0 + float(bonuses.get("experience_percent", 0.0)) / 100.0
credits_multiplier = 1.0 + float(bonuses.get("credits_percent", 0.0)) / 100.0
+ luck_percent = maxf(0.0, float(bonuses.get("luck_percent", 0.0)))
shield_on_damage_percent = maxf(0.0, float(bonuses.get("shield_on_damage_percent", 0.0)))
critical_chance_bonus = float(bonuses.get("critical_chance_percent", 0.0)) / 100.0
critical_damage_bonus = float(bonuses.get("critical_damage_percent", 0.0)) / 100.0
@@ -93,6 +96,11 @@ func apply_equipment_bonuses(bonuses: Dictionary) -> void:
_update_shield_visual()
func _ready() -> void:
+ var extension_systems := Node.new()
+ extension_systems.name = "ExtensionSystems"
+ extension_systems.set_script(preload("res://scripts/game/extension_systems.gd"))
+ extension_systems.set("cooldowns", $ShipInventory.extension_cooldowns)
+ add_child(extension_systems)
for index in ShipInventory.EXTENSION_SLOT_COUNT:
_ensure_input_action(StringName("ability_%d" % (index + 1)), (KEY_1 + index) as Key)
_ensure_input_action(&"use_energy_bank", KEY_R)
@@ -106,7 +114,7 @@ func _ready() -> void:
var scanner_layer := CanvasLayer.new()
scanner_layer.name = "ScannerOverlay"
- scanner_layer.layer = 8
+ scanner_layer.layer = preload("res://scripts/ui/render_layers.gd").SCANNER
add_child(scanner_layer)
var scanner := Control.new()
scanner.set_script(preload("res://scripts/ui/scanner_overlay.gd"))
@@ -220,6 +228,8 @@ func _destroy_ship() -> void:
if _is_destroyed:
return
_is_destroyed = true
+ $ExtensionSystems.reset_combat_state()
+ GameState.captain.reset_combat_state()
var death_velocity := velocity
velocity = Vector2.ZERO
collision_layer = 0
@@ -255,6 +265,7 @@ func _show_defeat_screen() -> void:
func _restart_level_after_destruction() -> void:
if is_inside_tree():
$ShipInventory.preserve_for_restart()
+ GameState.reset_current_system_exploration()
get_tree().reload_current_scene.call_deferred()
func _physics_process(delta: float) -> void:
@@ -391,16 +402,35 @@ func get_active_ability(slot_index: int) -> ActiveAbility:
var item: ItemInstance = $ShipInventory.get_item("extension", slot_index)
return item.definition.active_ability if item != null and item.definition != null else null
+func get_luck_chance() -> float:
+ return GameState.captain.get_luck_chance(luck_percent)
+
+func is_cloaked() -> bool:
+ var systems := get_node_or_null("ExtensionSystems")
+ return systems != null and float(systems.get("cloak_remaining")) > 0.0 and not _is_destroyed
+
+func get_ability_cooldown(slot_index: int) -> float:
+ var ability := get_active_ability(slot_index)
+ if ability == null or ability.ability_id.is_empty():
+ return 0.0
+ return float($ExtensionSystems.cooldowns.get(ability.ability_id, 0.0))
+
func ability_unavailable_reason(slot_index: int) -> String:
- if _is_destroyed or is_docked:
+ if _is_destroyed or is_docked or get_tree().paused:
return "Statek niedostępny"
+ var remaining := get_ability_cooldown(slot_index)
+ if remaining > 0.0:
+ return "Odnowienie: %d s" % ceili(remaining)
var ability := get_active_ability(slot_index)
return ability.unavailable_reason(self) if ability != null else "Brak rozszerzenia"
func activate_ability(slot_index: int) -> bool:
if not ability_unavailable_reason(slot_index).is_empty():
return false
- get_active_ability(slot_index).activate(self)
+ var ability := get_active_ability(slot_index)
+ ability.activate(self)
+ if ability.cooldown > 0.0 and not ability.ability_id.is_empty():
+ $ExtensionSystems.cooldowns[ability.ability_id] = ability.cooldown
return true
func use_energy_bank() -> void:
diff --git a/scripts/game/teleport_vortex.gd b/scripts/game/teleport_vortex.gd
new file mode 100644
index 0000000..d33191a
--- /dev/null
+++ b/scripts/game/teleport_vortex.gd
@@ -0,0 +1,44 @@
+extends Node2D
+
+const VFX := preload("res://scripts/game/extension_vfx.gd")
+var arriving := true
+var ship_texture: Texture2D
+var ship_rotation := 0.0
+var _age := 0.0
+var _shards: Array[Dictionary] = []
+
+func _ready() -> void:
+ z_index = 27
+ var rng := RandomNumberGenerator.new()
+ rng.randomize()
+ for index in 28:
+ _shards.append({"angle": rng.randf_range(0, TAU), "distance": rng.randf_range(25, 85), "size": rng.randf_range(1, 3)})
+
+func _process(delta: float) -> void:
+ _age += delta
+ queue_redraw()
+ if _age >= 1.0:
+ queue_free()
+
+func _draw() -> void:
+ var progress := clampf(_age, 0, 1)
+ var fade := pow(1.0 - progress, 2)
+ var radius := 28.0 + (70.0 * (1.0 - progress) if arriving else progress * 70.0)
+ draw_texture_rect(VFX.radial_texture(), Rect2(-90, -90, 180, 180), false, Color(0.42, 0.35, 1.0, fade * 0.65))
+ for ring in 3:
+ var r := radius + float(ring) * 6.0
+ var color := Color(0.45 + float(ring) * 0.15, 0.55 + float(ring) * 0.1, 1.0, fade * 0.6)
+ for segment in 4:
+ var start := float(segment) * TAU / 4.0 + progress * (2.0 if ring % 2 == 0 else -2.0)
+ draw_arc(Vector2.ZERO, r, start, start + 1.05, 24, color, 1.5, true)
+ for shard in _shards:
+ var angle := float(shard["angle"]) + progress * 1.2
+ var distance := float(shard["distance"]) * (1.0 - progress if arriving else progress)
+ var point := Vector2.RIGHT.rotated(angle) * distance
+ draw_line(point, point + Vector2.RIGHT.rotated(angle + 0.7) * 6.0, Color(0.7, 0.85, 1.0, fade), float(shard["size"]), true)
+ if ship_texture != null:
+ draw_set_transform(Vector2.ZERO, ship_rotation, Vector2.ONE * (1.0 + progress * 0.6))
+ var dimensions := ship_texture.get_size()
+ dimensions *= 64.0 / maxf(dimensions.x, dimensions.y)
+ draw_texture_rect(ship_texture, Rect2(-dimensions * 0.5, dimensions), false, Color(0.5, 0.7, 1.0, fade * 0.45))
+ draw_set_transform(Vector2.ZERO)
diff --git a/scripts/game/teleport_vortex.gd.uid b/scripts/game/teleport_vortex.gd.uid
new file mode 100644
index 0000000..505b9be
--- /dev/null
+++ b/scripts/game/teleport_vortex.gd.uid
@@ -0,0 +1 @@
+uid://dvprav6rgv8h4
diff --git a/scripts/game/weapon_projectile.gd b/scripts/game/weapon_projectile.gd
index 05eb538..13af061 100644
--- a/scripts/game/weapon_projectile.gd
+++ b/scripts/game/weapon_projectile.gd
@@ -253,6 +253,9 @@ func _hit_target(target: Node) -> void:
_impact_handled = true
var is_explosion := weapon_kind == ItemDefinition.WeaponKind.MISSILE
_spawn_impact_effect(is_explosion)
+ if enemy_shot and target.is_in_group("asteroids"):
+ queue_free()
+ return
if weapon_kind == ItemDefinition.WeaponKind.MISSILE:
_explode_at_target(target)
else:
@@ -283,6 +286,8 @@ func _explode_at_target(impact_target: Node) -> void:
func _apply_damage_once(target: Variant) -> void:
if not (target is Node):
return
+ if enemy_shot and target.is_in_group("asteroids"):
+ return
var instance_id: int = target.get_instance_id()
if _already_hit.has(instance_id):
return
diff --git a/scripts/items/item_generator.gd b/scripts/items/item_generator.gd
index e42305f..6c636d8 100644
--- a/scripts/items/item_generator.gd
+++ b/scripts/items/item_generator.gd
@@ -1,6 +1,12 @@
extends RefCounted
const DEFINITIONS := [
+ preload("res://resources/items/teleport.tres"),
+ preload("res://resources/items/cloak.tres"),
+ preload("res://resources/items/mine_layer.tres"),
+ preload("res://resources/items/flares.tres"),
+ preload("res://resources/items/solar_shield_charge.tres"),
+
preload("res://resources/items/gamma_cannon.tres"),
preload("res://resources/items/plasma_cannon.tres"),
preload("res://resources/items/beam_laser.tres"),
@@ -22,6 +28,10 @@ const AFFIX_DATABASE := preload("res://resources/items/affix_database.tres")
static func generate(level: int, enemy_tier: int = 0) -> ItemInstance:
return generate_for_definition(DEFINITIONS.pick_random(), level, enemy_tier)
+static func generate_loot(level: int, enemy_tier: int = 0, definition: ItemDefinition = null, equipment_luck_percent: float = 0.0) -> ItemInstance:
+ var loot_level := GameState.captain.roll_loot_level(level, -1.0, equipment_luck_percent)
+ return generate_for_definition(definition if definition != null else DEFINITIONS.pick_random(), loot_level, enemy_tier)
+
static func generate_for_definition(definition: ItemDefinition, level: int, enemy_tier: int = 0) -> ItemInstance:
var item := ItemInstance.new()
item.definition = definition
diff --git a/scripts/items/item_instance.gd b/scripts/items/item_instance.gd
index cc60a24..5d0be15 100644
--- a/scripts/items/item_instance.gd
+++ b/scripts/items/item_instance.gd
@@ -135,4 +135,4 @@ func get_tooltip_text() -> String:
return "\n".join(lines)
static func stat_name(key: String) -> String:
- return str({"kinetic_absorption": "Absorpcja kinetyczna", "energy_absorption": "Absorpcja energetyczna", "radiation_absorption": "Absorpcja radiacyjna", "missile_cooldown_reduction": "Skrócenie cooldownu rakiet", "scanner_range": "Zasięg skanera", "shield_on_damage_percent": "Odzysk tarcz z obrażeń", "damage": "Obrażenia", "max_hull": "Kadłub", "max_shields": "Tarcze", "max_energy": "Energia", "max_speed": "Prędkość", "energy_regeneration": "Regeneracja energii", "shield_regeneration": "Regeneracja tarcz", "hull_percent": "Kadłub", "shield_percent": "Tarcze", "damage_percent": "Obrażenia broni", "speed_percent": "Prędkość statku", "experience_percent": "Doświadczenie (EXP)", "credits_percent": "Kredyty", "critical_chance_percent": "Szansa na trafienie krytyczne", "critical_damage_percent": "Obrażenia krytyczne"}.get(key, key))
+ return str({"luck_percent": "Szczęście", "kinetic_absorption": "Absorpcja kinetyczna", "energy_absorption": "Absorpcja energetyczna", "radiation_absorption": "Absorpcja radiacyjna", "missile_cooldown_reduction": "Skrócenie cooldownu rakiet", "scanner_range": "Zasięg skanera", "shield_on_damage_percent": "Odzysk tarcz z obrażeń", "damage": "Obrażenia", "max_hull": "Kadłub", "max_shields": "Tarcze", "max_energy": "Energia", "max_speed": "Prędkość", "energy_regeneration": "Regeneracja energii", "shield_regeneration": "Regeneracja tarcz", "hull_percent": "Kadłub", "shield_percent": "Tarcze", "damage_percent": "Obrażenia broni", "speed_percent": "Prędkość statku", "experience_percent": "Doświadczenie (EXP)", "credits_percent": "Kredyty", "critical_chance_percent": "Szansa na trafienie krytyczne", "critical_damage_percent": "Obrażenia krytyczne"}.get(key, key))
diff --git a/scripts/items/ship_inventory.gd b/scripts/items/ship_inventory.gd
index 3badeca..9cb2356 100644
--- a/scripts/items/ship_inventory.gd
+++ b/scripts/items/ship_inventory.gd
@@ -19,6 +19,7 @@ const AMMUNITION_CRAFT_AMOUNT := 10
const AMMUNITION_BUY_AMOUNT := 100
const AMMUNITION_BUY_BASE_COST := 20
+var extension_cooldowns: Dictionary = {}
var energy_banks := 0
var irradiated_ammunition := 0
@@ -287,7 +288,7 @@ func _recalculate_ship_stats() -> void:
for item in weapon_slots + module_slots:
if item == null or item.definition == null:
continue
- for stat_key in ["kinetic_absorption", "energy_absorption", "radiation_absorption", "shield_on_damage_percent", "scanner_range", "max_hull", "max_shields", "max_energy", "max_speed", "energy_regeneration", "shield_regeneration", "hull_percent", "shield_percent", "damage_percent", "speed_percent", "experience_percent", "credits_percent", "critical_chance_percent", "critical_damage_percent"]:
+ for stat_key in ["kinetic_absorption", "energy_absorption", "radiation_absorption", "shield_on_damage_percent", "scanner_range", "max_hull", "max_shields", "max_energy", "max_speed", "energy_regeneration", "shield_regeneration", "hull_percent", "shield_percent", "damage_percent", "speed_percent", "experience_percent", "credits_percent", "luck_percent", "critical_chance_percent", "critical_damage_percent"]:
bonuses[stat_key] = float(bonuses.get(stat_key, 0.0)) + item.get_stat_total(stat_key)
ship.call("apply_equipment_bonuses", bonuses)
@@ -344,7 +345,7 @@ func _comparison_bonuses(incoming: ItemInstance = null, outgoing: ItemInstance =
return bonuses
func _add_comparison_bonuses(bonuses: Dictionary, item: ItemInstance) -> void:
- for key in ["kinetic_absorption", "energy_absorption", "radiation_absorption", "max_hull", "max_shields", "max_energy", "max_speed", "scanner_range", "hull_percent", "shield_percent", "speed_percent", "damage_percent", "critical_damage_percent", "critical_chance_percent", "shield_on_damage_percent", "energy_regeneration", "shield_regeneration", "missile_cooldown_reduction", "experience_percent", "credits_percent"]:
+ for key in ["kinetic_absorption", "energy_absorption", "radiation_absorption", "max_hull", "max_shields", "max_energy", "max_speed", "scanner_range", "hull_percent", "shield_percent", "speed_percent", "damage_percent", "critical_damage_percent", "critical_chance_percent", "shield_on_damage_percent", "energy_regeneration", "shield_regeneration", "missile_cooldown_reduction", "experience_percent", "credits_percent", "luck_percent"]:
bonuses[key] = float(bonuses.get(key, 0.0)) + item.get_stat_total(key)
func _comparison_stats(bonuses: Dictionary) -> Dictionary:
@@ -357,6 +358,7 @@ func _comparison_stats(bonuses: Dictionary) -> Dictionary:
# These stats are bonuses in their own right, not rewards or hit scenarios.
for data in [["kinetic_absorption", "Absorpcja kinetyczna"], ["energy_absorption", "Absorpcja energetyczna"], ["radiation_absorption", "Absorpcja radiacyjna"], ["damage_percent", "Obrażenia broni"], ["critical_damage_percent", "Obrażenia krytyczne"], ["critical_chance_percent", "Szansa na krytyk"], ["shield_on_damage_percent", "Odzysk tarcz z obrażeń"], ["experience_percent", "Doświadczenie"], ["credits_percent", "Kredyty"]]:
stats[data[0]] = {"value": float(bonuses.get(data[0], 0.0)), "name": data[1], "unit": "%"}
+ stats["luck_percent"] = {"value": GameState.captain.get_luck_chance(float(bonuses.get("luck_percent", 0.0))) * 100.0, "name": "Szczęście (limit 60%)", "unit": "%"}
stats["missile_cooldown_reduction"] = {"value": float(bonuses.get("missile_cooldown_reduction", 0.0)), "name": "Skrócenie cooldownu rakiet", "unit": "s"}
var absorption_values := DamageTypes.capped(Vector3(float(bonuses.get("kinetic_absorption", 0.0)), float(bonuses.get("energy_absorption", 0.0)), float(bonuses.get("radiation_absorption", 0.0))))
for index in 3:
diff --git a/scripts/levels/exploration_generator.gd b/scripts/levels/exploration_generator.gd
index 7e3d035..0e71308 100644
--- a/scripts/levels/exploration_generator.gd
+++ b/scripts/levels/exploration_generator.gd
@@ -3,6 +3,10 @@ extends RefCounted
const POI := preload("res://scripts/levels/exploration_poi.gd")
static func spawn(game: Node2D, data: Dictionary, player: CharacterBody2D) -> void:
+ # Snapshot once per system. Learning a rank later must not add wrecks on reload.
+ if not GameState.exploration_wreck_bonuses.has(GameState.system_seed):
+ GameState.exploration_wreck_bonuses[GameState.system_seed] = roundi(GameState.captain.get_skill_bonus("explorer"))
+ var wreck_bonus := int(GameState.exploration_wreck_bonuses[GameState.system_seed])
var rng := RandomNumberGenerator.new()
rng.seed = GameState.system_seed ^ 0x504f49
var occupied: Array[Vector2] = []
@@ -39,6 +43,26 @@ static func spawn(game: Node2D, data: Dictionary, player: CharacterBody2D) -> vo
var planet := game.get_node("CelestialBodies/Planet%d" % (index + 1))
_add(game, player, "ruin", planet.position, 100, rng.randi(), float(planet.get("radius")) + 100.0)
+ # Separate stream and IDs preserve the base POIs, asteroid count and ruin chance.
+ var bonus_rng := RandomNumberGenerator.new()
+ bonus_rng.seed = GameState.system_seed ^ 0x4558504c
+ for index in wreck_bonus:
+ for attempt in 1000:
+ var candidate := Vector2(bonus_rng.randf_range(-2300, 2300), bonus_rng.randf_range(-1500, 1500))
+ if candidate.length() < 650 or candidate.distance_to(player.position) < 450:
+ continue
+ var clear := true
+ for planet in data["planets"]:
+ if candidate.distance_to(planet["position"]) < float(planet["radius"]) + 220:
+ clear = false
+ for point in occupied:
+ if candidate.distance_to(point) < 180:
+ clear = false
+ if clear:
+ occupied.append(candidate)
+ _add(game, player, "wreck", candidate, 1000 + index, bonus_rng.randi())
+ break
+
static func _add(game: Node2D, player: CharacterBody2D, kind: String, position: Vector2, index: int, reward_seed: int, approach_radius: float = 115.0) -> void:
var id := "%d:%d" % [GameState.system_seed, index]
if GameState.explored_pois.has(id):
diff --git a/scripts/levels/exploration_poi.gd b/scripts/levels/exploration_poi.gd
index c0c5c1e..df650f2 100644
--- a/scripts/levels/exploration_poi.gd
+++ b/scripts/levels/exploration_poi.gd
@@ -1,7 +1,13 @@
extends StaticBody2D
const GENERATOR := preload("res://scripts/items/item_generator.gd")
-const ASTEROID_TEXTURES := [preload("res://assets/asteroids/asteroid_rock.png"), preload("res://assets/asteroids/asteroid_metal.png"), preload("res://assets/asteroids/asteroid_ice.png")]
+const ASTEROID_TEXTURES := {
+ "iron": preload("res://assets/icons/resources/iron.png"),
+ "titanium": preload("res://assets/icons/resources/titanium.png"),
+ "uranium": preload("res://assets/icons/resources/uranium.png"),
+ "aluminium": preload("res://assets/icons/resources/aluminium.png"),
+ "sulfur": preload("res://assets/icons/resources/sulfur.png"),
+}
const WRECK_TEXTURES := [preload("res://assets/wrecks/wreck_frigate.png"), preload("res://assets/wrecks/wreck_freighter.png"), preload("res://assets/wrecks/wreck_interceptor.png")]
const LOOT := preload("res://scripts/items/loot_pickup.gd")
const ASTEROID_DESTRUCTION := preload("res://scripts/game/asteroid_destruction_effect.gd")
@@ -25,9 +31,10 @@ func _ready() -> void:
_rng.seed = reward_seed
_mineral = ["iron", "aluminium", "titanium", "uranium", "sulfur"][_rng.randi_range(0, 4)]
hull = 20.0 * reward_scale()
- collision_layer = 4 if kind == "asteroid" else 0
+ collision_layer = (4 | 32) if kind == "asteroid" else 0
collision_mask = 0
if kind == "asteroid":
+ add_to_group("asteroids")
var shape := CollisionShape2D.new()
var circle := CircleShape2D.new()
circle.radius = 30
@@ -37,7 +44,7 @@ func _ready() -> void:
_points.append(Vector2.RIGHT.rotated(TAU * index / 10) * _rng.randf_range(23, 35))
_asteroid_sprite = Sprite2D.new()
_asteroid_sprite.name = "AsteroidSprite"
- _asteroid_sprite.texture = ASTEROID_TEXTURES[posmod(reward_seed, ASTEROID_TEXTURES.size())]
+ _asteroid_sprite.texture = ASTEROID_TEXTURES[_mineral]
var texture_size := _asteroid_sprite.texture.get_size()
_asteroid_sprite.scale = Vector2.ONE * (78.0 / maxf(texture_size.x, texture_size.y))
_asteroid_sprite.rotation = deg_to_rad(posmod(reward_seed, 360))
@@ -80,7 +87,8 @@ func _update_label(near: bool) -> void:
if kind == "ruin":
_label.text = "?"
elif kind == "asteroid":
- _label.text = "Asteroida · %s\nHP: %.0f" % [{"iron": "Żelazo", "aluminium": "Aluminium", "titanium": "Tytan", "uranium": "Uran", "sulfur": "Siarka"}[_mineral], hull]
+ _label.text = ""
+ _label.hide()
else:
_label.text = ("Wrak statku" if kind == "wreck" else "Antyczne znalezisko") + ("\n[E] Przeszukaj" if near else "\nPodleć, aby przeszukać")
@@ -160,7 +168,7 @@ func claim() -> bool:
func _show_discovery(reward: String) -> void:
var layer := CanvasLayer.new()
- layer.layer = 30
+ layer.layer = preload("res://scripts/ui/render_layers.gd").DISCOVERY
layer.name = "AncientDiscoveryMessage"
get_parent().add_child(layer)
var label := Label.new()
@@ -193,7 +201,7 @@ func _drop_module(ancient: bool) -> ItemInstance:
for definition in GENERATOR.DEFINITIONS:
if definition.category != ItemDefinition.Category.WEAPON:
definitions.append(definition)
- var item := GENERATOR.generate_for_definition(definitions[_rng.randi_range(0, definitions.size() - 1)], mini(1000, GameState.system_level + (1 if ancient else 0)), 2 if ancient else 0)
+ var item := GENERATOR.generate_loot(mini(1000, GameState.system_level + (1 if ancient else 0)), 2 if ancient else 0, definitions[_rng.randi_range(0, definitions.size() - 1)], float(player.get("luck_percent")))
if ancient:
for modifier in item.additional_modifiers:
var bounds := modifier.affix.get_value_range(item.item_level)
diff --git a/scripts/ui/captain_panel.gd b/scripts/ui/captain_panel.gd
index 29dcb3f..1ba3573 100644
--- a/scripts/ui/captain_panel.gd
+++ b/scripts/ui/captain_panel.gd
@@ -14,6 +14,10 @@ func _ready() -> void:
_captain.normalize_skill_ranks()
_captain.changed.connect(_refresh)
player.get_node("ShipInventory").connect("inventory_changed", _refresh)
+ for label in [level_label, xp_label, credits_label]:
+ label.add_theme_color_override("font_shadow_color", Color("030811"))
+ label.add_theme_constant_override("shadow_offset_x", 1)
+ label.add_theme_constant_override("shadow_offset_y", 1)
_refresh()
func _refresh() -> void:
@@ -24,17 +28,17 @@ func _refresh() -> void:
credits_label.text = "Kredyty: %d" % _captain.credits
func _build_portrait() -> void:
- custom_minimum_size = Vector2(320, 0)
+ custom_minimum_size = Vector2(244, 0)
var style := StyleBoxFlat.new()
- style.bg_color = Color(0.025, 0.042, 0.064, 0.96)
- style.border_color = Color("376779")
+ style.bg_color = Color(0.025, 0.042, 0.064, 0.0)
+ style.border_color = Color("37677900")
style.set_border_width_all(1)
style.set_corner_radius_all(8)
- style.set_content_margin_all(10)
+ style.set_content_margin_all(6)
add_theme_stylebox_override("panel", style)
var row := HBoxContainer.new()
row.name = "PortraitRow"
- row.add_theme_constant_override("separation", 10)
+ row.add_theme_constant_override("separation", 8)
$Contents.add_child(row)
$Contents.move_child(row, 1)
var frame := PanelContainer.new()
@@ -42,11 +46,12 @@ func _build_portrait() -> void:
var portrait_style := style.duplicate() as StyleBoxFlat
portrait_style.set_content_margin_all(2)
portrait_style.border_color = Color("daa85d")
+ portrait_style.bg_color = Color("0b1520")
frame.add_theme_stylebox_override("panel", portrait_style)
row.add_child(frame)
var portrait := TextureRect.new()
portrait.name = "CaptainAvatar"
- portrait.custom_minimum_size = Vector2(76, 76)
+ portrait.custom_minimum_size = Vector2(54, 54)
portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE
@@ -59,7 +64,7 @@ func _build_portrait() -> void:
row.add_child(info)
var designation := Label.new()
designation.text = "DOWÓDCA STATKU"
- designation.add_theme_font_size_override("font_size", 11)
+ designation.add_theme_font_size_override("font_size", 9)
designation.add_theme_color_override("font_color", Color("91dbea"))
info.add_child(designation)
xp_label.reparent(info, false)
diff --git a/scripts/ui/captain_skills_ui.gd b/scripts/ui/captain_skills_ui.gd
index 10180e2..253d852 100644
--- a/scripts/ui/captain_skills_ui.gd
+++ b/scripts/ui/captain_skills_ui.gd
@@ -15,7 +15,7 @@ var _refresh_pending := false
func _ready() -> void:
name = "CaptainSkills"
- layer = 40
+ layer = preload("res://scripts/ui/render_layers.gd").MANAGEMENT
process_mode = Node.PROCESS_MODE_ALWAYS
_captain = GameState.captain
_captain.normalize_equipped_skills()
@@ -39,7 +39,7 @@ func _ready() -> void:
_bar = HBoxContainer.new()
_bar.name = "CaptainActiveSkills"
_bar.add_theme_constant_override("separation", 6)
- add_child(_bar)
+ get_parent().get_node("HUDRoot").add_child(_bar)
_captain.changed.connect(_queue_refresh)
get_viewport().size_changed.connect(_layout)
_queue_refresh()
@@ -307,6 +307,8 @@ func _process(_delta: float) -> void:
_bar.visible = not bool(player.get("_is_destroyed")) and not _screen.visible
func _layout() -> void:
+ if not is_inside_tree():
+ return
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))
diff --git a/scripts/ui/defeat_overlay.gd b/scripts/ui/defeat_overlay.gd
index 994d31c..7277874 100644
--- a/scripts/ui/defeat_overlay.gd
+++ b/scripts/ui/defeat_overlay.gd
@@ -4,7 +4,7 @@ var ship: CharacterBody2D
var _retrying := false
func _ready() -> void:
- layer = 40
+ layer = preload("res://scripts/ui/render_layers.gd").DEFEAT
name = "DefeatOverlay"
var root := Control.new()
add_child(root)
diff --git a/scripts/ui/flight_cockpit.gd b/scripts/ui/flight_cockpit.gd
index 458568a..db2bafa 100644
--- a/scripts/ui/flight_cockpit.gd
+++ b/scripts/ui/flight_cockpit.gd
@@ -35,13 +35,14 @@ func _draw() -> void:
if _inventory == null or not is_instance_valid(player) or size.x < 50 or size.y < 50:
return
var outline := PackedVector2Array([Vector2(24, 0), Vector2(size.x - 24, 0), Vector2(size.x, 24), Vector2(size.x, size.y - 12), Vector2(size.x - 12, size.y), Vector2(12, size.y), Vector2(0, size.y - 12), Vector2(0, 24)])
- draw_colored_polygon(outline, Color(0.025, 0.041, 0.063, 0.96))
+ draw_colored_polygon(outline, Color(0.025, 0.041, 0.063, 1.0))
var closed := outline.duplicate()
closed.append(outline[0])
draw_polyline(closed, Color("376779"), 1.5, true)
draw_line(Vector2(28, 2), Vector2(size.x - 28, 2), CYAN, 2, true)
# Put weapon cards beside the counters rather than stacking three rows.
_text("DPS: %.1f" % _inventory.get_total_dps(), Vector2(20, 81), 172, 12, CYAN)
+ _text("PRĘDKOŚĆ: %.0f" % player.velocity.length(), Vector2(20, 101), 172, 11, Color("c4d6df"))
var absorption: Vector3 = player.get("absorption")
for index in 3:
_text("%s - %.1f%%" % [DamageTypes.NAMES[index], absorption[index]], Vector2(205, 77 + index * 15), 2.0 * (size.x - 238) / 3.0, 11, Color("a9d69a"))
diff --git a/scripts/ui/navigation_button.gd b/scripts/ui/navigation_button.gd
new file mode 100644
index 0000000..48b5cbe
--- /dev/null
+++ b/scripts/ui/navigation_button.gd
@@ -0,0 +1,29 @@
+extends "res://scripts/ui/captain_skill_button.gd"
+
+func configure_navigation(artwork: Texture2D, accent: Color, caption: String) -> void:
+ configure(artwork, accent)
+ custom_minimum_size = Vector2(64, 80)
+ for state in ["normal", "hover", "pressed"]:
+ var frame := get_theme_stylebox(state).duplicate() as StyleBoxFlat
+ frame.set_corner_radius_all(9)
+ frame.border_color = accent if state == "hover" else accent.darkened(0.45)
+ frame.shadow_color = Color(accent, 0.12 if state == "hover" else 0.04)
+ frame.shadow_size = 5 if state == "hover" else 3
+ add_theme_stylebox_override(state, frame)
+ var picture := get_node_or_null("RoundedArtwork") as TextureRect
+ if picture != null:
+ picture.offset_left = 5
+ picture.offset_right = -5
+ picture.offset_top = 5
+ picture.offset_bottom = -21
+ var label := Label.new()
+ label.text = caption
+ label.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+ label.add_theme_font_size_override("font_size", 9)
+ label.add_theme_color_override("font_color", accent.lightened(0.25))
+ add_child(label)
+ label.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_WIDE)
+ label.offset_top = -21
+ label.offset_bottom = -2
diff --git a/scripts/ui/navigation_button.gd.uid b/scripts/ui/navigation_button.gd.uid
new file mode 100644
index 0000000..268a0b3
--- /dev/null
+++ b/scripts/ui/navigation_button.gd.uid
@@ -0,0 +1 @@
+uid://bq0dmkp34jo8g
diff --git a/scripts/ui/render_layers.gd b/scripts/ui/render_layers.gd
new file mode 100644
index 0000000..8ea1dfc
--- /dev/null
+++ b/scripts/ui/render_layers.gd
@@ -0,0 +1,9 @@
+extends RefCounted
+
+# Canvas layers are ordered independently of world-space z_index values.
+const SCANNER := 8
+const DISCOVERY := 10
+const HUD := 20
+const MANAGEMENT := 40
+const DEFEAT := 50
+const WINDOW_Z := 100
diff --git a/scripts/ui/render_layers.gd.uid b/scripts/ui/render_layers.gd.uid
new file mode 100644
index 0000000..f864c7c
--- /dev/null
+++ b/scripts/ui/render_layers.gd.uid
@@ -0,0 +1 @@
+uid://cc5diriqmv08s
diff --git a/tests/abilities/hud_cooldown_reset_test.gd b/tests/abilities/hud_cooldown_reset_test.gd
new file mode 100644
index 0000000..e046e47
--- /dev/null
+++ b/tests/abilities/hud_cooldown_reset_test.gd
@@ -0,0 +1,61 @@
+extends Node
+
+const GAME := preload("res://scenes/game/game.tscn")
+
+func _ready() -> void:
+ GameState.start_new_run()
+ GameState.captain.skills["reactor_overload"] = 1
+ GameState.captain.equipped_skills[0] = "reactor_overload"
+ GameState.captain.skill_cooldowns["reactor_overload"] = 25.0
+ GameState.captain.skill_durations["reactor_overload"] = 3.0
+ var game := GAME.instantiate() as Node2D
+ add_child(game)
+ await get_tree().process_frame
+ 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)
+ for enemy in get_tree().get_nodes_in_group("enemies"):
+ enemy.set_physics_process(false)
+ assert(GameState.captain.skill_cooldowns.is_empty())
+ assert(GameState.captain.skill_durations.is_empty())
+ var hud := game.get_node("HUD")
+ var captain := hud.get_node("HUDRoot/CaptainPanel") as PanelContainer
+ assert(captain.size.x <= 250)
+ assert(captain.get_theme_stylebox("panel").bg_color.a == 0.0)
+ assert(captain.get_node("Contents/PortraitRow/AvatarFrame/CaptainAvatar").custom_minimum_size == Vector2(54, 54))
+ assert(hud.get_node("HUDRoot/TopNavigation").get_child_count() == 2)
+ assert(hud.get_node("HUDRoot/TopNavigation").get_child(0).get_node("RoundedArtwork").texture != null)
+ var inventory := ship.get_node("ShipInventory") as ShipInventory
+ var item := ItemInstance.new()
+ item.definition = preload("res://resources/items/cloak.tres")
+ assert(inventory.add_item(item))
+ assert(inventory.move_item("cargo", inventory.cargo.find(item), "extension", 0))
+ assert(ship.call("activate_ability", 0))
+ assert(ship.call("is_cloaked"))
+ assert(GameState.captain.activate_skill("reactor_overload", ship))
+ assert(ship.call("get_ability_cooldown", 0) > 0.0)
+ assert(not GameState.captain.skill_cooldowns.is_empty())
+ ship.call("take_damage", 100000.0)
+ assert(ship.get("_is_destroyed"))
+ assert(not ship.call("is_cloaked"))
+ assert(ship.call("get_ability_cooldown", 0) == 0.0)
+ assert(GameState.captain.skill_cooldowns.is_empty())
+ assert(GameState.captain.skill_durations.is_empty())
+ inventory.preserve_for_restart()
+ game.free()
+ GameState.system_level += 1
+ GameState.system_seed = GameState.create_system_seed()
+ GameState.captain.skill_cooldowns["reactor_overload"] = 12.0
+ GameState.captain.skill_durations["reactor_overload"] = 2.0
+ var next_game := GAME.instantiate() as Node2D
+ add_child(next_game)
+ await get_tree().process_frame
+ var next_ship := next_game.get_node("PlayerShip")
+ assert(next_ship.call("get_ability_cooldown", 0) == 0.0)
+ assert(GameState.captain.skill_cooldowns.is_empty())
+ assert(GameState.captain.skill_durations.is_empty())
+ assert(GameState.captain.get_skill_rank("reactor_overload") == 1)
+ assert(next_ship.get_node("ShipInventory").extension_slots[0].definition == item.definition)
+ print("HUD_COOLDOWN_RESET_TEST: PASS (death/new board reset captain and extension cooldowns/effects, equipment and learned skills retained, compact transparent captain, new artwork)")
+ get_tree().quit()
diff --git a/tests/abilities/hud_cooldown_reset_test.gd.uid b/tests/abilities/hud_cooldown_reset_test.gd.uid
new file mode 100644
index 0000000..aa0f7f1
--- /dev/null
+++ b/tests/abilities/hud_cooldown_reset_test.gd.uid
@@ -0,0 +1 @@
+uid://b1jbnovika6bd
diff --git a/tests/abilities/hud_cooldown_reset_test.tscn b/tests/abilities/hud_cooldown_reset_test.tscn
new file mode 100644
index 0000000..77ead5b
--- /dev/null
+++ b/tests/abilities/hud_cooldown_reset_test.tscn
@@ -0,0 +1,4 @@
+[gd_scene load_steps=2 format=3]
+[ext_resource type="Script" path="res://tests/abilities/hud_cooldown_reset_test.gd" id="1"]
+[node name="HudCooldownResetTest" type="Node"]
+script = ExtResource("1")
diff --git a/tests/abilities/tactical_extensions_test.gd b/tests/abilities/tactical_extensions_test.gd
new file mode 100644
index 0000000..eec434a
--- /dev/null
+++ b/tests/abilities/tactical_extensions_test.gd
@@ -0,0 +1,140 @@
+extends Node2D
+
+const PLAYER := preload("res://scenes/game/player_ship.tscn")
+const ENEMY := preload("res://scenes/game/enemy_ship.tscn")
+const GENERATOR := preload("res://scripts/items/item_generator.gd")
+const IDS := ["teleport", "cloak", "mine_layer", "flares", "solar_shield_charge"]
+var ship: CharacterBody2D
+var inventory: ShipInventory
+
+func equip(id: String, slot: int) -> void:
+ var item := GENERATOR.generate_for_definition(load("res://resources/items/%s.tres" % id), 1)
+ assert(item.additional_modifiers.is_empty())
+ assert(item.definition.icon != null)
+ assert(inventory.add_item(item))
+ assert(inventory.move_item("cargo", inventory.cargo.find(item), "extension", slot))
+
+func _ready() -> void:
+ GameState.start_new_run()
+ ship = PLAYER.instantiate() as CharacterBody2D
+ ship.name = "PlayerShip"
+ add_child(ship)
+ ship.set_physics_process(false)
+ ship.get_node("WeaponController").set_physics_process(false)
+ var systems := ship.get_node("ExtensionSystems")
+ systems.set_physics_process(false)
+ inventory = ship.get_node("ShipInventory") as ShipInventory
+ for index in IDS.size():
+ equip(IDS[index], index)
+ var enemy := ENEMY.instantiate() as CharacterBody2D
+ enemy.position = Vector2(300, 0)
+ add_child(enemy)
+ enemy.set("_player", ship)
+ enemy.set_physics_process(false)
+ await get_tree().physics_frame
+ await get_tree().process_frame
+ # Teleport: partial energy is fully consumed; landing stays inside the playable area.
+ var origin := ship.position
+ ship.set("energy", 37.0)
+ assert(ship.call("activate_ability", 0))
+ assert(ship.position.distance_to(origin) >= 400.0)
+ assert(absf(ship.position.x) <= 2440 and absf(ship.position.y) <= 1640)
+ assert(ship.get("energy") == 0.0)
+ assert(ship.call("get_ability_cooldown", 0) == 60.0)
+ assert(not ship.call("activate_ability", 0))
+ # Moving a module or equipping a duplicate cannot bypass its cooldown.
+ assert(inventory.move_item("extension", 0, "extension", 1))
+ assert(ship.call("get_ability_cooldown", 1) == 60.0)
+ equip("teleport", 4)
+ assert(ship.call("get_ability_cooldown", 4) == 60.0)
+ assert(not ship.call("activate_ability", 4))
+ equip("solar_shield_charge", 4)
+ assert(inventory.move_item("extension", 1, "extension", 0))
+ ship.position = origin
+ enemy.position = origin + Vector2(300, 0)
+ assert(enemy.call("select_combat_target") == ship)
+ # Cloak removes targeting, lasts ten seconds and restores the original artwork.
+ var original_material: Material = ship.get_node("Sprite2D").material
+ assert(ship.call("activate_ability", 1))
+ assert(ship.call("is_cloaked"))
+ assert(enemy.call("select_combat_target") == null)
+ assert(ship.get_node("Sprite2D").material != original_material)
+ assert(not ship.call("activate_ability", 1))
+ # Flares attract enemies even while the player is invisible, and expire in ten seconds.
+ assert(ship.call("activate_ability", 3))
+ assert(ship.call("get_ability_cooldown", 3) == 30.0)
+ var flares := get_tree().get_nodes_in_group("player_flares")
+ assert(flares.size() == 3)
+ assert(flares.has(enemy.call("select_combat_target")))
+ assert(not ship.call("activate_ability", 3))
+ for flare in flares:
+ flare.set_physics_process(false)
+ flare.call("_physics_process", 10.0)
+ assert(flare.is_queued_for_deletion())
+ assert(enemy.call("select_combat_target") == null)
+ systems.call("_physics_process", 9.9)
+ assert(ship.call("is_cloaked"))
+ systems.call("_physics_process", 0.11)
+ assert(not ship.call("is_cloaked"))
+ assert(ship.get_node("Sprite2D").material == original_material)
+ assert(enemy.call("select_combat_target") == ship)
+ assert(not ship.call("activate_ability", 1))
+ # Mine: 5% maximum hull damage despite armor; slow lasts five seconds; one hit only.
+ assert(ship.call("activate_ability", 2))
+ var mine: Node2D = get_tree().get_nodes_in_group("player_mines")[0]
+ mine.set_physics_process(false)
+ enemy.position = mine.position
+ enemy.set("shields", 0.0)
+ enemy.set("absorption", Vector3(75, 0, 0))
+ var maximum := float(enemy.get("max_hull"))
+ mine.call("_physics_process", 0.8)
+ assert(mine.is_queued_for_deletion())
+ assert(is_equal_approx(float(enemy.get("hull")), maximum * 0.95))
+ mine.call("_physics_process", 0.1)
+ assert(is_equal_approx(float(enemy.get("hull")), maximum * 0.95))
+ assert(enemy.call("get_slow_multiplier") == 0.5)
+ enemy.set("_player", null)
+ enemy.call("_physics_process", 5.1)
+ assert(enemy.call("get_slow_multiplier") == 1.0)
+ # Solar shields: blocked away from stars and at full shields; fills in five seconds.
+ ship.set("shields", 0.0)
+ assert(not ship.call("activate_ability", 4))
+ var star := Node2D.new()
+ star.position = ship.position
+ add_child(star)
+ ship.set("charging_star", star)
+ assert(ship.call("activate_ability", 4))
+ assert(ship.call("get_ability_cooldown", 4) == 30.0)
+ systems.call("_physics_process", 2.5)
+ assert(is_equal_approx(float(ship.get("shields")), float(ship.get("max_shields")) * 0.5))
+ systems.call("_physics_process", 2.5)
+ assert(is_equal_approx(float(ship.get("shields")), float(ship.get("max_shields"))))
+ systems.get("cooldowns").clear()
+ assert(not ship.call("activate_ability", 4))
+ ship.set("shields", 0.0)
+ assert(ship.call("activate_ability", 4))
+ ship.position += Vector2(1000, 0)
+ systems.call("_physics_process", 1.0)
+ assert(ship.get("shields") == 0.0 and systems.get("solar_remaining") == 0.0)
+ # Docking, defeat and pause prevent activation; expired cooldowns allow reuse.
+ systems.call("_physics_process", 61.0)
+ ship.set("is_docked", true)
+ assert(not ship.call("activate_ability", 3))
+ ship.set("is_docked", false)
+ get_tree().paused = true
+ assert(not ship.call("activate_ability", 3))
+ get_tree().paused = false
+ ship.set("_is_destroyed", true)
+ assert(not ship.call("activate_ability", 3))
+ ship.set("_is_destroyed", false)
+ assert(ship.call("activate_ability", 3))
+ inventory.preserve_for_restart()
+ assert(not GameState.restart_inventory.has("extension_cooldowns"))
+ var restored := PLAYER.instantiate() as CharacterBody2D
+ add_child(restored)
+ restored.set_physics_process(false)
+ restored.get_node("WeaponController").set_physics_process(false)
+ assert(restored.call("get_ability_cooldown", 3) == 0.0)
+ assert(restored.call("activate_ability", 3))
+ print("TACTICAL_EXTENSIONS_TEST: PASS (five modules, icons, loot, teleport safety/energy, cloak duration/AI/restoration, flares targeting/lifetime, mine damage/slow/single hit, solar recharge/range, shared cooldowns, pause/dock/defeat, cooldown reset on reload)")
+ get_tree().quit()
diff --git a/tests/abilities/tactical_extensions_test.gd.uid b/tests/abilities/tactical_extensions_test.gd.uid
new file mode 100644
index 0000000..b510aba
--- /dev/null
+++ b/tests/abilities/tactical_extensions_test.gd.uid
@@ -0,0 +1 @@
+uid://bd5nib3xihg7u
diff --git a/tests/abilities/tactical_extensions_test.tscn b/tests/abilities/tactical_extensions_test.tscn
new file mode 100644
index 0000000..2a3fc1b
--- /dev/null
+++ b/tests/abilities/tactical_extensions_test.tscn
@@ -0,0 +1,4 @@
+[gd_scene load_steps=2 format=3]
+[ext_resource type="Script" path="res://tests/abilities/tactical_extensions_test.gd" id="1"]
+[node name="TacticalExtensionsTest" type="Node2D"]
+script = ExtResource("1")
diff --git a/tests/captain/captain_skills_test.gd b/tests/captain/captain_skills_test.gd
index aa73ead..9ef0922 100644
--- a/tests/captain/captain_skills_test.gd
+++ b/tests/captain/captain_skills_test.gd
@@ -157,10 +157,10 @@ func _ready() -> void:
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)
+ assert(profile.skill_cooldowns.is_empty() and profile.skill_durations.is_empty())
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)")
+ print("CAPTAIN_SKILLS_TEST: PASS (eight skills, three ranks, five equipped slots, F5 input, slot swaps preserve cooldown, new boards reset cooldown, offers, pause, economy, ammo, mining, buffs, shield lock, new run)")
get_tree().quit()
func _test_offers() -> void:
diff --git a/tests/captain/lucky_explorer_test.gd b/tests/captain/lucky_explorer_test.gd
new file mode 100644
index 0000000..a8cedd0
--- /dev/null
+++ b/tests/captain/lucky_explorer_test.gd
@@ -0,0 +1,102 @@
+extends Node2D
+
+const CATALOG := preload("res://scripts/captain/captain_skills.gd")
+const GENERATOR := preload("res://scripts/items/item_generator.gd")
+const EXPLORATION := preload("res://scripts/levels/exploration_generator.gd")
+const PLAYER := preload("res://scenes/game/player_ship.tscn")
+const DEFINITION := preload("res://resources/items/plasma_cannon.tres")
+var ship: CharacterBody2D
+
+func snapshot() -> Dictionary:
+ var container := Node2D.new()
+ add_child(container)
+ EXPLORATION.spawn(container, {"planets": []}, ship)
+ var result := {}
+ for poi in container.get_children():
+ result[poi.get("poi_id")] = {"kind": poi.get("kind"), "position": poi.position, "reward_seed": poi.get("reward_seed")}
+ container.free()
+ return result
+
+func count_kind(data: Dictionary, kind: String) -> int:
+ var count := 0
+ for poi in data.values():
+ if poi["kind"] == kind:
+ count += 1
+ return count
+
+func _ready() -> void:
+ GameState.start_new_run()
+ ship = PLAYER.instantiate() as CharacterBody2D
+ ship.name = "PlayerShip"
+ add_child(ship)
+ ship.set_physics_process(false)
+ ship.get_node("WeaponController").set_physics_process(false)
+ assert(CATALOG.IDS.has("lucky") and CATALOG.IDS.has("explorer"))
+ for id in ["lucky", "explorer"]:
+ assert(not CATALOG.DATA[id]["active"])
+ assert(CATALOG.icon(id) != null)
+ assert(not CATALOG.description(id, 3).is_empty())
+ # Every roll threshold is exact, with 0% baseline and a hard level cap.
+ for rank in range(4):
+ GameState.captain.skills["lucky"] = rank
+ var chance: float = [0.0, 0.05, 0.1, 0.25][rank]
+ assert(is_equal_approx(GameState.captain.get_skill_bonus("lucky"), chance))
+ for roll in 1000:
+ assert(GameState.captain.roll_loot_level(10, float(roll) / 1000.0) == (11 if float(roll) / 1000.0 < chance else 10))
+ assert(GameState.captain.roll_loot_level(1000, 0.0) == 1000)
+ assert(GameState.captain.roll_loot_level(0, 0.99) == 1)
+ # Exercise actual drops, including stat scaling and the normal level generator.
+ seed(888)
+ var upgrades := 0
+ for index in 2000:
+ var item := GENERATOR.generate_loot(10, 0, DEFINITION)
+ assert(item.item_level in [10, 11])
+ if item.item_level == 11:
+ upgrades += 1
+ assert(upgrades == 0 if rank == 0 else absf(float(upgrades) / 2000.0 - chance) < 0.04)
+ assert(GENERATOR.generate_for_definition(DEFINITION, 10).item_level == 10)
+ # Both new skills participate in the normal level-up offer and training path.
+ for id in ["lucky", "explorer"]:
+ var captain := CaptainProgress.new()
+ for skill in CATALOG.IDS:
+ captain.skills[skill] = 3
+ captain.skills[id] = 2
+ captain.learning_points = 1
+ captain.prepare_skill_offers()
+ assert(captain.skill_offers == [id])
+ assert(captain.train_skill(id))
+ assert(captain.get_skill_rank(id) == 3)
+ # Each rank adds exactly 1/3/6 wrecks and leaves every base POI intact.
+ var baseline := {}
+ for rank in range(4):
+ GameState.start_new_run()
+ GameState.system_seed = 222
+ GameState.captain.skills["explorer"] = rank
+ var current := snapshot()
+ if rank == 0:
+ baseline = current
+ var bonus: int = [0, 1, 3, 6][rank]
+ assert(count_kind(current, "wreck") == count_kind(baseline, "wreck") + bonus)
+ assert(count_kind(current, "asteroid") == count_kind(baseline, "asteroid"))
+ for id in baseline:
+ assert(current[id] == baseline[id])
+ GameState.start_new_run()
+ GameState.system_seed = 222
+ var before := snapshot()
+ GameState.captain.skills["explorer"] = 3
+ GameState.captain.emit_changed()
+ assert(snapshot() == before) # Reloading the current board does not add wrecks.
+ GameState.system_seed = 333
+ var next_board := snapshot()
+ var rng := RandomNumberGenerator.new()
+ rng.seed = 333 ^ 0x504f49
+ assert(count_kind(next_board, "wreck") == rng.randi_range(2, 4) + 6)
+ GameState.explored_pois["333:1000"] = true
+ var reloaded := snapshot()
+ assert(not reloaded.has("333:1000"))
+ assert(reloaded.size() == next_board.size() - 1)
+ assert(GameState.exploration_wreck_bonuses[333] == 6)
+ GameState.start_new_run()
+ assert(GameState.exploration_wreck_bonuses.is_empty())
+ print("LUCKY_EXPLORER_TEST: PASS (0/5/10/25% luck, thresholds, 8000 drops, level cap/scaling, skill offers/training, +1/3/6 wrecks, stable base POIs, next-board application, reload/claimed-wreck persistence)")
+ get_tree().quit()
diff --git a/tests/captain/lucky_explorer_test.gd.uid b/tests/captain/lucky_explorer_test.gd.uid
new file mode 100644
index 0000000..edfe341
--- /dev/null
+++ b/tests/captain/lucky_explorer_test.gd.uid
@@ -0,0 +1 @@
+uid://63vawkp5glvs
diff --git a/tests/captain/lucky_explorer_test.tscn b/tests/captain/lucky_explorer_test.tscn
new file mode 100644
index 0000000..7b3eb1b
--- /dev/null
+++ b/tests/captain/lucky_explorer_test.tscn
@@ -0,0 +1,4 @@
+[gd_scene load_steps=2 format=3]
+[ext_resource type="Script" path="res://tests/captain/lucky_explorer_test.gd" id="1"]
+[node name="LuckyExplorerTest" type="Node2D"]
+script = ExtResource("1")
diff --git a/tests/items/affix_database_test.gd b/tests/items/affix_database_test.gd
index 5711e35..65d7d82 100644
--- a/tests/items/affix_database_test.gd
+++ b/tests/items/affix_database_test.gd
@@ -4,7 +4,7 @@ const GENERATOR := preload("res://scripts/items/item_generator.gd")
const DATABASE := preload("res://resources/items/affix_database.tres")
func _ready() -> void:
- assert(DATABASE.affixes.size() == 15)
+ assert(DATABASE.affixes.size() == 16)
for level in [1, 50, 1000]:
for tier in [0, 1, 2]:
for sample in 50:
diff --git a/tests/items/enemy_asteroid_cover_test.gd b/tests/items/enemy_asteroid_cover_test.gd
new file mode 100644
index 0000000..b714bad
--- /dev/null
+++ b/tests/items/enemy_asteroid_cover_test.gd
@@ -0,0 +1,81 @@
+extends Node2D
+
+const PLAYER := preload("res://scenes/game/player_ship.tscn")
+const ENEMY := preload("res://scenes/game/enemy_ship.tscn")
+const PROJECTILE := preload("res://scenes/game/weapon_projectile.tscn")
+const PROJECTILE_SCRIPT := preload("res://scripts/game/weapon_projectile.gd")
+
+class AccurateEnemy:
+ extends "res://scripts/game/enemy_ship.gd"
+ func _laser_aim_direction(direction: Vector2) -> Vector2:
+ return direction
+
+func _ready() -> void:
+ GameState.start_new_run()
+ var ship := PLAYER.instantiate() as CharacterBody2D
+ ship.name = "PlayerShip"
+ ship.position = Vector2(150, 0)
+ add_child(ship)
+ ship.set_physics_process(false)
+ ship.get_node("WeaponController").set_physics_process(false)
+ var enemy := ENEMY.instantiate() as CharacterBody2D
+ enemy.set_script(AccurateEnemy)
+ enemy.position = Vector2(-200, 0)
+ add_child(enemy)
+ enemy.set("_player", ship)
+ enemy.set_physics_process(false)
+ var asteroid := StaticBody2D.new()
+ asteroid.set_script(preload("res://scripts/levels/exploration_poi.gd"))
+ asteroid.set("kind", "asteroid")
+ asteroid.set("poi_id", "enemy-cover-test")
+ asteroid.set("player", ship)
+ add_child(asteroid)
+ asteroid.set_physics_process(false)
+ var asteroid_hp := float(asteroid.get("hull"))
+ var player_hp := float(ship.get("hull")) + float(ship.get("shields"))
+ var minerals := GameState.captain.minerals.duplicate()
+ await get_tree().physics_frame
+ await get_tree().process_frame
+ for kind in [ItemDefinition.WeaponKind.PLASMA, ItemDefinition.WeaponKind.MACHINE_GUN, ItemDefinition.WeaponKind.GAMMA, ItemDefinition.WeaponKind.MISSILE]:
+ enemy.set("weapon_kind", kind)
+ enemy.call("_fire_plasma", Vector2.RIGHT)
+ var shot: Area2D
+ for child in get_children():
+ if child is Area2D and child.get_script() == PROJECTILE_SCRIPT and not child.is_queued_for_deletion():
+ shot = child
+ assert(shot != null and (shot.collision_mask & 32) != 0)
+ shot.set_physics_process(false)
+ shot.set("damage", 10000.0)
+ shot.call("_physics_process", 1.0)
+ assert(shot.is_queued_for_deletion())
+ assert(shot.position.x < 0.0) # Stops at the asteroid even at high speed.
+ assert(float(asteroid.get("hull")) == asteroid_hp)
+ assert(not asteroid.get("_claimed"))
+ assert(float(ship.get("hull")) + float(ship.get("shields")) == player_hp)
+ assert(GameState.captain.minerals == minerals)
+ enemy.set("weapon_kind", ItemDefinition.WeaponKind.LASER)
+ enemy.call("_fire_plasma", Vector2.RIGHT)
+ var beam: Line2D
+ for child in get_children():
+ if child is Line2D:
+ beam = child
+ assert(beam != null and beam.get_point_position(1).x < 0.0)
+ assert(float(asteroid.get("hull")) == asteroid_hp)
+ assert(float(ship.get("hull")) + float(ship.get("shields")) == player_hp)
+ var legacy := preload("res://scenes/game/enemy_plasma_projectile.tscn").instantiate() as Area2D
+ legacy.call("configure", Vector2(-100, 0), Vector2.RIGHT, 10000.0)
+ add_child(legacy)
+ legacy.set_physics_process(false)
+ legacy.call("_physics_process", 1.0)
+ assert(legacy.is_queued_for_deletion())
+ assert(float(asteroid.get("hull")) == asteroid_hp)
+ # Player mining remains functional, using the existing target layer.
+ var player_shot := PROJECTILE.instantiate() as Area2D
+ player_shot.call("configure", Vector2(-100, 0), Vector2.RIGHT, ItemDefinition.WeaponKind.PLASMA, 600.0, 2.0, 500.0)
+ add_child(player_shot)
+ player_shot.set_physics_process(false)
+ player_shot.call("_physics_process", 1.0)
+ assert(player_shot.is_queued_for_deletion())
+ assert(is_equal_approx(float(asteroid.get("hull")), asteroid_hp - 2.0))
+ print("ENEMY_ASTEROID_COVER_TEST: PASS (plasma/machine gun/gamma/missile/laser, fast-shot collision, no asteroid damage/mining/rewards, player protected, legacy projectile, player mining retained)")
+ get_tree().quit()
diff --git a/tests/items/enemy_asteroid_cover_test.gd.uid b/tests/items/enemy_asteroid_cover_test.gd.uid
new file mode 100644
index 0000000..aad4ede
--- /dev/null
+++ b/tests/items/enemy_asteroid_cover_test.gd.uid
@@ -0,0 +1 @@
+uid://d2pwe68pu6fvh
diff --git a/tests/items/enemy_asteroid_cover_test.tscn b/tests/items/enemy_asteroid_cover_test.tscn
new file mode 100644
index 0000000..c2cd287
--- /dev/null
+++ b/tests/items/enemy_asteroid_cover_test.tscn
@@ -0,0 +1,4 @@
+[gd_scene load_steps=2 format=3]
+[ext_resource type="Script" path="res://tests/items/enemy_asteroid_cover_test.gd" id="1"]
+[node name="EnemyAsteroidCoverTest" type="Node2D"]
+script = ExtResource("1")
diff --git a/tests/items/luck_affix_test.gd b/tests/items/luck_affix_test.gd
new file mode 100644
index 0000000..1f715db
--- /dev/null
+++ b/tests/items/luck_affix_test.gd
@@ -0,0 +1,80 @@
+extends Node2D
+
+const PLAYER := preload("res://scenes/game/player_ship.tscn")
+const AFFIX := preload("res://resources/items/affixes/luck_percent.tres")
+const GENERATOR := preload("res://scripts/items/item_generator.gd")
+
+func lucky_item(definition: ItemDefinition, amount: float) -> ItemInstance:
+ var item := ItemInstance.new()
+ item.definition = definition
+ var modifier := ItemStatModifier.new()
+ modifier.affix = AFFIX
+ modifier.stat_key = "luck_percent"
+ modifier.value = amount
+ item.additional_modifiers.append(modifier)
+ return item
+
+func _ready() -> void:
+ GameState.start_new_run()
+ assert(AFFIX.get_value_range(1).is_equal_approx(Vector2(0.5, 1.0)))
+ assert(AFFIX.get_value_range(1000).is_equal_approx(Vector2(3.0, 6.0)))
+ var last := Vector2.ZERO
+ for level in [1, 10, 100, 500, 1000]:
+ var bounds := AFFIX.get_value_range(level)
+ assert(bounds.x >= last.x and bounds.y >= last.y)
+ for index in 100:
+ var roll := AFFIX.roll_value(level)
+ assert(roll >= bounds.x and roll <= bounds.y)
+ last = bounds
+ assert(AFFIX.can_roll(preload("res://resources/items/plasma_cannon.tres"), 1))
+ assert(not AFFIX.can_roll(preload("res://resources/items/cloak.tres"), 1))
+ var ship := PLAYER.instantiate() as CharacterBody2D
+ ship.name = "PlayerShip"
+ add_child(ship)
+ ship.set_physics_process(false)
+ ship.get_node("WeaponController").set_physics_process(false)
+ var inventory := ship.get_node("ShipInventory") as ShipInventory
+ var item := lucky_item(preload("res://resources/items/compact_generator.tres"), 0.75)
+ assert(item.get_tooltip_text().contains("Szczęście: +0.75 %"))
+ assert(inventory.add_item(item))
+ assert(ship.call("get_luck_chance") == 0.0) # Cargo does not grant stats.
+ assert(inventory.move_item("cargo", inventory.cargo.find(item), "module", 3))
+ assert(is_equal_approx(float(ship.call("get_luck_chance")), 0.0075))
+ GameState.captain.skills["lucky"] = 1
+ assert(is_equal_approx(float(ship.call("get_luck_chance")), 0.0575))
+ assert(GameState.captain.roll_loot_level(10, 0.0574, float(ship.get("luck_percent"))) == 11)
+ assert(GameState.captain.roll_loot_level(10, 0.0575, float(ship.get("luck_percent"))) == 10)
+ assert(inventory.move_item("module", 3, "cargo", inventory.cargo.find(null)))
+ assert(is_equal_approx(float(ship.call("get_luck_chance")), 0.05))
+ # Combine strong equipment and rank III, with exactly one shared 60% cap.
+ var reactor := lucky_item(preload("res://resources/items/compact_generator.tres"), 30.0)
+ var engine := lucky_item(preload("res://resources/items/engine_extension.tres"), 30.0)
+ for equipped in [reactor, engine]:
+ assert(inventory.add_item(equipped))
+ var slot := ShipInventory.MODULE_CATEGORIES.find(equipped.definition.category)
+ assert(inventory.move_item("cargo", inventory.cargo.find(equipped), "module", slot))
+ GameState.captain.skills["lucky"] = 3
+ assert(is_equal_approx(float(ship.call("get_luck_chance")), 0.6))
+ assert(GameState.captain.roll_loot_level(10, 0.59999, 60.0) == 11)
+ assert(GameState.captain.roll_loot_level(10, 0.6, 60.0) == 10)
+ assert(GameState.captain.roll_loot_level(1000, 0.0, 60.0) == 1000)
+ assert(is_equal_approx(GameState.captain.get_luck_chance(100000.0), 0.6))
+ var stronger := lucky_item(reactor.definition, 50.0)
+ var comparisons := inventory.get_item_comparison(stronger, reactor)
+ for row in comparisons:
+ assert(row["key"] != "luck_percent") # Already capped: no fictitious gain.
+ seed(42)
+ var upgrades := 0
+ for index in 1000:
+ var loot := GENERATOR.generate_loot(10, 0, preload("res://resources/items/plasma_cannon.tres"), float(ship.get("luck_percent")))
+ if loot.item_level == 11:
+ upgrades += 1
+ assert(upgrades >= 550 and upgrades <= 650)
+ inventory.preserve_for_restart()
+ var restored := PLAYER.instantiate() as CharacterBody2D
+ add_child(restored)
+ restored.set_physics_process(false)
+ restored.get_node("WeaponController").set_physics_process(false)
+ assert(is_equal_approx(float(restored.call("get_luck_chance")), 0.6))
+ print("LUCK_AFFIX_TEST: PASS (roll bounds/scaling, categories, cargo/equipment, skill stacking, strict 60% cap, thresholds, tooltips/comparison, 1000 drops, persistence)")
+ get_tree().quit()
diff --git a/tests/items/luck_affix_test.gd.uid b/tests/items/luck_affix_test.gd.uid
new file mode 100644
index 0000000..18df091
--- /dev/null
+++ b/tests/items/luck_affix_test.gd.uid
@@ -0,0 +1 @@
+uid://dp8axo8tkjwmc
diff --git a/tests/items/luck_affix_test.tscn b/tests/items/luck_affix_test.tscn
new file mode 100644
index 0000000..b9281ad
--- /dev/null
+++ b/tests/items/luck_affix_test.tscn
@@ -0,0 +1,4 @@
+[gd_scene load_steps=2 format=3]
+[ext_resource type="Script" path="res://tests/items/luck_affix_test.gd" id="1"]
+[node name="LuckAffixTest" type="Node2D"]
+script = ExtResource("1")
diff --git a/tests/levels/asteroid_debris_geometry_test.gd b/tests/levels/asteroid_debris_geometry_test.gd
new file mode 100644
index 0000000..794f50b
--- /dev/null
+++ b/tests/levels/asteroid_debris_geometry_test.gd
@@ -0,0 +1,25 @@
+extends Node2D
+
+const DEBRIS := preload("res://scripts/game/asteroid_destruction_effect.gd")
+const TEXTURES := preload("res://scripts/levels/exploration_poi.gd").ASTEROID_TEXTURES
+
+func _ready() -> void:
+ var count := 0
+ for texture in TEXTURES.values():
+ var sprite := Sprite2D.new()
+ sprite.texture = texture
+ sprite.scale = Vector2.ONE * 78.0 / maxf(texture.get_width(), texture.get_height())
+ add_child(sprite)
+ for sample in 100:
+ var effect := Node2D.new()
+ effect.set_script(DEBRIS)
+ effect.call("configure", sprite)
+ assert(effect.get("_fragments").size() == 16)
+ for fragment in effect.get("_fragments"):
+ assert(fragment["points"].size() == fragment["uv"].size())
+ assert(not Geometry2D.triangulate_polygon(fragment["points"]).is_empty())
+ count += 1
+ effect.free()
+ sprite.free()
+ print("ASTEROID_DEBRIS_GEOMETRY_TEST: PASS (%d fragments across all five ore textures; triangulation and UVs valid)" % count)
+ get_tree().quit()
diff --git a/tests/levels/asteroid_debris_geometry_test.tscn b/tests/levels/asteroid_debris_geometry_test.tscn
new file mode 100644
index 0000000..b7699ab
--- /dev/null
+++ b/tests/levels/asteroid_debris_geometry_test.tscn
@@ -0,0 +1,4 @@
+[gd_scene load_steps=2 format=3]
+[ext_resource type="Script" path="res://tests/levels/asteroid_debris_geometry_test.gd" id="1"]
+[node name="AsteroidDebrisGeometryTest" type="Node2D"]
+script = ExtResource("1")
diff --git a/tests/levels/exploration_death_restart_test.gd b/tests/levels/exploration_death_restart_test.gd
new file mode 100644
index 0000000..155d452
--- /dev/null
+++ b/tests/levels/exploration_death_restart_test.gd
@@ -0,0 +1,74 @@
+extends Node
+
+const GAME := preload("res://scenes/game/game.tscn")
+
+func pois(game: Node) -> Array[Node]:
+ var found: Array[Node] = []
+ for poi in get_tree().get_nodes_in_group("exploration_pois"):
+ if game.is_ancestor_of(poi) and not poi.is_queued_for_deletion():
+ found.append(poi)
+ return found
+
+func freeze(game: Node) -> void:
+ game.set_physics_process(false)
+ game.get_node("PlayerShip").set_physics_process(false)
+ game.get_node("PlayerShip/WeaponController").set_physics_process(false)
+ for enemy in get_tree().get_nodes_in_group("enemies"):
+ enemy.set_physics_process(false)
+
+func _ready() -> void:
+ await get_tree().process_frame
+ for rank in range(4):
+ GameState.start_new_run()
+ GameState.system_seed = 987654
+ GameState.captain.skills["miner"] = rank
+ GameState.captain.skills["explorer"] = 2
+ GameState.explored_pois["other-system:0"] = true
+ var game := GAME.instantiate() as Node2D
+ get_tree().root.add_child(game)
+ get_tree().current_scene = game
+ freeze(game)
+ var before := {}
+ for poi in pois(game):
+ before[poi.get("poi_id")] = {"kind": poi.get("kind"), "position": poi.position, "mineral": poi.get("_mineral")}
+ assert(before.values().filter(func(p): return p["kind"] == "wreck").size() >= 2)
+ assert(before.values().filter(func(p): return p["kind"] == "asteroid").size() >= 6)
+ for poi in pois(game):
+ if poi.get("kind") in ["asteroid", "wreck"]:
+ # Test reward retention independently of the debris renderer.
+ if poi.get("kind") == "asteroid":
+ poi.set("_asteroid_sprite", null)
+ assert(poi.call("claim"))
+ var minerals_before := GameState.captain.minerals.duplicate()
+ var seed_before := GameState.system_seed
+ var bonus_before := int(GameState.exploration_wreck_bonuses[seed_before])
+ var ship := game.get_node("PlayerShip")
+ var inventory := ship.get_node("ShipInventory") as ShipInventory
+ inventory.energy_banks = 7
+ ship.call("_destroy_ship")
+ assert(ship.get("_is_destroyed"))
+ var old_instance_id := game.get_instance_id()
+ ship.call("_restart_level_after_destruction")
+ await get_tree().process_frame
+ await get_tree().process_frame
+ var restarted := get_tree().current_scene
+ assert(is_instance_valid(restarted) and restarted.get_instance_id() != old_instance_id)
+ freeze(restarted)
+ var after := pois(restarted)
+ assert(after.size() == before.size(), "Death must restore all asteroids and wrecks; miner rank %d: %d vs %d" % [rank, after.size(), before.size()])
+ for poi in after:
+ var original: Dictionary = before[poi.get("poi_id")]
+ assert(original["kind"] == poi.get("kind"))
+ assert(original["position"] == poi.position)
+ assert(original["mineral"] == poi.get("_mineral"))
+ assert(GameState.captain.minerals == minerals_before)
+ assert(GameState.captain.get_skill_rank("miner") == rank)
+ assert(GameState.system_seed == seed_before)
+ assert(GameState.exploration_wreck_bonuses[seed_before] == bonus_before)
+ assert(GameState.explored_pois.has("other-system:0"))
+ assert(restarted.get_node("PlayerShip/ShipInventory").energy_banks == 7)
+ get_tree().current_scene = self
+ restarted.queue_free()
+ await get_tree().process_frame
+ print("EXPLORATION_DEATH_RESTART_TEST: PASS (actual death and scene reload at miner ranks 0/1/2/3, exhausted boards restored, stable positions/minerals, explorer snapshot, rewards/equipment retained)")
+ get_tree().quit()
diff --git a/tests/levels/exploration_death_restart_test.tscn b/tests/levels/exploration_death_restart_test.tscn
new file mode 100644
index 0000000..dde13e7
--- /dev/null
+++ b/tests/levels/exploration_death_restart_test.tscn
@@ -0,0 +1,4 @@
+[gd_scene load_steps=2 format=3]
+[ext_resource type="Script" path="res://tests/levels/exploration_death_restart_test.gd" id="1"]
+[node name="ExplorationDeathRestartTest" type="Node"]
+script = ExtResource("1")
diff --git a/tests/levels/exploration_test.gd b/tests/levels/exploration_test.gd
index 4ce1f8a..48a015e 100644
--- a/tests/levels/exploration_test.gd
+++ b/tests/levels/exploration_test.gd
@@ -1,5 +1,10 @@
extends Node2D
const POI = preload("res://scripts/levels/exploration_poi.gd")
+
+class PlanetStub:
+ extends Node2D
+ var radius := 100.0
+
func _ready() -> void:
GameState.start_new_run()
var ship = preload("res://scenes/game/player_ship.tscn").instantiate()
@@ -21,6 +26,9 @@ func _ready() -> void:
assert(poi.has_node("AsteroidSprite"))
assert(poi.get_node("AsteroidSprite").texture is Texture2D)
var asteroid = poi.get_node("AsteroidSprite")
+ assert(asteroid.texture == poi.ASTEROID_TEXTURES[poi._mineral])
+ assert(not poi._label.visible and poi._label.text.is_empty())
+ var minerals_before := GameState.captain.minerals.duplicate()
var previous_rotation = asteroid.rotation
poi._process(1.0)
assert(not is_equal_approx(asteroid.rotation, previous_rotation))
@@ -30,6 +38,11 @@ func _ready() -> void:
assert(not poi._claimed and is_equal_approx(poi.hull, 1))
poi.take_weapon_hit(1)
assert(poi._claimed)
+ for mineral in minerals_before:
+ if mineral == poi._mineral:
+ assert(GameState.captain.minerals[mineral] > minerals_before[mineral])
+ else:
+ assert(GameState.captain.minerals[mineral] == minerals_before[mineral])
else:
if kind == "wreck":
assert(poi.has_node("WreckSprite"))
@@ -74,13 +87,19 @@ func _ready() -> void:
var bodies = Node2D.new()
bodies.name = "CelestialBodies"
add_child(bodies)
+ for index in data["planets"].size():
+ var planet := PlanetStub.new()
+ planet.name = "Planet%d" % (index + 1)
+ planet.position = data["planets"][index]["position"]
+ planet.radius = data["planets"][index]["radius"]
+ bodies.add_child(planet)
preload("res://scripts/levels/exploration_generator.gd").spawn(self, data, ship)
var generated = get_tree().get_nodes_in_group("exploration_pois").filter(func(p): return not p.is_queued_for_deletion())
- assert(generated.size() >= 8 and generated.size() <= 14)
+ assert(generated.size() >= 8 and generated.size() <= 15)
var probe = generated[0]
assert(probe.reward_scale() == 1.0)
GameState.system_level = 10
assert(is_equal_approx(probe.reward_scale(), 1.45))
GameState.system_level = 1
- print("EXPLORATION_TEST: PASS rewards, duplicate prevention, minerals, ancient level+1 and high addons, generation")
+ print("EXPLORATION_TEST: PASS rewards, duplicate prevention, ore textures match mined minerals, hidden asteroid labels, ancient level+1 and high addons, generation")
get_tree().quit()
diff --git a/tests/previews/ore_asteroids_preview.gd b/tests/previews/ore_asteroids_preview.gd
new file mode 100644
index 0000000..9a27f08
--- /dev/null
+++ b/tests/previews/ore_asteroids_preview.gd
@@ -0,0 +1,26 @@
+extends "res://scripts/game/game.gd"
+
+const MINERALS := ["iron", "titanium", "uranium", "aluminium", "sulfur"]
+
+func _ready() -> void:
+ super._ready()
+ for enemy in get_tree().get_nodes_in_group("enemies"):
+ enemy.set_physics_process(false)
+ for poi in get_tree().get_nodes_in_group("exploration_pois"):
+ poi.queue_free()
+ for index in MINERALS.size():
+ var reward_seed := 1
+ var rng := RandomNumberGenerator.new()
+ while true:
+ rng.seed = reward_seed
+ if ["iron", "aluminium", "titanium", "uranium", "sulfur"][rng.randi_range(0, 4)] == MINERALS[index]:
+ break
+ reward_seed += 1
+ var asteroid := StaticBody2D.new()
+ asteroid.set_script(preload("res://scripts/levels/exploration_poi.gd"))
+ asteroid.set("kind", "asteroid")
+ asteroid.set("poi_id", "ore_preview_%s" % MINERALS[index])
+ asteroid.set("reward_seed", reward_seed)
+ asteroid.set("player", player)
+ asteroid.position = player.position + Vector2((index - 2) * 120, 130)
+ add_child(asteroid)
diff --git a/tests/previews/ore_asteroids_preview.gd.uid b/tests/previews/ore_asteroids_preview.gd.uid
new file mode 100644
index 0000000..4a37ff7
--- /dev/null
+++ b/tests/previews/ore_asteroids_preview.gd.uid
@@ -0,0 +1 @@
+uid://cdvvoi5e1e6ss
diff --git a/tests/previews/ore_asteroids_preview.tscn b/tests/previews/ore_asteroids_preview.tscn
new file mode 100644
index 0000000..6b049f2
--- /dev/null
+++ b/tests/previews/ore_asteroids_preview.tscn
@@ -0,0 +1,5 @@
+[gd_scene load_steps=3 format=3]
+[ext_resource type="PackedScene" path="res://scenes/game/game.tscn" id="1"]
+[ext_resource type="Script" path="res://tests/previews/ore_asteroids_preview.gd" id="2"]
+[node name="OreAsteroidsPreview" instance=ExtResource("1")]
+script = ExtResource("2")
diff --git a/tests/previews/tactical_extensions_preview.gd b/tests/previews/tactical_extensions_preview.gd
new file mode 100644
index 0000000..c1ba8a0
--- /dev/null
+++ b/tests/previews/tactical_extensions_preview.gd
@@ -0,0 +1,20 @@
+extends "res://scripts/game/game.gd"
+
+const EXTENSIONS := ["teleport", "cloak", "mine_layer", "flares", "solar_shield_charge"]
+
+func _ready() -> void:
+ super._ready()
+ for enemy in get_tree().get_nodes_in_group("enemies"):
+ enemy.set_physics_process(false)
+ var inventory := player.get_node("ShipInventory") as ShipInventory
+ for index in EXTENSIONS.size():
+ var item := ItemInstance.new()
+ item.definition = load("res://resources/items/%s.tres" % EXTENSIONS[index])
+ inventory.extension_slots[index] = item
+ inventory.inventory_changed.emit()
+ player.set("shields", 15.0)
+
+func preview_cloak() -> void:
+ player.call("activate_ability", 1)
+ player.call("activate_ability", 2)
+ player.call("activate_ability", 3)
diff --git a/tests/previews/tactical_extensions_preview.gd.uid b/tests/previews/tactical_extensions_preview.gd.uid
new file mode 100644
index 0000000..9a07d75
--- /dev/null
+++ b/tests/previews/tactical_extensions_preview.gd.uid
@@ -0,0 +1 @@
+uid://b3uptpvkl7rgm
diff --git a/tests/previews/tactical_extensions_preview.tscn b/tests/previews/tactical_extensions_preview.tscn
new file mode 100644
index 0000000..2559de5
--- /dev/null
+++ b/tests/previews/tactical_extensions_preview.tscn
@@ -0,0 +1,5 @@
+[gd_scene load_steps=3 format=3]
+[ext_resource type="PackedScene" path="res://scenes/game/game.tscn" id="1"]
+[ext_resource type="Script" path="res://tests/previews/tactical_extensions_preview.gd" id="2"]
+[node name="TacticalExtensionsPreview" instance=ExtResource("1")]
+script = ExtResource("2")