diff --git a/artifacts/captain_window_preview.png b/artifacts/captain_window_preview.png new file mode 100644 index 0000000..c113b08 Binary files /dev/null and b/artifacts/captain_window_preview.png differ diff --git a/artifacts/captain_window_preview.png.import b/artifacts/captain_window_preview.png.import new file mode 100644 index 0000000..23dcec2 --- /dev/null +++ b/artifacts/captain_window_preview.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cyeugj6bwpxwe" +path="res://.godot/imported/captain_window_preview.png-fd71a3b2bc0fe3fe06fb1f7e9e2c97dc.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://artifacts/captain_window_preview.png" +dest_files=["res://.godot/imported/captain_window_preview.png-fd71a3b2bc0fe3fe06fb1f7e9e2c97dc.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/artifacts/item_drag_preview.png b/artifacts/item_drag_preview.png new file mode 100644 index 0000000..6f8d291 Binary files /dev/null and b/artifacts/item_drag_preview.png differ diff --git a/artifacts/item_drag_preview.png.import b/artifacts/item_drag_preview.png.import new file mode 100644 index 0000000..c714f1e --- /dev/null +++ b/artifacts/item_drag_preview.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cepoalnss6c1o" +path="res://.godot/imported/item_drag_preview.png-775710079bd6bc529ba2e05a44060976.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://artifacts/item_drag_preview.png" +dest_files=["res://.godot/imported/item_drag_preview.png-775710079bd6bc529ba2e05a44060976.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/artifacts/passive_extensions_preview.png b/artifacts/passive_extensions_preview.png new file mode 100644 index 0000000..f4ca79f Binary files /dev/null and b/artifacts/passive_extensions_preview.png differ diff --git a/artifacts/passive_extensions_preview.png.import b/artifacts/passive_extensions_preview.png.import new file mode 100644 index 0000000..02c7f17 --- /dev/null +++ b/artifacts/passive_extensions_preview.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://db6g0pym7vseg" +path="res://.godot/imported/passive_extensions_preview.png-e441e432f841964ebe8fd7ee54f7d2bb.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://artifacts/passive_extensions_preview.png" +dest_files=["res://.godot/imported/passive_extensions_preview.png-e441e432f841964ebe8fd7ee54f7d2bb.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/passive_art_prompts.md b/assets/icons/extensions/passive_art_prompts.md new file mode 100644 index 0000000..7920595 --- /dev/null +++ b/assets/icons/extensions/passive_art_prompts.md @@ -0,0 +1,21 @@ +# Passive extension art + +Generated with the built-in image_gen tool. Transparent PNG originals are retained in assets/icons/extensions/. + +## recycler + +File: recycler_art.png + +Prompt: Use case: stylized-concept. Asset type: high quality inventory icon for a top-down space action RPG. A compact spaceship salvage recycler module: two curved mechanical arms feeding a small jagged asteroid into a circular energy core, cyan and emerald luminous energy loops, polished gunmetal and bronze housing. Single isolated object centered, three-quarter isometric view, bold readable silhouette at 48 pixels, detailed hand-painted realistic 3D game art, crisp metallic highlights, controlled glow confined to object. Transparent background with real alpha. Square composition with 12% empty margin, no frame, no text, no letters, no numbers, no watermark. + +## virus + +File: virus_art.png + +Prompt: Use case: stylized-concept. Asset type: high quality inventory icon for a top-down space action RPG. A hostile cybernetic virus module for a spaceship: a menacing violet crystalline neural processor with three hooked antenna prongs and tiny branching magenta circuitry, electric purple core, polished dark titanium housing. Single isolated object centered, three-quarter isometric view, bold readable silhouette at 48 pixels, detailed hand-painted realistic 3D game art, crisp metallic highlights, controlled glow confined to object. Transparent background with real alpha. Square composition with 12% empty margin, no frame, no text, no letters, no numbers, no watermark. + +## titanium_armor + +File: titanium_armor_art.png + +Prompt: Use case: stylized-concept. Asset type: high quality inventory icon for a top-down space action RPG. A spaceship titanium armor module: layered silver titanium armored plates forming a strong hexagonal shield, beveled edges, subtle cyan energy seams and a warm gold central reinforcing spine, heavy solid premium metal. Single isolated object centered, three-quarter isometric view, bold readable silhouette at 48 pixels, detailed hand-painted realistic 3D game art, crisp metallic highlights, controlled glow confined to object. Transparent background with real alpha. Square composition with 12% empty margin, no frame, no text, no letters, no numbers, no watermark. \ No newline at end of file diff --git a/assets/icons/extensions/recycler_art.png b/assets/icons/extensions/recycler_art.png new file mode 100644 index 0000000..b9d22f7 Binary files /dev/null and b/assets/icons/extensions/recycler_art.png differ diff --git a/assets/icons/extensions/recycler_art.png.import b/assets/icons/extensions/recycler_art.png.import new file mode 100644 index 0000000..43a2ee8 --- /dev/null +++ b/assets/icons/extensions/recycler_art.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dphd1x0th81b7" +path="res://.godot/imported/recycler_art.png-9bd765a370eabd761a8651a4e26ad2fa.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/extensions/recycler_art.png" +dest_files=["res://.godot/imported/recycler_art.png-9bd765a370eabd761a8651a4e26ad2fa.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/titanium_armor_art.png b/assets/icons/extensions/titanium_armor_art.png new file mode 100644 index 0000000..a0b26bd Binary files /dev/null and b/assets/icons/extensions/titanium_armor_art.png differ diff --git a/assets/icons/extensions/titanium_armor_art.png.import b/assets/icons/extensions/titanium_armor_art.png.import new file mode 100644 index 0000000..35d13e2 --- /dev/null +++ b/assets/icons/extensions/titanium_armor_art.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://y00q03eeo73k" +path="res://.godot/imported/titanium_armor_art.png-77b741707d0dfb24fcae9ad6ad3219d4.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/extensions/titanium_armor_art.png" +dest_files=["res://.godot/imported/titanium_armor_art.png-77b741707d0dfb24fcae9ad6ad3219d4.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/virus_art.png b/assets/icons/extensions/virus_art.png new file mode 100644 index 0000000..143e590 Binary files /dev/null and b/assets/icons/extensions/virus_art.png differ diff --git a/assets/icons/extensions/virus_art.png.import b/assets/icons/extensions/virus_art.png.import new file mode 100644 index 0000000..8bd5dfe --- /dev/null +++ b/assets/icons/extensions/virus_art.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cw4ub6vfukxj5" +path="res://.godot/imported/virus_art.png-6fdadf0dc8dadfe12432e7a72485171e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/extensions/virus_art.png" +dest_files=["res://.godot/imported/virus_art.png-6fdadf0dc8dadfe12432e7a72485171e.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/aim_cursor.svg b/assets/icons/ui/aim_cursor.svg new file mode 100644 index 0000000..b89fdb9 --- /dev/null +++ b/assets/icons/ui/aim_cursor.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/assets/icons/ui/aim_cursor.svg.import b/assets/icons/ui/aim_cursor.svg.import new file mode 100644 index 0000000..2a5b197 --- /dev/null +++ b/assets/icons/ui/aim_cursor.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://be3rq5fhd8dgx" +path="res://.godot/imported/aim_cursor.svg-cd2711c215cac45daa91fe6817bf504e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/ui/aim_cursor.svg" +dest_files=["res://.godot/imported/aim_cursor.svg-cd2711c215cac45daa91fe6817bf504e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/resources/items/recycler.tres b/resources/items/recycler.tres new file mode 100644 index 0000000..cd3a811 --- /dev/null +++ b/resources/items/recycler.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" script_class="ItemDefinition" load_steps=3 format=3] + +[ext_resource type="Script" path="res://scripts/items/item_definition.gd" id="1"] +[ext_resource type="Texture2D" path="res://assets/icons/extensions/recycler_art.png" id="2"] + +[resource] +script = ExtResource("1") +item_id = "recycler" +display_name = "Recykler" +icon = ExtResource("2") +category = 5 +description = "Pasywne rozszerzenie statku. Działa po zamontowaniu. Zebrany odłamek asteroidy przywraca tarcze i energię." +passive_effect = "recycler" diff --git a/resources/items/titanium_armor.tres b/resources/items/titanium_armor.tres new file mode 100644 index 0000000..9f928bc --- /dev/null +++ b/resources/items/titanium_armor.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" script_class="ItemDefinition" load_steps=3 format=3] + +[ext_resource type="Script" path="res://scripts/items/item_definition.gd" id="1"] +[ext_resource type="Texture2D" path="res://assets/icons/extensions/titanium_armor_art.png" id="2"] + +[resource] +script = ExtResource("1") +item_id = "titanium_armor" +display_name = "Pancerz z tytanu" +icon = ExtResource("2") +category = 5 +description = "Pasywne rozszerzenie statku. Działa po zamontowaniu. Przy niskim kadłubie pochłania 50% otrzymywanych obrażeń." +passive_effect = "titanium_armor" diff --git a/resources/items/virus.tres b/resources/items/virus.tres new file mode 100644 index 0000000..785a478 --- /dev/null +++ b/resources/items/virus.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" script_class="ItemDefinition" load_steps=3 format=3] + +[ext_resource type="Script" path="res://scripts/items/item_definition.gd" id="1"] +[ext_resource type="Texture2D" path="res://assets/icons/extensions/virus_art.png" id="2"] + +[resource] +script = ExtResource("1") +item_id = "virus" +display_name = "Wirus" +icon = ExtResource("2") +category = 5 +description = "Pasywne rozszerzenie statku. Działa po zamontowaniu. Uciekający wrogowie przechodzą pod kontrolę wirusa i atakują inne wrogie statki." +passive_effect = "virus" diff --git a/scripts/abilities/afterburner_ability.gd b/scripts/abilities/afterburner_ability.gd index 90a6bd1..8decd4a 100644 --- a/scripts/abilities/afterburner_ability.gd +++ b/scripts/abilities/afterburner_ability.gd @@ -3,11 +3,12 @@ extends ActiveAbility @export var speed_bonus: float = 2.0 @export var acceleration_time: float = 0.3 +func _init() -> void: + ability_id = "afterburner" + cooldown = 30.0 + func unavailable_reason(ship: CharacterBody2D) -> String: - if ship.call("is_boosting"): - return "Dopalacz już działa" - return "Wymagana pełna energia" if ship.get("energy") < ship.get("max_energy") else "" + return "Dopalacz już działa" if ship.call("is_boosting") else "" func activate(ship: CharacterBody2D) -> void: - ship.set("energy", 0.0) ship.call("start_boost", speed_bonus, acceleration_time) diff --git a/scripts/game/enemy_health_bar.gd b/scripts/game/enemy_health_bar.gd index adf58af..8cde03d 100644 --- a/scripts/game/enemy_health_bar.gd +++ b/scripts/game/enemy_health_bar.gd @@ -30,10 +30,14 @@ func _update_position() -> void: global_position = enemy.global_position + Vector2(0, -enemy.get_visual_size() * 0.5 - 12.0) func _tier_color() -> Color: + if bool(enemy.get("virus_controlled")): + return Color("c57dff") return Color("ff76d6") if enemy.loot_tier == 2 else (Color("ffc785") if enemy.loot_tier == 1 else Color("ef5967")) func _update_tier_label() -> void: _tier_label.text = "BOSS" if enemy.loot_tier == 2 else ("ELITA" if enemy.loot_tier == 1 else "") + if bool(enemy.get("virus_controlled")): + _tier_label.text = "WIRUS" _tier_label.add_theme_color_override("font_color", _tier_color()) func get_health_ratio() -> float: diff --git a/scripts/game/enemy_ship.gd b/scripts/game/enemy_ship.gd index b841e4b..5dcee52 100644 --- a/scripts/game/enemy_ship.gd +++ b/scripts/game/enemy_ship.gd @@ -40,6 +40,40 @@ var _sprite: Sprite2D var _weapon_timer := 0.0 var _slow_remaining := 0.0 var _combat_target: Node2D +var virus_controlled := false +var _reward_claimed := false + +func set_virus_controlled(value: bool) -> void: + if virus_controlled == value or is_queued_for_deletion(): + return + virus_controlled = value + _combat_target = null + _activated = true + if value: + remove_from_group("enemies") + add_to_group("virus_allies") + set_deferred("collision_layer", 64) + set_deferred("collision_mask", 5) + if not _reward_claimed: + _reward_claimed = true + _reward_captain() + _drop_loot() + else: + remove_from_group("virus_allies") + add_to_group("enemies") + set_deferred("collision_layer", 4) + set_deferred("collision_mask", 2) + if is_instance_valid(_sprite): + _sprite.modulate = Color("b986ff") if value else get_tier_tint() + queue_redraw() + +func _draw() -> void: + if virus_controlled: + draw_arc(Vector2.ZERO, get_visual_size() * 0.6, 0.0, TAU, 48, Color("c57dff"), 2.0, true) + for index in 3: + var marker := Vector2.from_angle(index * TAU / 3.0) * get_visual_size() * 0.67 + draw_circle(marker, 2.5, Color("e0baff")) + func _ready() -> void: tactic = randi_range(0, 1) if tactic_choice == 0 else tactic_choice - 1 @@ -105,6 +139,8 @@ func _physics_process(delta: float) -> void: move_and_slide() return + if not virus_controlled and hull > 0.0 and hull < max_hull * 0.1: + _player.call("try_control_enemy", self) _combat_target = select_combat_target() if _combat_target == null: velocity = velocity.move_toward(Vector2.ZERO, approach_acceleration * delta) @@ -134,7 +170,7 @@ func _physics_process(delta: float) -> void: _check_player_collision() func is_retreating() -> bool: - return hull > 0.0 and hull < max_hull * 0.1 + return not virus_controlled and hull > 0.0 and hull < max_hull * 0.1 func _drop_retreat_mine(toward_target: Vector2) -> void: var mine := Node2D.new() @@ -168,6 +204,17 @@ func get_slow_multiplier() -> float: return 0.5 if _slow_remaining > 0.0 else 1.0 func select_combat_target() -> Node2D: + if virus_controlled: + var opponent: Node2D + var nearest := INF + for enemy in get_tree().get_nodes_in_group("enemies"): + if enemy.is_queued_for_deletion() or float(enemy.get("hull")) <= 0.0: + continue + var distance := global_position.distance_squared_to(enemy.global_position) + if distance < nearest: + nearest = distance + opponent = enemy + return opponent var closest: Node2D var closest_distance := INF for flare in get_tree().get_nodes_in_group("player_flares"): @@ -177,6 +224,13 @@ func select_combat_target() -> Node2D: if distance <= maxf(activation_range, weapon_range) and distance < closest_distance: closest = flare closest_distance = distance + for ally in get_tree().get_nodes_in_group("virus_allies"): + if ally.is_queued_for_deletion() or float(ally.get("hull")) <= 0.0: + continue + var distance := global_position.distance_to(ally.global_position) + if distance <= activation_range and distance < closest_distance: + closest = ally + 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")): @@ -191,16 +245,19 @@ func apply_mine_hit() -> void: take_damage(hull * 0.5, false, DamageTypes.Type.KINETIC, true) func _check_player_collision() -> void: + if virus_controlled: + return for collision_index in get_slide_collision_count(): var collision := get_slide_collision(collision_index) if collision.get_collider() == _player and _player.has_method("register_enemy_collision"): _player.call("register_enemy_collision", global_position) func _fire_plasma(direction: Vector2) -> void: + var shot_mask := 37 if virus_controlled else 115 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, 51, [get_rid()]) + var query := PhysicsRayQueryParameters2D.create(muzzle, endpoint, shot_mask, [get_rid()]) var hit := get_world_2d().direct_space_state.intersect_ray(query) if not hit.is_empty(): endpoint = hit["position"] @@ -218,7 +275,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 = 51 + projectile.collision_mask = shot_mask 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) @@ -246,8 +303,10 @@ func take_damage(amount: float, critical: bool = false, damage_type: int = Damag _shield_visual.flash_hit() _show_damage_popup(actual, critical) if hull <= 0.0: - _reward_captain() - _drop_loot() + if not _reward_claimed: + _reward_claimed = true + _reward_captain() + _drop_loot() _spawn_destruction_effect() queue_free() diff --git a/scripts/game/flight_hud.gd b/scripts/game/flight_hud.gd index e77cf69..7d469e1 100644 --- a/scripts/game/flight_hud.gd +++ b/scripts/game/flight_hud.gd @@ -13,7 +13,12 @@ var _energy_bank_button: Button var _debug_expanded := false var _ability_buttons: Dictionary = {} var _ability_bar: HBoxContainer -var _passive_skills: VBoxContainer +var _passive_skills: GridContainer +var _passive_captain_panel: PanelContainer +var _passive_ship_panel: PanelContainer +var _passive_ship_icons: GridContainer +var _passive_ship_buttons: Dictionary = {} +var _ship_passive_reserved_height := 0.0 var _inventory_clip: Control var _sidebar: PanelContainer var _sidebar_toggle: Button @@ -112,6 +117,11 @@ func _toggle_window(window: PanelContainer) -> void: func _ready() -> void: layer = RENDER_LAYERS.HUD + var cursor := Node.new() + cursor.name = "FlightCursor" + cursor.set_script(preload("res://scripts/ui/flight_cursor.gd")) + cursor.set("player", player) + add_child(cursor) _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 @@ -166,11 +176,12 @@ func _create_sidebar() -> void: _captain_panel.position = Vector2(12, 12) _captain_panel.size = Vector2(244, _captain_panel.get_combined_minimum_size().y) _captain_panel.resized.connect(_layout_debug) - _passive_skills = VBoxContainer.new() + _passive_captain_panel = _create_passive_panel("PassiveCaptainPanel", "KAPITAN · PASYWNE") + _passive_skills = _passive_captain_panel.get_node("Contents/Icons") as GridContainer _passive_skills.name = "PassiveCaptainSkills" - _passive_skills.add_theme_constant_override("separation", 6) - $HUDRoot.add_child(_passive_skills) - _passive_skills.minimum_size_changed.connect(func() -> void: _layout_debug.call_deferred()) + _passive_ship_panel = _create_passive_panel("PassiveShipPanel", "STATEK · PASYWNE") + _passive_ship_icons = _passive_ship_panel.get_node("Contents/Icons") as GridContainer + _passive_ship_panel.hide() GameState.captain.changed.connect(_refresh_passive_skills) _refresh_passive_skills() _debug_actions = VBoxContainer.new() @@ -255,22 +266,45 @@ func _create_sidebar() -> void: _create_navigation() _layout_debug() +func _create_passive_panel(node_name: String, caption: String) -> PanelContainer: + var panel := PanelContainer.new() + panel.name = node_name + panel.set_script(preload("res://scripts/ui/passive_status_panel.gd")) + panel.set("caption", caption) + $HUDRoot.add_child(panel) + return panel + +func _layout_passive_panels() -> void: + if not is_instance_valid(_cockpit): + return + var width := clampf(_cockpit.position.x - 24.0, 180.0, 260.0) + for panel in [_passive_captain_panel, _passive_ship_panel]: + if not is_instance_valid(panel): + continue + var grid: GridContainer = _passive_skills if panel == _passive_captain_panel else _passive_ship_icons + grid.columns = maxi(1, floori((width - 14.0) / 42.0)) + panel.size = Vector2(width, panel.get_combined_minimum_size().y) + var x: float = 12.0 if panel == _passive_captain_panel else $HUDRoot.size.x - width - 12.0 + panel.position = Vector2(x, $HUDRoot.size.y - panel.size.y - 12.0) + var reserved := _passive_ship_panel.size.y + 12.0 if _passive_ship_panel.visible else 0.0 + if not is_equal_approx(reserved, _ship_passive_reserved_height): + _ship_passive_reserved_height = reserved + _layout_sidebar.call_deferred() + func _layout_debug() -> void: if not is_instance_valid(_debug_scroll) or not is_instance_valid(_captain_panel): return var top := _captain_panel.position.y + _captain_panel.size.y + 6.0 - if is_instance_valid(_passive_skills): - _passive_skills.position = Vector2(_captain_panel.position.x, top) - _passive_skills.size = Vector2(36, _passive_skills.get_combined_minimum_size().y) - if _passive_skills.visible: - top += _passive_skills.size.y + 8.0 + var objectives := $HUDRoot.get_node_or_null("MissionObjectives") as Control + if is_instance_valid(objectives): + top = maxf(top, objectives.position.y + objectives.size.y + 8.0) debug_toggle.position = Vector2(12, top) _debug_actions.position = Vector2(18, top + 30) _debug_actions.size = Vector2(310, _debug_actions.get_combined_minimum_size().y) _debug_actions.visible = _debug_expanded var scroll_top := _debug_actions.position.y + _debug_actions.size.y + 8.0 _debug_scroll.position = Vector2(18, scroll_top) - _debug_scroll.size = Vector2(310, maxf(40, $HUDRoot.size.y - scroll_top - 12)) + _debug_scroll.size = Vector2(310, maxf(40, (_passive_captain_panel.position.y - 8.0 if _passive_captain_panel.visible else $HUDRoot.size.y - 12.0) - scroll_top)) _debug_scroll.visible = _debug_expanded func _toggle_sidebar() -> void: @@ -294,7 +328,7 @@ func _layout_sidebar() -> void: if _sidebar_tween != null and _sidebar_tween.is_running(): _sidebar_tween.kill() _sidebar.scale = Vector2.ONE * EQUIPMENT_UI_SCALE - _sidebar.size = Vector2(maxf(SIDEBAR_WIDTH, _sidebar.get_combined_minimum_size().x), minf(_sidebar.get_node("Scroll/Contents").get_combined_minimum_size().y + 16.0, maxf(100.0, ($HUDRoot.size.y - 148.0) / EQUIPMENT_UI_SCALE))) + _sidebar.size = Vector2(maxf(SIDEBAR_WIDTH, _sidebar.get_combined_minimum_size().x), minf(_sidebar.get_node("Scroll/Contents").get_combined_minimum_size().y + 16.0, maxf(100.0, ($HUDRoot.size.y - 148.0 - _ship_passive_reserved_height) / EQUIPMENT_UI_SCALE))) _cockpit.scale = Vector2.ONE * 0.85 _cockpit.size = Vector2(minf(850.0, ($HUDRoot.size.x - 24) / 0.85), 120) _cockpit.position = Vector2(($HUDRoot.size.x - _cockpit.size.x * 0.85) * 0.5, $HUDRoot.size.y - _cockpit.size.y * 0.85 - 12) @@ -369,6 +403,8 @@ func _process(_delta: float) -> void: _update_energy_bank_button() _layout_inventory() _update_ability_bar() + _layout_passive_panels() + _layout_debug() if not _debug_expanded: return var inventory := player.get_node("ShipInventory") as ShipInventory @@ -499,6 +535,7 @@ func _create_ability_bar() -> void: button.name = "Ability%d" % (index + 1) button.call("configure", null, Color("91dbea")) var shortcut := Label.new() + shortcut.name = "Shortcut" shortcut.text = str(index + 1) shortcut.position = Vector2(4, 2) shortcut.mouse_filter = Control.MOUSE_FILTER_IGNORE @@ -522,12 +559,32 @@ func _create_ability_bar() -> void: timer.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) _ability_bar.add_child(button) _ability_buttons[index] = button + var passive := Button.new() + passive.name = "PassiveSlot%d" % (index + 1) + passive.set_script(preload("res://scripts/ui/captain_skill_button.gd")) + passive.call("configure", null, Color("85e5b6")) + passive.disabled = true + passive.mouse_default_cursor_shape = Control.CURSOR_HELP + _passive_ship_icons.add_child(passive) + _passive_ship_buttons[index] = passive _update_ability_bar() func _update_ability_bar() -> void: var any_equipped := false + var any_passive := false for index in _ability_buttons: var button: Button = _ability_buttons[index] + var equipped: ItemInstance = player.get_node("ShipInventory").get_item("extension", index) + var passive: Button = _passive_ship_buttons[index] + passive.visible = equipped != null and equipped.is_passive_extension() + if passive.visible: + any_passive = true + button.hide() + passive.call("configure", equipped.definition.icon, Color("85e5b6")) + passive.call("set_visual_state", 0.0, false, false) + passive.tooltip_text = equipped.get_tooltip_text() + continue + (button.get_node("Shortcut") as Label).text = str(index + 1) var ability: ActiveAbility = player.call("get_active_ability", index) button.visible = ability != null if ability == null: @@ -542,6 +599,7 @@ func _update_ability_bar() -> void: (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 + _passive_ship_panel.visible = any_passive func _refresh_passive_skills() -> void: if not is_instance_valid(_passive_skills): @@ -562,5 +620,6 @@ func _refresh_passive_skills() -> void: icon.mouse_default_cursor_shape = Control.CURSOR_HELP icon.tooltip_text = "%s · poziom %d / 3\n%s" % [catalog.DATA[skill]["name"], rank, catalog.description(skill, rank)] _passive_skills.add_child(icon) - _passive_skills.visible = _passive_skills.get_child_count() > 0 + _passive_captain_panel.visible = _passive_skills.get_child_count() > 0 + _layout_passive_panels.call_deferred() _layout_debug.call_deferred() diff --git a/scripts/game/ship_controller.gd b/scripts/game/ship_controller.gd index 9d225ac..2dd1e96 100644 --- a/scripts/game/ship_controller.gd +++ b/scripts/game/ship_controller.gd @@ -180,6 +180,47 @@ func restore_shields_from_damage(actual_damage: float) -> void: shields = minf(max_shields, shields + actual_damage * shield_on_damage_percent / 100.0) _update_shield_visual() +func get_passive_extension_value(effect: String) -> float: + var best := 0.0 + for item in $ShipInventory.extension_slots: + if item != null and item.is_passive_extension() and item.definition.passive_effect == effect: + best = maxf(best, item.get_passive_value()) + return best + +func recycle_asteroid_fragment() -> void: + if _is_destroyed: + return + var fraction := get_passive_extension_value("recycler") / 100.0 + if fraction <= 0.0: + return + energy = minf(max_energy, energy + max_energy * fraction) + if not GameState.captain.is_skill_active("power_redirect"): + shields = minf(max_shields, shields + max_shields * fraction) + _update_shield_visual() + +func try_control_enemy(enemy: CharacterBody2D) -> bool: + var limit := int(get_passive_extension_value("virus")) + if limit <= 0 or _is_destroyed: + return false + var controlled := 0 + for ally in get_tree().get_nodes_in_group("virus_allies"): + if not ally.is_queued_for_deletion() and float(ally.get("hull")) > 0.0: + controlled += 1 + if controlled >= limit: + return false + enemy.call("set_virus_controlled", true) + return true + +func _sync_virus_allies() -> void: + var remaining := int(get_passive_extension_value("virus")) if not _is_destroyed else 0 + for ally in get_tree().get_nodes_in_group("virus_allies"): + if ally.is_queued_for_deletion(): + continue + if remaining > 0: + remaining -= 1 + else: + ally.call("set_virus_controlled", false) + func get_evasion_chance() -> float: return GameState.captain.get_skill_bonus("tactical_maneuver") if GameState.captain.is_skill_active("tactical_maneuver") else 0.0 @@ -187,7 +228,11 @@ func get_energy_regeneration() -> float: return (energy_regeneration + get_solar_regeneration()) * GameState.captain.energy_regeneration_multiplier() func get_damage_reduction() -> float: - return GameState.captain.get_skill_bonus("mechanic") + var reduction := GameState.captain.get_skill_bonus("mechanic") + var threshold := get_passive_extension_value("titanium_armor") / 100.0 + if threshold > 0.0 and hull / maxf(1.0, max_hull) <= threshold: + reduction = 1.0 - (1.0 - reduction) * 0.5 + return reduction func get_weapon_damage_multiplier() -> float: return damage_multiplier * (1.0 + GameState.captain.get_skill_bonus("gunner")) @@ -269,6 +314,7 @@ func _restart_level_after_destruction() -> void: get_tree().reload_current_scene.call_deferred() func _physics_process(delta: float) -> void: + _sync_virus_allies() if not _is_destroyed: GameState.captain.tick_skills(delta) if GameState.captain.is_skill_active("power_redirect"): diff --git a/scripts/game/weapon_projectile.gd b/scripts/game/weapon_projectile.gd index 13af061..c422b05 100644 --- a/scripts/game/weapon_projectile.gd +++ b/scripts/game/weapon_projectile.gd @@ -61,6 +61,8 @@ func configure( func _physics_process(delta: float) -> void: var travel_velocity := direction * projectile_speed if weapon_kind == ItemDefinition.WeaponKind.MISSILE and missile_homing: + if is_instance_valid(homing_target) and homing_target.is_in_group("virus_allies") and not enemy_shot: + homing_target = null _flight_time += delta if not is_instance_valid(homing_target): _has_locked_target = false @@ -279,7 +281,9 @@ func _explode_at_target(impact_target: Node) -> void: var query := PhysicsShapeQueryParameters2D.new() query.shape = explosion_shape query.transform = Transform2D(0.0, global_position) - query.collision_mask = 2 if enemy_shot else 4 + # Converted ships fire at hostiles; hostile blasts can also hit converted ships. + var converted_shot := enemy_shot and (collision_mask & 4) != 0 + query.collision_mask = 4 if converted_shot or not enemy_shot else 66 for result in get_world_2d().direct_space_state.intersect_shape(query, 32): _apply_damage_once(result.get("collider")) @@ -292,7 +296,9 @@ func _apply_damage_once(target: Variant) -> void: if _already_hit.has(instance_id): return _already_hit[instance_id] = true - if target.has_method("take_weapon_hit"): + if enemy_shot and target is CharacterBody2D and target.has_method("take_damage"): + target.call("take_damage", damage, is_critical, DamageTypes.for_weapon(weapon_kind)) + elif target.has_method("take_weapon_hit"): target.call("take_weapon_hit", damage, is_critical, DamageTypes.for_weapon(weapon_kind)) elif target.has_method("take_damage"): if target is CharacterBody2D: diff --git a/scripts/items/item_definition.gd b/scripts/items/item_definition.gd index f623b13..7fa6805 100644 --- a/scripts/items/item_definition.gd +++ b/scripts/items/item_definition.gd @@ -27,6 +27,7 @@ func get_damage_type() -> int: @export_range(0.0, 1.0, 0.05) var energy_damage_exponent: float = 0.5 @export_range(0.0, 1.0, 0.01) var energy_per_addon: float = 0.05 @export var active_ability: ActiveAbility +@export var passive_effect: String = "" @export_group("Losowanie zakresu obrażeń broni (poziom 1)") @export_range(1, 10000) var damage_lower_roll_min: int = 1 diff --git a/scripts/items/item_generator.gd b/scripts/items/item_generator.gd index 5ffae18..f40652b 100644 --- a/scripts/items/item_generator.gd +++ b/scripts/items/item_generator.gd @@ -1,6 +1,9 @@ extends RefCounted const DEFINITIONS := [ + preload("res://resources/items/recycler.tres"), + preload("res://resources/items/virus.tres"), + preload("res://resources/items/titanium_armor.tres"), preload("res://resources/items/combat_drones.tres"), preload("res://resources/items/teleport.tres"), preload("res://resources/items/cloak.tres"), @@ -49,7 +52,10 @@ static func generate_for_definition(definition: ItemDefinition, level: int, enem item.primary_value_override = (item.damage_min + item.damage_max) * 0.5 item.energy_regeneration_override = item.definition.energy_regeneration_bonus * scale_factor * randf_range(0.8, 1.2) if definition.category == ItemDefinition.Category.ACTIVE_EXTENSION: - item.extension_cooldown = ItemInstance.EXTENSION_COOLDOWNS.roll_seconds() + if item.is_passive_extension(): + item.passive_value = preload("res://scripts/items/passive_extension_rolls.gd").roll_value(definition.passive_effect) + else: + item.extension_cooldown = ItemInstance.EXTENSION_COOLDOWNS.roll_seconds() return item var roll := randf() var count := 1 if roll < 0.5 else (2 if roll < 0.75 else (3 if roll < 0.89 else (4 if roll < 0.97 else 5))) diff --git a/scripts/items/item_instance.gd b/scripts/items/item_instance.gd index b6ee057..099be56 100644 --- a/scripts/items/item_instance.gd +++ b/scripts/items/item_instance.gd @@ -6,6 +6,28 @@ const EXTENSION_COOLDOWNS := preload("res://scripts/items/extension_cooldowns.gd # Exported with the item; old items default to 30 seconds without rerolling. @export_range(5, 30, 1) var extension_cooldown: int = 30 var _instance_ability: ActiveAbility +@export var passive_value: float = -1.0 + +func is_passive_extension() -> bool: + return definition != null and definition.category == ItemDefinition.Category.ACTIVE_EXTENSION and not definition.passive_effect.is_empty() + +func get_passive_value() -> float: + if not is_passive_extension(): + return 0.0 + match definition.passive_effect: + "recycler": return clampf(passive_value, 0.5, 40.0) + "virus": return clampf(floorf(passive_value), 1.0, 20.0) + "titanium_armor": return clampf(passive_value, 5.0, 50.0) + return 0.0 + +func get_passive_description() -> String: + var value := get_passive_value() + match definition.passive_effect: + "recycler": return "Każdy zebrany odłamek asteroidy odnawia %.1f%% maksymalnych tarcz i energii." % value + "virus": return "Przejmuje uciekających wrogów (poniżej 10%% kadłuba). Zamiast uciekać atakują swoich dawnych sojuszników. Limit jednocześnie kontrolowanych: %d." % int(value) + "titanium_armor": return "Pochłania 50%% otrzymywanych obrażeń, gdy kadłub ma nie więcej niż %d%% maksimum. Próg sprawdzany przed trafieniem." % int(value) + return "" + func get_active_ability() -> ActiveAbility: if definition == null or definition.active_ability == null: @@ -24,6 +46,8 @@ func get_active_ability() -> ActiveAbility: description = fixed_cooldown.sub(description, "", true) if definition.item_id == "mine_layer": description = "Zostawia minę na 60 s. Uzbraja się po 0,7 s. Wykrywa bliski przelot wroga. Wybuch w promieniu 140 jednostek zadaje każdemu wrogowi 50% aktualnego kadłuba bez redukcji absorpcji (tarcze pochłaniają obrażenia) i spowalnia o 50% na 5 s." + if definition.item_id == "afterburner": + description = "Szybko rozpędza do 300% zwykłej prędkości (+200%), następnie wytraca nadmiar prędkości. Bez kosztu energii." _instance_ability.description = description + "\nOdnowienie: %d s." % _instance_ability.cooldown return _instance_ability @@ -122,7 +146,7 @@ func get_type_color() -> Color: ItemDefinition.Category.WEAPON: return Color("ae74ef") ItemDefinition.Category.ACTIVE_EXTENSION: - return Color("f39c4d") + return Color("85e5b6") if is_passive_extension() else Color("f39c4d") return Color("65ce8b") func get_tooltip_text() -> String: @@ -133,6 +157,8 @@ func get_tooltip_text() -> String: var primary_text := "%s: +%.1f%s" % [stat_name(definition.primary_stat), get_stat_total(definition.primary_stat), definition.stat_suffix] if definition.category == ItemDefinition.Category.WEAPON: primary_text = "Obrażenia: %s" % get_damage_range_text() + elif is_passive_extension(): + primary_text = get_passive_description() + "\nPASYWNE • Bez kosztu i cooldownu.\nDuplikaty nie sumują efektu — działa najlepszy egzemplarz." elif definition.active_ability != null: primary_text = get_active_ability().description var lines: Array[String] = [definition.display_name, "%s • Poziom %d" % [get_rarity_name(), item_level], "", definition.description, "", "CECHA GŁÓWNA", primary_text] diff --git a/scripts/items/mineral_pickup.gd b/scripts/items/mineral_pickup.gd index ed8af04..43d4a7a 100644 --- a/scripts/items/mineral_pickup.gd +++ b/scripts/items/mineral_pickup.gd @@ -32,6 +32,7 @@ func _physics_process(delta: float) -> void: if distance <= PICKUP_RADIUS: _collected = true GameState.captain.minerals[mineral] = int(GameState.captain.minerals.get(mineral, 0)) + 1 + _player.call("recycle_asteroid_fragment") GameState.captain.emit_changed() var popup := preload("res://scenes/game/damage_popup.tscn").instantiate() as Label popup.text = "+1 %s" % {"iron": "żelaza", "aluminium": "aluminium", "titanium": "tytanu", "uranium": "uranu", "sulfur": "siarki"}.get(mineral, mineral) diff --git a/scripts/items/passive_extension_rolls.gd b/scripts/items/passive_extension_rolls.gd new file mode 100644 index 0000000..eee4ad5 --- /dev/null +++ b/scripts/items/passive_extension_rolls.gd @@ -0,0 +1,22 @@ +extends RefCounted + +const CHANCES := [1.0, 0.25, 0.05, 0.01, 0.0015, 0.0002] + +static func tail_chance(quality: float) -> float: + var scaled := clampf(quality, 0.0, 1.0) * 5.0 + var index := mini(4, floori(scaled)) + return exp(lerpf(log(CHANCES[index]), log(CHANCES[index + 1]), scaled - index)) + +static func rank_from_roll(steps: int, roll: float) -> int: + for rank in range(steps, 0, -1): + if roll < tail_chance(float(rank) / steps): + return rank + return 0 + +static func roll_value(effect: String, roll: float = -1.0) -> float: + var chance := randf() if roll < 0.0 else roll + match effect: + "recycler": return 0.5 + rank_from_roll(79, chance) * 0.5 + "virus": return 1.0 + rank_from_roll(19, chance) + "titanium_armor": return 5.0 + rank_from_roll(45, chance) + return 0.0 diff --git a/scripts/items/passive_extension_rolls.gd.uid b/scripts/items/passive_extension_rolls.gd.uid new file mode 100644 index 0000000..98a3d08 --- /dev/null +++ b/scripts/items/passive_extension_rolls.gd.uid @@ -0,0 +1 @@ +uid://qyharawiegof diff --git a/scripts/items/ship_inventory.gd b/scripts/items/ship_inventory.gd index 20ca0a9..0509ac8 100644 --- a/scripts/items/ship_inventory.gd +++ b/scripts/items/ship_inventory.gd @@ -218,7 +218,7 @@ func _accepts_item(kind: String, index: int, item: ItemInstance) -> bool: if kind == "module": return item.definition.category == MODULE_CATEGORIES[index] if kind == "extension": - return item.definition.category == ItemDefinition.Category.ACTIVE_EXTENSION and item.definition.active_ability != null + return item.definition.category == ItemDefinition.Category.ACTIVE_EXTENSION and (item.definition.active_ability != null or item.is_passive_extension()) return kind == "cargo" func get_missile_count() -> int: diff --git a/scripts/ui/captain_skills_ui.gd b/scripts/ui/captain_skills_ui.gd index 72c3923..e00cad8 100644 --- a/scripts/ui/captain_skills_ui.gd +++ b/scripts/ui/captain_skills_ui.gd @@ -24,12 +24,12 @@ func _ready() -> void: _screen.hide() get_parent().get_node("HUDRoot/FloatingWindows").set("modal_overlay", _screen) var dim := ColorRect.new() - dim.color = Color(0.015, 0.025, 0.045, 0.9) + dim.color = Color(0.015, 0.025, 0.045, 0.3) _screen.add_child(dim) dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) _panel = PanelContainer.new() _panel.name = "Panel" - _panel.add_theme_stylebox_override("panel", _style(Color("567b99"), Color("0a1422"), 22)) + _panel.add_theme_stylebox_override("panel", _style(Color("567b99"), Color("0a1422c7"), 22)) _screen.add_child(_panel) _panel.set_anchors_and_offsets_preset(Control.PRESET_CENTER) _contents = VBoxContainer.new() diff --git a/scripts/ui/equipment_slot_view.gd b/scripts/ui/equipment_slot_view.gd index f0e354b..fe183fb 100644 --- a/scripts/ui/equipment_slot_view.gd +++ b/scripts/ui/equipment_slot_view.gd @@ -343,17 +343,21 @@ func _get_drag_data(_at_position: Vector2) -> Variant: var item: ItemInstance = _inventory.get_item(slot_kind, slot_index) if item == null: return null - if item.definition.icon != null: - var preview := TextureRect.new() - preview.texture = item.definition.icon - preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - preview.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - preview.custom_minimum_size = Vector2(64, 64) - set_drag_preview(preview) - else: - var preview := Label.new() - preview.text = item.definition.display_name - set_drag_preview(preview) + # Native preview lives in the viewport's base canvas, below HUD CanvasLayers. + # Keep its drag lifecycle, but draw the centered icon in a dedicated top layer. + var overlay := get_tree().get_first_node_in_group("item_drag_overlay") + if overlay == null: + overlay = CanvasLayer.new() + overlay.name = "ItemDragOverlay" + overlay.set_script(preload("res://scripts/ui/item_drag_overlay.gd")) + var host := get_tree().current_scene + if host == null: + host = get_viewport() + host.add_child(overlay) + overlay.call("begin_drag", item) + var native_preview := Control.new() + native_preview.mouse_filter = Control.MOUSE_FILTER_IGNORE + set_drag_preview(native_preview) return {"from_kind": slot_kind, "from_index": slot_index} func _can_drop_data(_at_position: Vector2, data: Variant) -> bool: diff --git a/scripts/ui/flight_cursor.gd b/scripts/ui/flight_cursor.gd new file mode 100644 index 0000000..c8e1413 --- /dev/null +++ b/scripts/ui/flight_cursor.gd @@ -0,0 +1,24 @@ +extends Node + +const AIM_TEXTURE := preload("res://assets/icons/ui/aim_cursor.svg") +const HOTSPOT := Vector2(24, 24) +var player: CharacterBody2D +var aiming := false + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + +func should_aim() -> bool: + return is_instance_valid(player) and not bool(player.get("_is_destroyed")) and not bool(player.get("is_docked")) and not get_tree().paused and not get_viewport().gui_is_dragging() and get_viewport().gui_get_hovered_control() == null + +func _process(_delta: float) -> void: + var next := should_aim() + if next == aiming: + return + aiming = next + # A hardware cursor keeps the hotspot exactly on the weapon aim point. + Input.set_custom_mouse_cursor(AIM_TEXTURE if aiming else null, Input.CURSOR_ARROW, HOTSPOT if aiming else Vector2.ZERO) + +func _exit_tree() -> void: + if aiming: + Input.set_custom_mouse_cursor(null, Input.CURSOR_ARROW) diff --git a/scripts/ui/flight_cursor.gd.uid b/scripts/ui/flight_cursor.gd.uid new file mode 100644 index 0000000..7a7a39a --- /dev/null +++ b/scripts/ui/flight_cursor.gd.uid @@ -0,0 +1 @@ +uid://ct7b1pxcenj3l diff --git a/scripts/ui/item_drag_overlay.gd b/scripts/ui/item_drag_overlay.gd new file mode 100644 index 0000000..8824020 --- /dev/null +++ b/scripts/ui/item_drag_overlay.gd @@ -0,0 +1,67 @@ +extends CanvasLayer + +const ICON_SIZE := Vector2(64, 64) +var _view: PanelContainer +var _picture: TextureRect +var _fallback: Label +var _started_frame := -1 +var _pointer_position := Vector2.ZERO + +func _ready() -> void: + layer = preload("res://scripts/ui/render_layers.gd").DRAG_PREVIEW + process_mode = Node.PROCESS_MODE_ALWAYS + add_to_group("item_drag_overlay") + _view = PanelContainer.new() + _view.name = "DragIcon" + _view.mouse_filter = Control.MOUSE_FILTER_IGNORE + _view.size = ICON_SIZE + add_child(_view) + _picture = TextureRect.new() + _picture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + _picture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + _picture.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS + _picture.mouse_filter = Control.MOUSE_FILTER_IGNORE + _view.add_child(_picture) + _fallback = Label.new() + _fallback.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _fallback.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _fallback.mouse_filter = Control.MOUSE_FILTER_IGNORE + _fallback.add_theme_font_size_override("font_size", 10) + _view.add_child(_fallback) + _view.hide() + +func begin_drag(item: ItemInstance) -> void: + _started_frame = Engine.get_process_frames() + _pointer_position = get_viewport().get_mouse_position() + _picture.texture = item.definition.icon + _fallback.text = item.definition.display_name if item.definition.icon == null else "" + _fallback.visible = item.definition.icon == null + var frame := StyleBoxFlat.new() + frame.bg_color = Color(0.025, 0.045, 0.065, 0.8) + frame.border_color = item.get_type_color() + frame.set_border_width_all(1) + frame.set_corner_radius_all(5) + frame.set_content_margin_all(5) + frame.shadow_color = Color(0, 0, 0, 0.45) + frame.shadow_size = 4 + _view.add_theme_stylebox_override("panel", frame) + _view.size = ICON_SIZE + _view.show() + _update_position() + +func _input(event: InputEvent) -> void: + if _view.visible and (event is InputEventMouseMotion or event is InputEventMouseButton): + _pointer_position = event.position + _update_position() + +func _update_position() -> void: + _view.position = _pointer_position - _view.size * 0.5 + +func _process(_delta: float) -> void: + if not _view.visible: + return + if get_viewport().gui_is_dragging(): + _update_position() + elif Engine.get_process_frames() > _started_frame + 1: + _view.hide() + _picture.texture = null diff --git a/scripts/ui/item_drag_overlay.gd.uid b/scripts/ui/item_drag_overlay.gd.uid new file mode 100644 index 0000000..bcd2000 --- /dev/null +++ b/scripts/ui/item_drag_overlay.gd.uid @@ -0,0 +1 @@ +uid://chsbheqg4n8m4 diff --git a/scripts/ui/main_menu.gd b/scripts/ui/main_menu.gd index afb06f9..c234ba3 100644 --- a/scripts/ui/main_menu.gd +++ b/scripts/ui/main_menu.gd @@ -9,6 +9,74 @@ var _ship_panel: Control var _panel_open := false var _launching := false var _drawer_tween: Tween +var _guide: Control +var _instruction_button: Button + +func _style_menu() -> void: + $Background.mouse_filter = Control.MOUSE_FILTER_IGNORE + $Shade.mouse_filter = Control.MOUSE_FILTER_IGNORE + var atmosphere := Control.new() + atmosphere.name = "MenuAtmosphere" + atmosphere.set_script(preload("res://scripts/ui/menu_atmosphere.gd")) + add_child(atmosphere) + move_child(atmosphere, $MenuLayout.get_index()) + var layout := $MenuLayout as VBoxContainer + layout.add_theme_constant_override("separation", 10) + $MenuLayout/Spacer.hide() + var eyebrow := Label.new() + eyebrow.text = "DZIENNIK POKŁADOWY / NOWA WYPRAWA" + eyebrow.add_theme_font_override("font", ThemeDB.fallback_font) + eyebrow.add_theme_font_size_override("font_size", 11) + eyebrow.add_theme_color_override("font_color", Color("91dbea")) + layout.add_child(eyebrow) + layout.move_child(eyebrow, 0) + var title := $MenuLayout/Title as Label + title.add_theme_font_size_override("font_size", 46) + title.add_theme_color_override("font_color", Color("f4ead9")) + var subtitle := Label.new() + subtitle.text = "Nieznany kosmos. Niepowtarzalny łup." + subtitle.add_theme_font_override("font", ThemeDB.fallback_font) + subtitle.add_theme_font_size_override("font_size", 15) + subtitle.add_theme_color_override("font_color", Color("a9bcc8")) + layout.add_child(subtitle) + layout.move_child(subtitle, title.get_index() + 1) + var space := Control.new() + space.custom_minimum_size.y = 10 + layout.add_child(space) + layout.move_child(space, subtitle.get_index() + 1) + _instruction_button = Button.new() + _instruction_button.name = "InstructionsButton" + layout.add_child(_instruction_button) + layout.move_child(_instruction_button, start_button.get_index() + 1) + var entries := [ + [start_button, "NOWA WYPRAWA", "Wybierz statek i ruszaj w nieznane", "01", true], + [_instruction_button, "INSTRUKCJA", "Pierwszy lot? Zacznij tutaj", "02", false], + [$MenuLayout/SaveButton, "ZAPIS ROZGRYWKI", "Wgraj / zapisz — wkrótce", "03", false], + [$MenuLayout/OptionsButton, "OPCJE", "Ustawienia — wkrótce", "04", false], + [exit_button, "WYJŚCIE", "Zakończ grę", "05", false], + ] + for entry in entries: + var button: Button = entry[0] + button.set_script(preload("res://scripts/ui/main_menu_button.gd")) + button.call("configure", entry[1], entry[2], entry[3], entry[4]) + $MenuLayout/SaveButton.disabled = true + $MenuLayout/OptionsButton.disabled = true + _guide = Control.new() + _guide.set_script(preload("res://scripts/ui/player_guide.gd")) + add_child(_guide) + _instruction_button.pressed.connect(func() -> void: _guide.call("open")) + _guide.connect("closed", _instruction_button.grab_focus) + resized.connect(_layout_menu) + _layout_menu.call_deferred() + +func _layout_menu() -> void: + var layout := $MenuLayout as VBoxContainer + layout.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT) + var factor := minf(1.0, minf((size.y - 120.0) / 520.0, (size.x - 64.0) / 420.0)) + layout.scale = Vector2.ONE * factor + layout.size = Vector2(420, layout.get_combined_minimum_size().y) + layout.position = Vector2(maxf(32.0, size.x * 0.065), maxf(68.0, (size.y - layout.size.y * factor) * 0.5)) + func _ready() -> void: start_button.pressed.connect(_start_game) @@ -22,6 +90,7 @@ func _ready() -> void: _ship_panel.connect("close_requested", _close_ship_panel) resized.connect(_layout_ship_panel) _layout_ship_panel() + _style_menu() func _start_game() -> void: if _panel_open or _launching: diff --git a/scripts/ui/main_menu_button.gd b/scripts/ui/main_menu_button.gd new file mode 100644 index 0000000..f9b2018 --- /dev/null +++ b/scripts/ui/main_menu_button.gd @@ -0,0 +1,55 @@ +extends Button + +const DISPLAY_FONT := preload("res://assets/fonts/font2.ttf") +var caption := "" +var detail := "" +var symbol := "01" +var primary := false +var _hover := 0.0 + +func configure(title: String, subtitle: String, marker: String, main: bool = false) -> void: + caption = title + detail = subtitle + symbol = marker + primary = main + text = "" + custom_minimum_size = Vector2(280, 72 if primary else 60) + mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + for state in ["normal", "hover", "pressed", "focus", "disabled"]: + add_theme_stylebox_override(state, StyleBoxEmpty.new()) + tooltip_text = title + queue_redraw() + +func _process(delta: float) -> void: + var target := 1.0 if (is_hovered() or has_focus()) and not disabled else 0.0 + var next := move_toward(_hover, target, delta * 6.0) + if next != _hover: + _hover = next + queue_redraw() + +func _draw() -> void: + var accent := Color("f1bd79") if primary else Color("86d5e8") + if disabled: + accent = Color("607180") + var inset := 2.0 if button_pressed else 0.0 + var rect := Rect2(Vector2(inset, inset), size - Vector2.ONE * inset * 2) + var points := PackedVector2Array([rect.position, Vector2(rect.end.x - 14, rect.position.y), Vector2(rect.end.x, rect.position.y + 14), rect.end, Vector2(rect.position.x + 10, rect.end.y), Vector2(rect.position.x, rect.end.y - 10)]) + var base := Color(0.028, 0.067, 0.10, 0.88).lerp(Color(0.09, 0.19, 0.25, 0.96), _hover) + if primary: + base = Color(0.17, 0.12, 0.075, 0.92).lerp(Color(0.29, 0.20, 0.105, 0.98), _hover) + draw_colored_polygon(points, base) + var edge := points.duplicate() + edge.append(points[0]) + draw_polyline(edge, Color(accent, 0.32 + _hover * 0.6), 1.0, true) + draw_line(Vector2(1, 10), Vector2(1, size.y - 10), Color(accent, 0.85), 3.0, true) + draw_line(Vector2(16, 1), Vector2(68 + _hover * 70, 1), Color(accent, 0.7), 1.0, true) + var body_font := ThemeDB.fallback_font + draw_string(body_font, Vector2(18, size.y * 0.5 + 5), symbol, HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(accent, 0.65)) + draw_line(Vector2(47, 17), Vector2(47, size.y - 17), Color(accent, 0.22), 1) + var label_color := Color("f4ead9") if primary else Color("e0edf3") + if disabled: + label_color = Color("82909d") + draw_string(DISPLAY_FONT, Vector2(62 + _hover * 3, 29 if primary else 24), caption, HORIZONTAL_ALIGNMENT_LEFT, -1, 17 if primary else 14, label_color) + draw_string(body_font, Vector2(62, 50 if primary else 43), detail, HORIZONTAL_ALIGNMENT_LEFT, size.x - 104, 12, Color("a1acb9") if disabled else Color("a9bcc8")) + var tip := Vector2(size.x - 23 + _hover * 3, size.y * 0.5) + draw_polyline(PackedVector2Array([tip + Vector2(-5, -5), tip, tip + Vector2(-5, 5)]), Color(accent, 0.5 + _hover * 0.5), 1.5, true) diff --git a/scripts/ui/main_menu_button.gd.uid b/scripts/ui/main_menu_button.gd.uid new file mode 100644 index 0000000..e0d16f3 --- /dev/null +++ b/scripts/ui/main_menu_button.gd.uid @@ -0,0 +1 @@ +uid://bmb2i1t505ol4 diff --git a/scripts/ui/menu_atmosphere.gd b/scripts/ui/menu_atmosphere.gd new file mode 100644 index 0000000..90b0d15 --- /dev/null +++ b/scripts/ui/menu_atmosphere.gd @@ -0,0 +1,15 @@ +extends Control + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + resized.connect(queue_redraw) + +func _draw() -> void: + var reach := minf(size.x * 0.72, 850.0) + draw_polygon(PackedVector2Array([Vector2.ZERO, Vector2(reach, 0), Vector2(reach, size.y), Vector2(0, size.y)]), PackedColorArray([Color(0.012, 0.024, 0.042, 0.94), Color(0.012, 0.024, 0.042, 0), Color(0.012, 0.024, 0.042, 0), Color(0.012, 0.024, 0.042, 0.94)])) + draw_line(Vector2(32, 36), Vector2(90, 36), Color("91dbea"), 2) + draw_line(Vector2(32, 36), Vector2(32, 62), Color(0.57, 0.86, 0.92, 0.4), 1) + draw_line(Vector2(32, size.y - 36), Vector2(size.x - 32, size.y - 36), Color(0.57, 0.86, 0.92, 0.17), 1) + draw_string(ThemeDB.fallback_font, Vector2(48, size.y - 16), "RANDOLOOT / EKSPLORUJ • WALCZ • ROZWIJAJ STATEK", HORIZONTAL_ALIGNMENT_LEFT, -1, 11, Color("91a4b7")) + draw_string(ThemeDB.fallback_font, Vector2(size.x - 215, size.y - 16), "NIEZNANY UKŁAD. TWOJA WYPRAWA.", HORIZONTAL_ALIGNMENT_LEFT, -1, 10, Color("829aac")) diff --git a/scripts/ui/menu_atmosphere.gd.uid b/scripts/ui/menu_atmosphere.gd.uid new file mode 100644 index 0000000..ab47d69 --- /dev/null +++ b/scripts/ui/menu_atmosphere.gd.uid @@ -0,0 +1 @@ +uid://ilqfl3an0hl7 diff --git a/scripts/ui/mission_objectives.gd b/scripts/ui/mission_objectives.gd index 70e83f6..1ce1a16 100644 --- a/scripts/ui/mission_objectives.gd +++ b/scripts/ui/mission_objectives.gd @@ -39,12 +39,8 @@ func _process(_delta: float) -> void: _objective.text = "Pokonaj bossa" else: _objective.text = "Pokonaj wszystkich wrogów" - var cockpit := get_parent().get_node_or_null("Cockpit") as Control - var available_width := cockpit.position.x - 24.0 if is_instance_valid(cockpit) else 260.0 - var panel_width := clampf(available_width, 180.0, 260.0) - size.x = panel_width - size.y = get_combined_minimum_size().y - var bottom: float = (get_parent() as Control).size.y - 12.0 - if available_width < 180.0 and is_instance_valid(cockpit): - bottom = cockpit.position.y - 64.0 - position = Vector2(12.0, bottom - size.y) + var captain := get_parent().get_node_or_null("CaptainPanel") as Control + if is_instance_valid(captain): + size.x = captain.size.x + size.y = get_combined_minimum_size().y + position = captain.position + Vector2(0.0, captain.size.y + 8.0) diff --git a/scripts/ui/passive_status_panel.gd b/scripts/ui/passive_status_panel.gd new file mode 100644 index 0000000..6a4fc68 --- /dev/null +++ b/scripts/ui/passive_status_panel.gd @@ -0,0 +1,32 @@ +extends PanelContainer + +var caption := "PASYWNE" + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + var frame := StyleBoxFlat.new() + frame.bg_color = Color(0.025, 0.041, 0.063, 0.55) + frame.border_color = Color(0.28, 0.48, 0.55, 0.45) + frame.set_border_width_all(1) + frame.set_corner_radius_all(6) + frame.set_content_margin_all(10) + add_theme_stylebox_override("panel", frame) + var contents := VBoxContainer.new() + contents.name = "Contents" + contents.mouse_filter = Control.MOUSE_FILTER_IGNORE + contents.add_theme_constant_override("separation", 8) + add_child(contents) + var title := Label.new() + title.text = caption + title.mouse_filter = Control.MOUSE_FILTER_IGNORE + title.add_theme_font_override("font", ThemeDB.fallback_font) + title.add_theme_font_size_override("font_size", 11) + title.add_theme_color_override("font_color", Color("91dbea")) + contents.add_child(title) + var icons := GridContainer.new() + icons.name = "Icons" + icons.columns = 5 + icons.mouse_filter = Control.MOUSE_FILTER_IGNORE + icons.add_theme_constant_override("h_separation", 6) + icons.add_theme_constant_override("v_separation", 6) + contents.add_child(icons) diff --git a/scripts/ui/passive_status_panel.gd.uid b/scripts/ui/passive_status_panel.gd.uid new file mode 100644 index 0000000..9d834c6 --- /dev/null +++ b/scripts/ui/passive_status_panel.gd.uid @@ -0,0 +1 @@ +uid://d1pekaexu4wan diff --git a/scripts/ui/player_guide.gd b/scripts/ui/player_guide.gd new file mode 100644 index 0000000..5ebe8ca --- /dev/null +++ b/scripts/ui/player_guide.gd @@ -0,0 +1,119 @@ +extends Control + +signal closed +const BUTTON_STYLE := preload("res://scripts/ui/hud_button_style.gd") +const PAGES := [ + ["Pierwsza wyprawa", "01", "[font_size=26][color=#f1bd79]Twój pierwszy układ[/color][/font_size]\n\nWybierz statek przyciskiem [b]Nowa wyprawa[/b]. Każdy kadłub ma inne parametry — na początek wybierz ten, który pasuje do Twojego stylu.\n\n[color=#91dbea][b]1. Pokonaj zwykłych wrogów[/b][/color]\nOdszukuj przeciwników, rozwijaj wyposażenie i pilnuj zasobów. Aktualny cel znajdziesz w lewym dolnym rogu ekranu.\n\n[color=#91dbea][b]2. Pokonaj bossa[/b][/color]\nGdy zniszczysz pozostałych wrogów, w układzie pojawi się boss. Przygotuj zapas amunicji i sprawdź kadłub przed walką.\n\n[color=#91dbea][b]3. Znajdź zamieszkaną planetę[/b][/color]\nPo zwycięstwie podleć do zamieszkanej planety. Skorzystaj z usług i otwórz wormhole. Wleć w jego środek, żeby przejść do następnego, trudniejszego układu."], + ["Sterowanie", "02", "[font_size=26][color=#f1bd79]Opanuj statek[/color][/font_size]\n\n[color=#91dbea][b]W[/b][/color] — ciąg do przodu.\n[color=#91dbea][b]S[/b][/color] — hamowanie; po zatrzymaniu lot wstecz.\n[color=#91dbea][b]A / D[/b][/color] — obracanie statku.\n[color=#91dbea][b]Q / E[/b][/color] — ruch boczny w lewo / prawo.\n[color=#91dbea][b]TAB[/b][/color] — dopalanie: większa prędkość kosztem energii.\n\n[color=#91dbea][b]Kursor myszy[/b][/color] — celowanie.\n[color=#91dbea][b]LPM / PPM / ŚPM[/b][/color] — strzelanie z trzech slotów broni.\n[color=#91dbea][b]1–5[/b][/color] — użycie zamontowanych rozszerzeń.\n[color=#91dbea][b]F1–F5[/b][/color] — aktywne umiejętności kapitana.\n[color=#91dbea][b]R[/b][/color] — użycie powerbanku, jeśli masz go w zapasach.\n\nStatek ma bezwładność. Obrót nie zatrzymuje ruchu — hamuj wcześniej i wykorzystuj ruch boczny, żeby unikać ostrzału."], + ["Walka i przetrwanie", "03", "[font_size=26][color=#f1bd79]Wróć z wyprawy[/color][/font_size]\n\n[color=#91dbea][b]Tarcze, kadłub i energia[/b][/color]\nTarcze pochłaniają obrażenia przed kadłubem. Utrata całego kadłuba oznacza zniszczenie statku. Energia zasila część broni oraz dopalanie; odnawia się z czasem, szybciej przy gwieździe.\n\n[color=#91dbea][b]Dobieraj broń do zapasów[/b][/color]\nBroń energetyczna zużywa energię. Działko maszynowe potrzebuje zwykłej amunicji, gamma — napromieniowanej, a wyrzutnie — rakiet. Puste zapasy oznaczają brak strzału. Absorpcja ogranicza obrażenia danego typu.\n\n[color=#91dbea][b]Korzystaj z rozszerzeń[/b][/color]\nMiny atakują obszarowo, flary odciągają uwagę, kamuflaż pomaga zerwać kontakt, teleport przenosi w bezpieczne miejsce, a drony wspierają ostrzał. Doładowanie tarcz działa przy gwieździe. Sprawdzaj koszt i cooldown w opisie.\n\n[color=#f1bd79][b]Uważaj na uciekających przeciwników[/b][/color]\nWróg z kadłubem poniżej 10% ucieka wolniej, ale może zostawić minę. Nie leć bezmyślnie prosto za nim."], + ["Łup i wytwarzanie", "04", "[font_size=26][color=#f1bd79]Zbieraj. Porównuj. Twórz.[/color][/font_size]\n\n[color=#91dbea][b]Łup zbierasz przez podlecenie[/b][/color]\nPrzedmioty w pobliżu są przyciągane do statku. Magazyn mieści 20 przedmiotów; przy pełnym magazynie zwolnij miejsce, żeby zebrać kolejny.\n\n[color=#91dbea][b]Asteroidy zostawiają fizyczne odłamki[/b][/color]\nStrzelaj do asteroid. Po zniszczeniu rozpadają się bazowo na 5–7 poszarpanych fragmentów. Każdy to jedna jednostka surowca — musisz go zebrać. Umiejętność górnika zwiększa uzysk.\n\n[color=#91dbea][b]Przeszukuj wraki[/b][/color]\nPodleć do wraku i przytrzymaj E, aby go przeszukać. Możesz znaleźć zapasy lub wyposażenie.\n\n[color=#91dbea][b]Warsztat — przycisk TWÓRZ[/b][/color]\nZ minerałów wytworzysz części, amunicję, rakiety i powerbanki. Receptura pokazuje wymagane składniki oraz ilość produktu.\n\n[color=#91dbea][b]Odzyskuj części[/b][/color]\nNajedź na zbędny przedmiot w magazynie i użyj przycisku rozłożenia. Części przydadzą się do napraw."], + ["Statek i kapitan", "05", "[font_size=26][color=#f1bd79]Buduj własny styl gry[/color][/font_size]\n\n[color=#91dbea][b]Wyposażenie[/b][/color]\nKliknij panel statku w prawym górnym rogu i otwórz magazyn. Przeciągaj przedmioty do pasujących slotów: 3 bronie, 5 modułów i 5 rozszerzeń. Aktywne rozszerzenia uruchamiasz klawiszami 1–5; pasywne (oznaczone P) działają automatycznie w tych samych slotach. Najedź kursorem, żeby sprawdzić parametry i porównanie wyposażenia.\n\n[color=#91dbea][b]Nie każdy duplikat jest taki sam[/b][/color]\nBroń i moduły mogą mieć różne parametry oraz addony. Aktywne rozszerzenia losują cooldown od 30 do 5 pełnych sekund. Pasywne mają losowaną siłę efektu: Recykler odnawia tarcze i energię po zebraniu odłamka, Wirus przejmuje uciekających wrogów, a Pancerz z tytanu ogranicza obrażenia przy niskim kadłubie. Krótsze odnowienia są rzadkie — przed rozłożeniem porównaj egzemplarze. Rozszerzenia tego samego typu dzielą aktywne odnowienie.\n\n[color=#91dbea][b]Umiejętności kapitana[/b][/color]\nZa pokonanych wrogów zdobywasz doświadczenie i kredyty. Po awansie wybierasz umiejętność. Pasywne działają stale, aktywne przypisujesz do F1–F5.\n\n[color=#f1bd79][b]Okno umiejętności nie pauzuje gry.[/b][/color]\nOtwieraj je w bezpiecznym miejscu. Przed kolejnym układem uzupełnij zapasy i sprawdź wyposażenie."] +] +var _panel: PanelContainer +var _reader: RichTextLabel +var _tabs: Array[Button] = [] +var _close_button: Button + +func _ready() -> void: + name = "PlayerGuide" + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + var dim := ColorRect.new() + dim.color = Color(0.005, 0.012, 0.025, 0.82) + add_child(dim) + dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _panel = PanelContainer.new() + var style := StyleBoxFlat.new() + style.bg_color = Color("0b1725") + style.border_color = Color("46798a") + style.set_border_width_all(1) + style.set_corner_radius_all(10) + style.set_content_margin_all(24) + _panel.add_theme_stylebox_override("panel", style) + add_child(_panel) + var contents := VBoxContainer.new() + contents.add_theme_constant_override("separation", 18) + _panel.add_child(contents) + var header := HBoxContainer.new() + contents.add_child(header) + var heading := Label.new() + heading.text = "INSTRUKCJA / AKADEMIA PILOTA" + heading.size_flags_horizontal = Control.SIZE_EXPAND_FILL + heading.add_theme_font_size_override("font_size", 16) + heading.add_theme_color_override("font_color", Color("91dbea")) + header.add_child(heading) + _close_button = Button.new() + _close_button.text = "ZAMKNIJ [ESC]" + BUTTON_STYLE.apply(_close_button) + _close_button.pressed.connect(close) + header.add_child(_close_button) + contents.add_child(HSeparator.new()) + var body := HBoxContainer.new() + body.size_flags_vertical = Control.SIZE_EXPAND_FILL + body.add_theme_constant_override("separation", 24) + contents.add_child(body) + var navigation := VBoxContainer.new() + navigation.custom_minimum_size.x = 190 + navigation.add_theme_constant_override("separation", 8) + body.add_child(navigation) + for index in PAGES.size(): + var button := Button.new() + button.text = "%s %s" % [PAGES[index][1], PAGES[index][0]] + button.alignment = HORIZONTAL_ALIGNMENT_LEFT + button.custom_minimum_size.y = 44 + BUTTON_STYLE.apply(button) + button.add_theme_font_override("font", ThemeDB.fallback_font) + button.add_theme_font_size_override("font_size", 14) + button.pressed.connect(_show_page.bind(index)) + navigation.add_child(button) + _tabs.append(button) + _reader = RichTextLabel.new() + _reader.name = "GuideText" + _reader.bbcode_enabled = true + _reader.scroll_active = true + _reader.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _reader.size_flags_vertical = Control.SIZE_EXPAND_FILL + _reader.add_theme_font_override("normal_font", ThemeDB.fallback_font) + _reader.add_theme_font_override("bold_font", ThemeDB.fallback_font) + _reader.add_theme_font_size_override("normal_font_size", 17) + _reader.add_theme_constant_override("line_separation", 5) + _reader.add_theme_color_override("default_color", Color("c5d4df")) + body.add_child(_reader) + var tip := Label.new() + tip.text = "Pierwszy cel: pokonaj wrogów → pokonaj bossa → znajdź zamieszkaną planetę." + tip.add_theme_font_override("font", ThemeDB.fallback_font) + tip.add_theme_font_size_override("font_size", 13) + tip.add_theme_color_override("font_color", Color("8ca5b8")) + contents.add_child(tip) + resized.connect(_layout) + _layout() + _show_page(0) + hide() + +func _layout() -> void: + if _panel == null: + return + var base := Vector2(1040, 620) + var factor := minf(1.0, minf((size.x - 32) / base.x, (size.y - 32) / base.y)) + _panel.size = base + _panel.scale = Vector2.ONE * factor + _panel.position = (size - base * factor) * 0.5 + +func _show_page(index: int) -> void: + _reader.text = PAGES[index][2] + _reader.scroll_to_line(0) + for tab_index in _tabs.size(): + _tabs[tab_index].modulate = Color("f1bd79") if tab_index == index else Color.WHITE + +func open() -> void: + show() + _show_page(0) + _close_button.grab_focus() + +func close() -> void: + hide() + closed.emit() + +func _unhandled_key_input(event: InputEvent) -> void: + if visible and event.is_action_pressed("ui_cancel"): + close() + get_viewport().set_input_as_handled() diff --git a/scripts/ui/player_guide.gd.uid b/scripts/ui/player_guide.gd.uid new file mode 100644 index 0000000..4c9ac87 --- /dev/null +++ b/scripts/ui/player_guide.gd.uid @@ -0,0 +1 @@ +uid://bsn4c58wliq4v diff --git a/scripts/ui/render_layers.gd b/scripts/ui/render_layers.gd index 8ea1dfc..575a00c 100644 --- a/scripts/ui/render_layers.gd +++ b/scripts/ui/render_layers.gd @@ -7,3 +7,4 @@ const HUD := 20 const MANAGEMENT := 40 const DEFEAT := 50 const WINDOW_Z := 100 +const DRAG_PREVIEW := 100 diff --git a/tests/abilities/passive_extensions_test.gd b/tests/abilities/passive_extensions_test.gd new file mode 100644 index 0000000..0fb56f3 --- /dev/null +++ b/tests/abilities/passive_extensions_test.gd @@ -0,0 +1,142 @@ +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 ROLLS := preload("res://scripts/items/passive_extension_rolls.gd") +const ORE := preload("res://scripts/items/mineral_pickup.gd") +var ship: CharacterBody2D +var inventory: ShipInventory + +func equip(id: String, slot: int, value: float) -> ItemInstance: + var item := GENERATOR.generate_for_definition(load("res://resources/items/%s.tres" % id), 1) + assert(item.is_passive_extension() and item.definition.icon != null) + item.passive_value = value + assert(inventory.add_item(item)) + assert(inventory.move_item("cargo", inventory.cargo.find(item), "extension", slot)) + assert(not ship.activate_ability(slot)) + assert(item.get_tooltip_text().contains("PASYWNE")) + return item + +func enemy_at(point: Vector2) -> CharacterBody2D: + var enemy := ENEMY.instantiate() as CharacterBody2D + enemy.position = point + add_child(enemy) + enemy.set("_player", ship) + enemy.set_physics_process(false) + enemy.set("_weapon_timer", 100.0) + return enemy + +func _ready() -> void: + await get_tree().process_frame + GameState.start_new_run() + ship = PLAYER.instantiate() + ship.name = "PlayerShip" + add_child(ship) + ship.set_physics_process(false) + ship.get_node("WeaponController").set_physics_process(false) + ship.get_node("ExtensionSystems").set_physics_process(false) + inventory = ship.get_node("ShipInventory") + var recycler := equip("recycler", 0, 0.5) + var virus := equip("virus", 1, 1) + var armor := equip("titanium_armor", 2, 5) + # Per-item values are stable across resource serialization. + var encoded := var_to_bytes_with_objects(recycler) + var restored: ItemInstance = bytes_to_var_with_objects(encoded) + assert(restored.get_passive_value() == 0.5) + # Every discrete value is reachable, with strict exponential tails. + for effect in ["recycler", "virus", "titanium_armor"]: + var steps: int = {"recycler": 79, "virus": 19, "titanium_armor": 45}[effect] + var base: float = {"recycler": 0.5, "virus": 1.0, "titanium_armor": 5.0}[effect] + var step := 0.5 if effect == "recycler" else 1.0 + assert(is_equal_approx(ROLLS.roll_value(effect, 0.0), base + steps * step)) + assert(is_equal_approx(ROLLS.roll_value(effect, 0.99999), base)) + for rank in range(steps + 1): + var high := ROLLS.tail_chance(float(rank) / steps) + var low := ROLLS.tail_chance(float(rank + 1) / steps) if rank < steps else 0.0 + assert(is_equal_approx(ROLLS.roll_value(effect, (high + low) * 0.5), base + rank * step)) + assert(is_equal_approx(ROLLS.tail_chance(1.0), 0.0002)) + # Actual ore pickup both credits one mineral and restores resources once. + ship.energy = 0 + ship.shields = 0 + var before := int(GameState.captain.minerals.get("iron", 0)) + var ore := Node2D.new() + ore.set_script(ORE) + add_child(ore) + ore.set_physics_process(false) + ore.call("_physics_process", 1.0) + assert(is_equal_approx(ship.energy, ship.max_energy * 0.005)) + assert(is_equal_approx(ship.shields, ship.max_shields * 0.005)) + assert(int(GameState.captain.minerals.get("iron", 0)) == before + 1) + ore.call("_physics_process", 1.0) + assert(int(GameState.captain.minerals.get("iron", 0)) == before + 1) + # Best duplicate wins; restoration is capped, not additive. + recycler.passive_value = 40 + equip("recycler", 3, 10) + ship.energy = ship.max_energy * 0.9 + ship.shields = ship.max_shields * 0.9 + ship.recycle_asteroid_fragment() + assert(ship.energy == ship.max_energy and ship.shields == ship.max_shields) + assert(ship.get_passive_extension_value("recycler") == 40) + # Armor reduces incoming damage (including shields) only at its threshold. + ship.hull = ship.max_hull * 0.05 + ship.shields = 50 + ship.take_damage(20) + assert(is_equal_approx(ship.shields, 40)) + ship.hull = ship.max_hull * 0.051 + ship.shields = 50 + ship.take_damage(20) + assert(is_equal_approx(ship.shields, 30)) + armor.passive_value = 50 + ship.hull = ship.max_hull * 0.5 + assert(is_equal_approx(ship.get_damage_reduction(), 0.5)) + # Virus takes fleeing ships only, respects capacity and switches allegiance. + var first := enemy_at(Vector2(200, 0)) + var second := enemy_at(Vector2(320, 0)) + var third := enemy_at(Vector2(450, 0)) + first.hull = 1 + second.hull = 1 + first.call("_physics_process", 0.01) + second.call("_physics_process", 0.01) + assert(first.virus_controlled and not second.virus_controlled) + assert(not first.is_in_group("enemies") and first.is_in_group("virus_allies")) + assert(not first.is_retreating() and first.select_combat_target() != ship) + assert(second.select_combat_target() == first) + await get_tree().physics_frame + assert(first.collision_layer == 64) + # Controlled fire uses enemy damage, cannot hit the player and can hit hostiles. + first.weapon_kind = ItemDefinition.WeaponKind.MACHINE_GUN + first.call("_fire_plasma", Vector2.RIGHT) + var bullet: Node + for child in get_children(): + if child is Area2D and child.get_script() == preload("res://scripts/game/weapon_projectile.gd"): + bullet = child + assert(bullet != null and bullet.collision_mask == 37 and bullet.enemy_shot) + var shields_before: float = third.shields + bullet.call("_apply_damage_once", third) + assert(third.shields < shields_before) + # Converted missile splash must never damage the player. + var player_shields: float = ship.shields + bullet.weapon_kind = ItemDefinition.WeaponKind.MISSILE + bullet.global_position = ship.global_position + bullet.call("_explode_at_target", third) + assert(ship.shields == player_shields) + # Increased capacity takes another ship; downgrade and unequip release extras. + virus.passive_value = 2 + second.call("_physics_process", 0.01) + assert(second.virus_controlled) + virus.passive_value = 1 + ship.call("_sync_virus_allies") + assert(get_tree().get_nodes_in_group("virus_allies").size() == 1) + assert(inventory.move_item("extension", 1, "cargo", 2)) + ship.call("_sync_virus_allies") + assert(get_tree().get_nodes_in_group("virus_allies").is_empty()) + assert(first.is_in_group("enemies") and second.is_in_group("enemies")) + print("PASS: passive extension rolls, persistence, pickup, armor and virus combat/lifecycle") + for sound in get_tree().root.find_children("*", "AudioStreamPlayer", true, false) + get_tree().root.find_children("*", "AudioStreamPlayer2D", true, false): + sound.stop() + sound.stream = null + preload("res://scripts/game/weapon_audio.gd")._streams.clear() + await get_tree().process_frame + await get_tree().process_frame + get_tree().quit() diff --git a/tests/abilities/passive_extensions_test.gd.uid b/tests/abilities/passive_extensions_test.gd.uid new file mode 100644 index 0000000..3914803 --- /dev/null +++ b/tests/abilities/passive_extensions_test.gd.uid @@ -0,0 +1 @@ +uid://phkehb3po7kf diff --git a/tests/abilities/passive_extensions_test.tscn b/tests/abilities/passive_extensions_test.tscn new file mode 100644 index 0000000..0586330 --- /dev/null +++ b/tests/abilities/passive_extensions_test.tscn @@ -0,0 +1,4 @@ +[gd_scene load_steps=2 format=3] +[ext_resource type="Script" path="res://tests/abilities/passive_extensions_test.gd" id="1"] +[node name="PassiveExtensionsTest" type="Node2D"] +script = ExtResource("1") \ No newline at end of file diff --git a/tests/previews/passive_extensions_preview.gd b/tests/previews/passive_extensions_preview.gd new file mode 100644 index 0000000..89b25be --- /dev/null +++ b/tests/previews/passive_extensions_preview.gd @@ -0,0 +1,45 @@ +extends "res://scripts/game/game.gd" + +func _ready() -> void: + super._ready() + set_physics_process(false) + player.set_physics_process(false) + player.get_node("WeaponController").set_physics_process(false) + var inventory := player.get_node("ShipInventory") as ShipInventory + var ids := ["recycler", "virus", "titanium_armor"] + for index in ids.size(): + var item := preload("res://scripts/items/item_generator.gd").generate_for_definition(load("res://resources/items/%s.tres" % ids[index]), 1) + item.passive_value = [5.0, 2.0, 15.0][index] + inventory.extension_slots[index] = item + var skills := preload("res://scripts/captain/captain_skills.gd") + for skill in skills.IDS: + if not bool(skills.DATA[skill]["active"]): + GameState.captain.skills[skill] = 1 + GameState.captain.emit_changed() + inventory.inventory_changed.emit() + var enemies := get_tree().get_nodes_in_group("enemies") + for enemy in enemies: + enemy.set_physics_process(false) + if enemies.size() >= 2: + enemies[0].global_position = player.global_position + Vector2(140, -50) + enemies[0].hull = 1 + player.try_control_enemy(enemies[0]) + enemies[1].global_position = player.global_position + Vector2(290, 15) + await get_tree().process_frame + if not $HUD.get("_sidebar_open"): + $HUD.call("_toggle_sidebar") + if "--capture-preview" in OS.get_cmdline_user_args(): + await get_tree().create_timer(1.0).timeout + var output := "res://artifacts/passive_extensions_preview.png" + if "--captain-window" in OS.get_cmdline_user_args(): + $HUD.get("_captain_skills_ui").call("open_management") + await get_tree().process_frame + await get_tree().process_frame + output = "res://artifacts/captain_window_preview.png" + assert($HUD.get("_passive_captain_panel").position.x < 20) + assert($HUD.get("_passive_ship_panel").position.x > 900) + assert($HUD.get_node("HUDRoot/MissionObjectives").position.y > $HUD.get("_captain_panel").position.y + $HUD.get("_captain_panel").size.y) + await RenderingServer.frame_post_draw + get_viewport().get_texture().get_image().save_png(output) + print("PREVIEW: captured passive extension HUD") + get_tree().quit() diff --git a/tests/previews/passive_extensions_preview.gd.uid b/tests/previews/passive_extensions_preview.gd.uid new file mode 100644 index 0000000..a5255d9 --- /dev/null +++ b/tests/previews/passive_extensions_preview.gd.uid @@ -0,0 +1 @@ +uid://b7n3lyntd3lqd diff --git a/tests/previews/passive_extensions_preview.tscn b/tests/previews/passive_extensions_preview.tscn new file mode 100644 index 0000000..763d5e4 --- /dev/null +++ b/tests/previews/passive_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/passive_extensions_preview.gd" id="2"] +[node name="Game" instance=ExtResource("1")] +script = ExtResource("2") \ No newline at end of file diff --git a/tests/ships/drag_cursor_test.gd b/tests/ships/drag_cursor_test.gd new file mode 100644 index 0000000..8c1b625 --- /dev/null +++ b/tests/ships/drag_cursor_test.gd @@ -0,0 +1,126 @@ +extends Node + +const GAME := preload("res://scenes/game/game.tscn") +var held := false +var _last_mouse := Vector2.ZERO + +func motion(point: Vector2) -> void: + var event := InputEventMouseMotion.new() + event.position = point + event.global_position = point + event.relative = point - _last_mouse + _last_mouse = point + event.button_mask = MOUSE_BUTTON_MASK_LEFT if held else 0 + get_viewport().push_input(event, true) + +func mouse(point: Vector2, down: bool) -> void: + held = down + var event := InputEventMouseButton.new() + event.position = point + event.global_position = point + event.button_index = MOUSE_BUTTON_LEFT + event.pressed = down + event.button_mask = MOUSE_BUTTON_MASK_LEFT if held else 0 + get_viewport().push_input(event, true) + +func _ready() -> void: + GameState.start_new_run() + var game := GAME.instantiate() + add_child(game) + await get_tree().process_frame + await get_tree().process_frame + game.set_physics_process(false) + var player := game.get_node("PlayerShip") as CharacterBody2D + player.set_physics_process(false) + player.get_node("WeaponController").set_physics_process(false) + for enemy in get_tree().get_nodes_in_group("enemies"): + enemy.set_physics_process(false) + var hud := game.get_node("HUD") + hud.call("_toggle_inventory") + var drawer: Control = hud.get("_inventory_clip") + drawer.set_meta("floating_window_dragged", true) + drawer.global_position = Vector2(32, 100) + var inventory: ShipInventory = player.get_node("ShipInventory") + var item := preload("res://scripts/items/item_generator.gd").generate_for_definition(preload("res://resources/items/hull_plating.tres"), 1) + assert(inventory.add_item(item)) + await get_tree().process_frame + await get_tree().process_frame + var panel: Control = hud.get("inventory_panel") + var source := panel.get_node("Contents/CargoGrid").get_child(0) as Control + var origin := source.get_global_rect().get_center() + var cursor := hud.get_node("FlightCursor") + # The aiming cursor is only active in free game space. + motion(Vector2(650, 150)) + await get_tree().process_frame + cursor.call("_process", 0.0) + assert(cursor.aiming) + motion(origin) + await get_tree().process_frame + cursor.call("_process", 0.0) + assert(not cursor.aiming) + # Start a real native drag, then pass over a raised crafting window. + mouse(origin, true) + motion(origin + Vector2(24, 6)) + await get_tree().process_frame + assert(get_viewport().gui_is_dragging()) + var craft: Control = hud.get("_crafting_window") + craft.position = Vector2(420, 160) + craft.show() + hud.get_node("HUDRoot/FloatingWindows").call("focus_window", craft) + motion(Vector2(660, 300)) + await get_tree().process_frame + await get_tree().process_frame + var overlay := get_tree().get_first_node_in_group("item_drag_overlay") as CanvasLayer + assert(overlay != null) + assert(overlay.layer > hud.layer and overlay.layer > 50) + var icon: Control = overlay.get_node("DragIcon") + assert(icon.visible) + assert((icon.position + icon.size * 0.5).is_equal_approx(Vector2(660, 300))) + assert(icon.mouse_filter == Control.MOUSE_FILTER_IGNORE) + assert(not cursor.should_aim()) + if "--capture-preview" in OS.get_cmdline_user_args(): + await RenderingServer.frame_post_draw + get_viewport().get_texture().get_image().save_png("res://artifacts/item_drag_preview.png") + mouse(Vector2(660, 300), false) + await get_tree().process_frame + await get_tree().process_frame + assert(not icon.visible and inventory.cargo[0] == item) + # A subsequent drag still delivers the module to its equipment slot. + craft.hide() + hud.get_node("HUDRoot/FloatingWindows").call("focus_window", drawer) + motion(origin) + mouse(origin, true) + motion(origin + Vector2(24, 6)) + await get_tree().process_frame + var target: Control = hud.get("_sidebar").get_node("Scroll/Contents/ShipEquipmentPanel/Contents/EquipmentLayout/Module2") + var destination := target.get_global_rect().get_center() + motion(destination) + await get_tree().process_frame + mouse(destination, false) + await get_tree().process_frame + await get_tree().process_frame + assert(inventory.module_slots[1] == item and inventory.cargo[0] == null) + assert(not icon.visible) + # Modal UI, defeat, docking and pause all suppress the aiming cursor. + var skills: CanvasLayer = hud.get("_captain_skills_ui") + skills.call("open_management") + motion(Vector2(650, 350)) + await get_tree().process_frame + assert(not cursor.should_aim()) + skills.call("_close") + motion(Vector2(650, 150)) + await get_tree().process_frame + player.set("_is_destroyed", true) + assert(not cursor.should_aim()) + player.set("_is_destroyed", false) + player.set("is_docked", true) + assert(not cursor.should_aim()) + player.set("is_docked", false) + get_tree().paused = true + assert(not cursor.should_aim()) + get_tree().paused = false + cursor.call("_process", 0.0) + game.queue_free() + await get_tree().process_frame + print("DRAG_CURSOR_TEST: PASS centered overlay above windows, cancel/drop, UI/space cursor and cleanup") + get_tree().quit() diff --git a/tests/ships/drag_cursor_test.gd.uid b/tests/ships/drag_cursor_test.gd.uid new file mode 100644 index 0000000..f7eaf57 --- /dev/null +++ b/tests/ships/drag_cursor_test.gd.uid @@ -0,0 +1 @@ +uid://dn1mmac5wrvva diff --git a/tests/ships/drag_cursor_test.tscn b/tests/ships/drag_cursor_test.tscn new file mode 100644 index 0000000..4e3553d --- /dev/null +++ b/tests/ships/drag_cursor_test.tscn @@ -0,0 +1,4 @@ +[gd_scene load_steps=2 format=3] +[ext_resource type="Script" path="res://tests/ships/drag_cursor_test.gd" id="1"] +[node name="DragCursorTest" type="Node"] +script = ExtResource("1")