diff --git a/assets/expeditions/README.md b/assets/expeditions/README.md new file mode 100644 index 0000000..c3a09b8 --- /dev/null +++ b/assets/expeditions/README.md @@ -0,0 +1,14 @@ +# Distant expedition art +Generated with the built-in image_gen tool. Transparent backgrounds preserved. Space backgrounds reuse assets/background/space. + +## Prompts +- assets/icons/orbs/piercing.png: A single magical sci-fi upgrade orb for shield piercing: polished luminous violet crystal sphere split by a brilliant golden needle penetrating a cyan segmented energy shield, intricate dark titanium ring, high-end hand-painted space RPG item icon, front view, centered circular silhouette, no text, no numbers, true transparent background, generous padding, reads clearly at 48px. +- assets/icons/orbs/booster.png: A single magical sci-fi engine upgrade orb: polished luminous emerald and turquoise crystal sphere with a white-hot miniature rocket exhaust vortex and infinity-shaped energy loop inside, intricate dark titanium ring, high-end hand-painted space RPG item icon, front view, centered circular silhouette, no text, no numbers, true transparent background, generous padding, reads clearly at 48px. +- ancient_gate.png: Top-down orthographic 2D space game decorative derelict alien navigation megastructure, a broken ancient orbital gate with asymmetrical modular wreckage, dark weathered blue steel, brass edges, sparse cyan power lights, hollow circular center, beautiful richly detailed hand-painted science fiction game sprite, readable at 300px, isolated on true transparent background, no background stars, no planets, no text, no logos, no shadow plane. Entire structure fits with padding. +- abandoned_relay.png: Top-down orthographic 2D space game sprite: abandoned deep space communications array, long trident-shaped dark weathered titanium structure with three large circular dishes and folded solar panel wings, little cyan and amber warning lights, sophisticated detailed hand-painted science fiction strategy game art, isolated true transparent background. No planets no stars no backdrop no text, entire structure centered with padding, readable at 300 pixels. +- freighter_wreck.png: Top-down orthographic 2D space game sprite: wreck of an enormous ancient freight carrier, split broken hull with exposed machinery and scattered attached hull fragments, asymmetrical silhouette, weathered dark blue gray plating with muted copper edges and a few dim cyan lights, polished hand-painted science fiction strategy game art, isolated on true transparent background. No planets no stars no backdrop no text, whole wreck fits centered with padding, readable at 300 pixels. + +- assets/icons/orbs/advancement.png: A single sci-fi RPG Orb of Ascension upgrade item icon. A luminous amber-gold crystal sphere containing three elegant ascending chevrons made of white starlight, delicate concentric mechanical titanium and brass ring, tiny violet sparks, polished hand painted science fiction game inventory art matching premium magical technology orb icons. Centered complete circular silhouette, reads clearly at 48px, front view, true transparent background with padding, no text, no letters, no numbers, no square background. + +- assets/icons/resources/time_crystals.png: A single time crystal currency icon for a polished science fiction RPG: three sharp translucent turquoise and lavender crystal shards suspended inside a faint gold chronal orbit, luminous white cyan core, intricate refraction, painterly premium inventory art, readable at 36 pixels, isolated true transparent background, centered with padding, no letters no numbers no text no square frame. +- assets/planets/props/time_engineer.png: Top-down near-overhead orthographic 2D sci-fi planetary game prop: a specialist chronal engineer's small workshop cottage, dark teal metal roof, copper pipes, compact cyan glowing precision calibration bench outside, crystalline turquoise time-resonator coils, warm amber entrance, a small antenna and neat tool crates. Beautiful richly hand-painted strategy game sprite, distinct readable silhouette at 150 pixels, entire building centered with padding, isolated true transparent background, no surrounding landscape, no people, no text, no logos. diff --git a/assets/expeditions/abandoned_relay.png b/assets/expeditions/abandoned_relay.png new file mode 100644 index 0000000..3696743 Binary files /dev/null and b/assets/expeditions/abandoned_relay.png differ diff --git a/assets/expeditions/abandoned_relay.png.import b/assets/expeditions/abandoned_relay.png.import new file mode 100644 index 0000000..6444068 --- /dev/null +++ b/assets/expeditions/abandoned_relay.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://davxtwmsn0hsb" +path="res://.godot/imported/abandoned_relay.png-bf1e34c1f0b0248d67a665fbad56bfd7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/expeditions/abandoned_relay.png" +dest_files=["res://.godot/imported/abandoned_relay.png-bf1e34c1f0b0248d67a665fbad56bfd7.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/expeditions/ancient_gate.png b/assets/expeditions/ancient_gate.png new file mode 100644 index 0000000..aef6b0b Binary files /dev/null and b/assets/expeditions/ancient_gate.png differ diff --git a/assets/expeditions/ancient_gate.png.import b/assets/expeditions/ancient_gate.png.import new file mode 100644 index 0000000..3db6187 --- /dev/null +++ b/assets/expeditions/ancient_gate.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bpfs0jxsr5jke" +path="res://.godot/imported/ancient_gate.png-1433097b824d323350bd909e67cbfe15.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/expeditions/ancient_gate.png" +dest_files=["res://.godot/imported/ancient_gate.png-1433097b824d323350bd909e67cbfe15.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/expeditions/freighter_wreck.png b/assets/expeditions/freighter_wreck.png new file mode 100644 index 0000000..a980bff Binary files /dev/null and b/assets/expeditions/freighter_wreck.png differ diff --git a/assets/expeditions/freighter_wreck.png.import b/assets/expeditions/freighter_wreck.png.import new file mode 100644 index 0000000..618ca21 --- /dev/null +++ b/assets/expeditions/freighter_wreck.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cy0sup06oeu1y" +path="res://.godot/imported/freighter_wreck.png-d709846df7706850f508ce5cb2517e9e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/expeditions/freighter_wreck.png" +dest_files=["res://.godot/imported/freighter_wreck.png-d709846df7706850f508ce5cb2517e9e.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/orbs/advancement.png b/assets/icons/orbs/advancement.png new file mode 100644 index 0000000..00044e4 Binary files /dev/null and b/assets/icons/orbs/advancement.png differ diff --git a/assets/icons/orbs/advancement.png.import b/assets/icons/orbs/advancement.png.import new file mode 100644 index 0000000..bc8b9dd --- /dev/null +++ b/assets/icons/orbs/advancement.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://nd76bbrd8kae" +path="res://.godot/imported/advancement.png-5740e7959740a2a240cf9116e1b2fb36.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/orbs/advancement.png" +dest_files=["res://.godot/imported/advancement.png-5740e7959740a2a240cf9116e1b2fb36.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/orbs/booster.png b/assets/icons/orbs/booster.png new file mode 100644 index 0000000..3f5c5c5 Binary files /dev/null and b/assets/icons/orbs/booster.png differ diff --git a/assets/icons/orbs/booster.png.import b/assets/icons/orbs/booster.png.import new file mode 100644 index 0000000..34d81a1 --- /dev/null +++ b/assets/icons/orbs/booster.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ndqpdc5lk3hl" +path="res://.godot/imported/booster.png-64adb8f38c6f282eb32fec1a6bdcc46c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/orbs/booster.png" +dest_files=["res://.godot/imported/booster.png-64adb8f38c6f282eb32fec1a6bdcc46c.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/orbs/piercing.png b/assets/icons/orbs/piercing.png new file mode 100644 index 0000000..8e8f7c1 Binary files /dev/null and b/assets/icons/orbs/piercing.png differ diff --git a/assets/icons/orbs/piercing.png.import b/assets/icons/orbs/piercing.png.import new file mode 100644 index 0000000..eb191a0 --- /dev/null +++ b/assets/icons/orbs/piercing.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bx6s546pqpskw" +path="res://.godot/imported/piercing.png-dcc818309271955506dd47fe8a542f91.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/orbs/piercing.png" +dest_files=["res://.godot/imported/piercing.png-dcc818309271955506dd47fe8a542f91.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/time_crystals.png b/assets/icons/resources/time_crystals.png new file mode 100644 index 0000000..10d2153 Binary files /dev/null and b/assets/icons/resources/time_crystals.png differ diff --git a/assets/icons/resources/time_crystals.png.import b/assets/icons/resources/time_crystals.png.import new file mode 100644 index 0000000..f669011 --- /dev/null +++ b/assets/icons/resources/time_crystals.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cgqeyd7dnfkek" +path="res://.godot/imported/time_crystals.png-c08cb1ec507a34dc5877b8fdcd99f93a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/resources/time_crystals.png" +dest_files=["res://.godot/imported/time_crystals.png-c08cb1ec507a34dc5877b8fdcd99f93a.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/planets/expedition_navigation.png b/assets/planets/expedition_navigation.png new file mode 100644 index 0000000..89caaed Binary files /dev/null and b/assets/planets/expedition_navigation.png differ diff --git a/assets/planets/expedition_navigation.png.import b/assets/planets/expedition_navigation.png.import new file mode 100644 index 0000000..38f05cc --- /dev/null +++ b/assets/planets/expedition_navigation.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://8rg0pkfx175s" +path="res://.godot/imported/expedition_navigation.png-7f86b11b1d2ab12f17bfd21a15821557.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/planets/expedition_navigation.png" +dest_files=["res://.godot/imported/expedition_navigation.png-7f86b11b1d2ab12f17bfd21a15821557.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/planets/props/hermit_cartographer.png b/assets/planets/props/hermit_cartographer.png new file mode 100644 index 0000000..a79b31f Binary files /dev/null and b/assets/planets/props/hermit_cartographer.png differ diff --git a/assets/planets/props/hermit_cartographer.png.import b/assets/planets/props/hermit_cartographer.png.import new file mode 100644 index 0000000..7c2e4ca --- /dev/null +++ b/assets/planets/props/hermit_cartographer.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://6wgih1u5t4cv" +path="res://.godot/imported/hermit_cartographer.png-bda49fa89bb15484a178bcddda388b7d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/planets/props/hermit_cartographer.png" +dest_files=["res://.godot/imported/hermit_cartographer.png-bda49fa89bb15484a178bcddda388b7d.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/planets/props/time_engineer.png b/assets/planets/props/time_engineer.png new file mode 100644 index 0000000..0afad6f Binary files /dev/null and b/assets/planets/props/time_engineer.png differ diff --git a/assets/planets/props/time_engineer.png.import b/assets/planets/props/time_engineer.png.import new file mode 100644 index 0000000..125c208 --- /dev/null +++ b/assets/planets/props/time_engineer.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dt60233tfftow" +path="res://.godot/imported/time_engineer.png-98843cd5608f7b86fd85c0e7b5fae8ad.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/planets/props/time_engineer.png" +dest_files=["res://.godot/imported/time_engineer.png-98843cd5608f7b86fd85c0e7b5fae8ad.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/scripts/game/distant_loot.gd b/scripts/game/distant_loot.gd new file mode 100644 index 0000000..671ae21 --- /dev/null +++ b/scripts/game/distant_loot.gd @@ -0,0 +1,26 @@ +extends RefCounted +const MAP_CHANCE := 0.15 +const BANK_CHANCE := 0.15 +static func roll(level: int, boss: bool, seed_value: int, map_roll: float, _orb_roll: float) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + if boss or map_roll < MAP_CHANCE: + var profile := {"id":"distant:%d" % seed_value,"seed":seed_value,"name":"Daleka wyprawa %d" % level} + result.append({"map":preload("res://scripts/planets/distant_expeditions.gd").offer(profile,level)}) + if boss: + result.append({"orb":preload("res://scripts/items/special_orbs.gd").IDS[posmod(seed_value,preload("res://scripts/items/special_orbs.gd").IDS.size())]}) + return result +static func crystal_amount(level: int, tier: int, roll_value: int) -> int: + return clampi(roll_value,1,3) * maxi(1,ceili(maxi(1,level)/10.0)) * [1,2,5][clampi(tier,0,2)] + +static func drop(enemy: Node2D) -> void: + var crystals := Node2D.new() + crystals.set_script(preload("res://scripts/items/time_crystal_pickup.gd")) + crystals.amount = crystal_amount(GameState.system_level,int(enemy.get("loot_tier")),randi_range(1,3)) + crystals.position = enemy.position+Vector2(38,-20) + enemy.get_parent().call_deferred("add_child",crystals) + for payload in roll(GameState.system_level,int(enemy.get("loot_tier"))==2,randi(),randf(),randf()): + var pickup := Node2D.new() + pickup.set_script(preload("res://scripts/items/expedition_pickup.gd")) + pickup.payload = payload + pickup.position = enemy.position+Vector2(randf_range(-36,36),65) + enemy.get_parent().call_deferred("add_child",pickup) diff --git a/scripts/game/distant_loot.gd.uid b/scripts/game/distant_loot.gd.uid new file mode 100644 index 0000000..d8fd0b7 --- /dev/null +++ b/scripts/game/distant_loot.gd.uid @@ -0,0 +1 @@ +uid://d0l5u5k4m7myv diff --git a/scripts/game/distant_sector.gd b/scripts/game/distant_sector.gd new file mode 100644 index 0000000..e62df01 --- /dev/null +++ b/scripts/game/distant_sector.gd @@ -0,0 +1,102 @@ +extends Node +const GENERATOR := preload("res://scripts/levels/distant_sector_generator.gd") +var game: Node2D +var _status: Label +var _return: Button +var _refresh := 0.0 + +func _ready() -> void: + name = "DistantSector" + var map: Dictionary = GameState.active_distant_map + game.system_data = GENERATOR.generate(map) + game.player.position = Vector2.ZERO + var camera: Camera2D = game.player.get_node("Camera2D") + camera.limit_left = -2600 + camera.limit_right = 2600 + camera.limit_top = -1800 + camera.limit_bottom = 1800 + for data in game.system_data.enemies: + if int(data.tier) == 2: game._pending_boss = data.duplicate() + else: game._spawn_enemy(data,data.position) + _build_structures() + var panel := PanelContainer.new() + panel.name = "DistantReturnPanel" + game.get_node("HUD/HUDRoot").add_child(panel) + panel.set_anchors_and_offsets_preset(Control.PRESET_CENTER_TOP) + panel.offset_left = -195 + panel.offset_right = 195 + panel.offset_top = 104 + panel.offset_bottom = 174 + var ui := preload("res://scripts/planets/freighter_menu.gd") + ui.style(panel,8,0.5) + var column := VBoxContainer.new() + panel.add_child(column) + _status = ui.label(column,"",12,Color("e9b4fc")) + _return = ui.button(column,"POWRÓT DO FRACHTOWCA",_return_home,true) + _return.name = "ReturnFromDistant" + _update_status() + +func _build_structures() -> void: + var group := Node2D.new() + group.name = "AncientStructures" + group.z_index = -8 + game.add_child(group) + for data in game.system_data.structures: + var sprite := Sprite2D.new() + sprite.texture = load("res://assets/expeditions/%s.png" % ["ancient_gate","abandoned_relay","freighter_wreck"][group.get_child_count()/2 % 3]) + sprite.position = data.position + sprite.rotation = data.rotation + if sprite.texture != null: sprite.scale = Vector2.ONE * float(data.size) / maxf(sprite.texture.get_width(),sprite.texture.get_height()) + group.add_child(sprite) + var label := Label.new() + label.text = str(data.name) + label.position = data.position+Vector2(-150,float(data.size)*0.4) + label.size.x = 300 + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.add_theme_font_size_override("font_size",12) + label.modulate = Color("8da9ae") + group.add_child(label) + +func _physics_process(delta: float) -> void: + if game._transitioning or bool(game.player.get("_is_destroyed")): return + if not game.system_cleared and game.get_remaining_enemies() == 0: + if not game._boss_spawned and not game._pending_boss.is_empty(): game._spawn_boss() + else: game.system_cleared = true + _refresh += delta + if _refresh >= 0.2: + _refresh = 0 + _update_status() + +func return_blocked() -> bool: + if float(game.player.get("landing_combat_lock")) > 0: return true + for enemy in get_tree().get_nodes_in_group("enemies"): + if not enemy.is_queued_for_deletion() and float(enemy.get("hull")) > 0 and enemy.global_position.distance_to(game.player.global_position) < 650: return true + return false + +func _update_status() -> void: + _status.text = "WYPRAWA %d · %s" % [GameState.system_level,"SEKTOR OCZYSZCZONY" if game.system_cleared else ("BOSS" if game._boss_spawned else "%d wrogów" % game.get_remaining_enemies())] + _return.disabled = bool(game.player.get("_is_destroyed")) or return_blocked() + _return.tooltip_text = "Oddal się od przeciwników i zakończ walkę." if _return.disabled else "Wróć do układu startowego. Powrót nie zużywa deuteru. Zbierz łup przed odlotem." + +func _return_home() -> void: + if game._transitioning or bool(game.player.get("_is_destroyed")) or return_blocked(): return + game._transitioning = true + _load_home.call_deferred() + +func _load_home() -> void: + var active: Dictionary = GameState.active_distant_map.duplicate(true) + var home: Dictionary = GameState.distant_home.duplicate(true) + var level := GameState.system_level + var seed_value := GameState.system_seed + game.player.get_node("ShipInventory").preserve_for_restart() + GameState.restore_distant_home() + GameState.return_to_freighter = true + var error := get_tree().reload_current_scene() + if error != OK: + GameState.return_to_freighter = false + GameState.active_distant_map = active + GameState.distant_home = home + GameState.system_level = level + GameState.system_seed = seed_value + GameState.restart_inventory.clear() + game._transitioning = false diff --git a/scripts/game/distant_sector.gd.uid b/scripts/game/distant_sector.gd.uid new file mode 100644 index 0000000..87b9307 --- /dev/null +++ b/scripts/game/distant_sector.gd.uid @@ -0,0 +1 @@ +uid://bljfethq7wf27 diff --git a/scripts/game/enemy_ship.gd b/scripts/game/enemy_ship.gd index c18f039..4466f0f 100644 --- a/scripts/game/enemy_ship.gd +++ b/scripts/game/enemy_ship.gd @@ -44,6 +44,8 @@ var _combat_target: Node2D var virus_controlled := false var _virus_infection_attempted := false var _research_player_hit := false +var _weapon_piercing := 0.0 +var combat_level := 1 var _reward_claimed := false func roll_virus_infection() -> bool: @@ -101,6 +103,7 @@ func _ready() -> void: func apply_level_settings(level: int, tier: int) -> void: loot_tier = tier + combat_level = maxi(1, level) $Sprite2D.modulate = get_tier_tint() var power := LEVEL_BALANCE.enemy_stat_scale(level) var combat_multiplier := 2.0 if tier == 2 else 1.25 @@ -327,6 +330,11 @@ func _fire_plasma(direction: Vector2) -> void: get_parent().add_child(projectile) WEAPON_AUDIO_SCRIPT.play(get_parent(), global_position, weapon_kind, -4.0) +func take_piercing_weapon_hit(amount: float, critical: bool, damage_type: int, piercing: float) -> void: + _weapon_piercing = clampf(piercing,0.0,0.5) + take_weapon_hit(amount,critical,damage_type) + _weapon_piercing = 0.0 + func take_weapon_hit(amount: float, critical: bool, damage_type: int = DamageTypes.Type.KINETIC) -> void: if is_queued_for_deletion() or hull <= 0.0: return @@ -345,7 +353,8 @@ func take_damage(amount: float, critical: bool = false, damage_type: int = Damag var progress: Resource = GameState.captain.expedition var shield_multiplier := 1.0 + (research.bonus(progress, "shields") if _research_player_hit else 0.0) var hull_multiplier := 1.0 + (research.bonus(progress, "hull") if _research_player_hit else 0.0) - var shield_damage := minf(shields, actual * shield_multiplier) + var piercing_damage := actual * _weapon_piercing if _research_player_hit else 0.0 + var shield_damage := minf(shields, (actual - piercing_damage) * shield_multiplier) var remaining := maxf(0.0, actual - shield_damage / shield_multiplier) shields -= shield_damage var hull_damage := minf(hull, remaining * hull_multiplier) @@ -396,7 +405,9 @@ func _reward_captain() -> void: func _drop_loot() -> void: # Roll independently of equipment drops. - var bank_chance := 0.1 + var distant := not GameState.active_distant_map.is_empty() + if distant: preload("res://scripts/game/distant_loot.gd").drop(self) + var bank_chance: float = preload("res://scripts/game/distant_loot.gd").BANK_CHANCE if distant else 0.1 if randf() < bank_chance: var bank := ItemInstance.new() bank.definition = preload("res://resources/items/energy_bank.tres") diff --git a/scripts/game/flight_hud.gd b/scripts/game/flight_hud.gd index 55611a6..2a5b538 100644 --- a/scripts/game/flight_hud.gd +++ b/scripts/game/flight_hud.gd @@ -626,7 +626,7 @@ func _create_system_badge() -> void: func _update_system_badge() -> void: if not is_instance_valid(_system_badge): return _system_badge.position = Vector2(_captain_panel.position.x + _captain_panel.size.x + 10, _captain_panel.position.y) - _system_title.text = "System %d" % GameState.system_level + _system_title.text = ("Wyprawa %d" if not GameState.active_distant_map.is_empty() else "System %d") % GameState.system_level _system_mode.text = "HARD" if GameState.system_level > 60 else "NORMALNY" _system_mode.add_theme_color_override("font_color", Color("e88972") if GameState.system_level > 60 else Color("88a7ae")) diff --git a/scripts/game/game.gd b/scripts/game/game.gd index 69f5925..9ffa55b 100644 --- a/scripts/game/game.gd +++ b/scripts/game/game.gd @@ -27,6 +27,12 @@ var _pending_boss: Dictionary = {} var _boss_spawned := false func _ready() -> void: + if not GameState.active_distant_map.is_empty(): + var distant := Node.new() + distant.set_script(preload("res://scripts/game/distant_sector.gd")) + distant.game = self + add_child(distant) + return GameState.system_level = clampi(GameState.system_level, 1, LEVEL_BALANCE.MAX_LEVEL) if GameState.system_seed == 0: GameState.system_seed = GameState.create_system_seed() @@ -58,6 +64,10 @@ func _ready() -> void: expeditions.game = self expeditions.player = player add_child(expeditions) + if GameState.return_to_freighter: + GameState.return_to_freighter = false + player.position = expeditions.freighter.position + expeditions.open_freighter.call_deferred() func _spawn_celestial_bodies() -> void: var bodies := Node2D.new() @@ -157,6 +167,7 @@ func is_boss_alive() -> bool: return false func _physics_process(_delta: float) -> void: + if not GameState.active_distant_map.is_empty(): return if _transitioning or bool(player.get("_is_destroyed")): services_menu.call("hide_services") return @@ -236,6 +247,30 @@ func _load_next_level() -> void: services_menu.call("show_services", false, true) push_error("Could not enter next system: %s" % error_string(error)) +func travel_to_distant(map_id: String) -> bool: + if _transitioning or bool(player.get("_is_destroyed")) or not GameState.active_distant_map.is_empty(): return false + if not GameState.captain.expedition.distant_maps.has(map_id): return false + if GameState.captain.expedition.deuter < preload("res://scripts/planets/distant_expeditions.gd").fuel_cost(GameState.captain.expedition.distant_maps[map_id]): return false + _transitioning = true + _load_distant.call_deferred(map_id) + return true + +func _load_distant(map_id: String) -> void: + player.get_node("ShipInventory").preserve_for_restart() + if not GameState.depart_distant(map_id): + GameState.restart_inventory.clear() + _transitioning = false + return + var cost: int = preload("res://scripts/planets/distant_expeditions.gd").fuel_cost(GameState.active_distant_map) + get_tree().paused = false + var error := get_tree().reload_current_scene() + if error != OK: + GameState.restore_distant_home() + GameState.captain.expedition.deuter += cost + GameState.captain.expedition.emit_changed() + GameState.restart_inventory.clear() + _transitioning = false + func travel_to_system(level: int) -> bool: if _transitioning or bool(player.get("_is_destroyed")) or level == GameState.system_level or not GameState.can_visit_system(level): return false _transitioning = true diff --git a/scripts/game/game_state.gd b/scripts/game/game_state.gd index 21f793a..2fe1c5c 100644 --- a/scripts/game/game_state.gd +++ b/scripts/game/game_state.gd @@ -7,6 +7,9 @@ var selected_ship_path: String = "" var restart_inventory: Dictionary = {} var system_level: int = 1 var system_seed: int = 0 +var active_distant_map: Dictionary = {} +var distant_home: Dictionary = {} +var return_to_freighter := false var highest_system := 1 var system_seeds: Dictionary = {} var cleared_systems: Dictionary = {} @@ -16,6 +19,26 @@ var asteroid_retry_seeds: Dictionary = {} var exploration_wreck_bonuses: Dictionary = {} var hud_panel_state: Dictionary = {} +func depart_distant(map_id: String) -> bool: + if not active_distant_map.is_empty() or not captain.expedition.distant_maps.has(map_id): return false + var map: Dictionary = captain.expedition.distant_maps[map_id] + var cost: int = preload("res://scripts/planets/distant_expeditions.gd").fuel_cost(map) + if captain.expedition.deuter < cost: return false + distant_home = {"level":system_level,"seed":system_seed} + active_distant_map = map.duplicate(true) + captain.expedition.deuter -= cost + system_level = int(map.level) + system_seed = int(map.seed) + captain.expedition.emit_changed() + return true + +func restore_distant_home() -> void: + if distant_home.is_empty(): return + system_level = int(distant_home.level) + system_seed = int(distant_home.seed) + active_distant_map.clear() + distant_home.clear() + func create_system_seed() -> int: var rng := RandomNumberGenerator.new() rng.randomize() @@ -37,6 +60,9 @@ func start_new_run() -> void: captain = CAPTAIN_PROGRESS.new() system_level = 1 highest_system = 1 + active_distant_map.clear() + distant_home.clear() + return_to_freighter = false system_seeds.clear() cleared_systems.clear() _production_signal_time = 0 diff --git a/scripts/game/radiation_status.gd b/scripts/game/radiation_status.gd index 12adb8e..dbd4fe4 100644 --- a/scripts/game/radiation_status.gd +++ b/scripts/game/radiation_status.gd @@ -5,11 +5,12 @@ const DURATION := 10.0 var remaining := 0.0 var tick_damage := 0.0 var credited_to_player := false +var shield_piercing := 0.0 var _tick_elapsed := 0.0 var _base_color := Color.WHITE var _sprite: Sprite2D -static func apply(target: Node, damage_per_second: float, player_hit: bool) -> void: +static func apply(target: Node, damage_per_second: float, player_hit: bool, piercing: float = 0.0) -> void: if not target.has_method("take_damage") or not target is CharacterBody2D: return var status := target.get_node_or_null("RadiationStatus") @@ -19,7 +20,9 @@ static func apply(target: Node, damage_per_second: float, player_hit: bool) -> v status.name = "RadiationStatus" target.add_child(status) status.remaining = DURATION - status.tick_damage = maxf(status.tick_damage, damage_per_second) + if damage_per_second >= status.tick_damage: + status.tick_damage = damage_per_second + status.shield_piercing = piercing status.credited_to_player = player_hit func _ready() -> void: @@ -37,7 +40,9 @@ func _physics_process(delta: float) -> void: if _tick_elapsed >= 0.5 or (remaining <= 0.0 and _tick_elapsed > 0.0): step = _tick_elapsed _tick_elapsed = 0.0 - if credited_to_player and target.has_method("take_weapon_hit"): + if credited_to_player and target.has_method("take_piercing_weapon_hit"): + target.take_piercing_weapon_hit(tick_damage * step, false, DamageTypes.Type.RADIATION, shield_piercing) + elif credited_to_player and target.has_method("take_weapon_hit"): target.take_weapon_hit(tick_damage * step, false, DamageTypes.Type.RADIATION) else: target.take_damage(tick_damage * step, false, DamageTypes.Type.RADIATION) diff --git a/scripts/game/ship_controller.gd b/scripts/game/ship_controller.gd index 9828709..21d123a 100644 --- a/scripts/game/ship_controller.gd +++ b/scripts/game/ship_controller.gd @@ -382,11 +382,19 @@ func is_mini_boosting() -> bool: func _get_allowed_speed() -> float: return maxf(max_speed + _boost_speed, max_speed * 2.0 if _mini_boost_active else max_speed + _mini_boost_coast_speed) +func has_free_mini_boost() -> bool: + var engine: ItemInstance = $ShipInventory.get_item("module",2) + return engine != null and engine.definition.category == ItemDefinition.Category.ENGINE and "booster" in engine.special_orbs + func _update_mini_boost(delta: float) -> void: _mini_boost_active = false if not Input.is_action_pressed("ship_mini_boost"): _mini_boost_exhausted = false return + if has_free_mini_boost() and not Input.is_action_pressed("ship_brake"): + _mini_boost_exhausted = false + _mini_boost_active = true + return if _mini_boost_exhausted or Input.is_action_pressed("ship_brake"): return var cost := MINI_BOOST_ENERGY_PER_SECOND * delta diff --git a/scripts/game/weapon_controller.gd b/scripts/game/weapon_controller.gd index be8202a..9087ff6 100644 --- a/scripts/game/weapon_controller.gd +++ b/scripts/game/weapon_controller.gd @@ -154,6 +154,7 @@ func _fire_projectile(item: ItemInstance) -> void: ) ship.get_parent().add_child(projectile) projectile.reset_physics_interpolation() + projectile.set("shield_piercing", item.shield_piercing()) projectile.set("missile_homing", definition.missile_homing) WEAPON_AUDIO_SCRIPT.play(ship.get_parent(), muzzle_position, definition.weapon_kind) if definition.weapon_kind == ItemDefinition.WeaponKind.MACHINE_GUN: @@ -198,7 +199,7 @@ func _fire_laser(slot_index: int, item: ItemInstance) -> void: _break_cloak_on_fire() if not result.is_empty(): var hit: Dictionary = ship.call("roll_weapon_hit", item) - _apply_damage(result.get("collider"), float(hit["damage"]), bool(hit["critical"])) + _apply_damage(result.get("collider"), float(hit["damage"]), bool(hit["critical"]), item.shield_piercing()) _spawn_laser_impact(endpoint) _laser_pulse_timers[slot_index] = definition.laser_pulse_duration _shot_timers[slot_index] = definition.fire_interval @@ -231,8 +232,10 @@ func _consume_energy(amount: float) -> bool: ship.set("energy", current_energy - amount) return true -func _apply_damage(target: Variant, damage: float, critical: bool = false) -> void: - if target is Node and target.has_method("take_weapon_hit"): +func _apply_damage(target: Variant, damage: float, critical: bool = false, piercing: float = 0.0) -> void: + if target is Node and target.has_method("take_piercing_weapon_hit"): + target.call("take_piercing_weapon_hit", damage, critical, DamageTypes.Type.ENERGY, piercing) + elif target is Node and target.has_method("take_weapon_hit"): target.call("take_weapon_hit", damage, critical, DamageTypes.Type.ENERGY) elif target is Node and target.has_method("take_damage"): target.call("take_damage", damage) diff --git a/scripts/game/weapon_projectile.gd b/scripts/game/weapon_projectile.gd index b707eae..0cd5c97 100644 --- a/scripts/game/weapon_projectile.gd +++ b/scripts/game/weapon_projectile.gd @@ -28,6 +28,7 @@ var _distance_travelled := 0.0 var _already_hit: Dictionary = {} var _impact_handled := false var _engine_time := 0.0 +var shield_piercing := 0.0 var _smoke_emission_timer := 0.0 var _smoke_puffs: Array[Dictionary] = [] @@ -296,6 +297,8 @@ func _apply_damage_once(target: Variant) -> void: _already_hit[instance_id] = true 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_piercing_weapon_hit"): + target.call("take_piercing_weapon_hit", damage, is_critical, DamageTypes.for_weapon(weapon_kind), shield_piercing) 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"): @@ -304,4 +307,4 @@ func _apply_damage_once(target: Variant) -> void: else: target.call("take_damage", damage) if weapon_kind == ItemDefinition.WeaponKind.GAMMA: - preload("res://scripts/game/radiation_status.gd").apply(target, damage * 0.25, not enemy_shot) + preload("res://scripts/game/radiation_status.gd").apply(target, damage * 0.25, not enemy_shot, shield_piercing if not enemy_shot else 0.0) diff --git a/scripts/items/expedition_pickup.gd b/scripts/items/expedition_pickup.gd new file mode 100644 index 0000000..327b5c0 --- /dev/null +++ b/scripts/items/expedition_pickup.gd @@ -0,0 +1,39 @@ +extends Node2D +var payload: Dictionary = {} +var _player: Node2D +var _label: Label +var _icon: Texture2D +var _time := 0.0 +func _ready() -> void: + add_to_group("loot") + z_index = 18 + _player = get_parent().get_node_or_null("PlayerShip") + _icon = preload("res://scripts/items/special_orbs.gd").icon(str(payload.orb)) if payload.has("orb") else load("res://assets/planets/expedition_navigation.png") + _label = Label.new() + _label.position = Vector2(-120,24) + _label.size.x = 240 + _label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _label.add_theme_font_size_override("font_size",12) + _label.text = preload("res://scripts/items/special_orbs.gd").NAMES[str(payload.orb)] if payload.has("orb") else "Mapa wyprawy · poz. %d" % int(payload.map.level) + add_child(_label) +func _process(delta: float) -> void: + _time += delta + queue_redraw() +func _draw() -> void: + var color := Color("b991ff") if payload.has("map") else Color("f5cf82") + draw_circle(Vector2.ZERO,27+sin(_time*3)*2,Color(color,0.15)) + draw_arc(Vector2.ZERO,24,_time,_time+TAU*0.8,40,color,1.5,true) + if _icon != null: draw_texture_rect(_icon,Rect2(-21,-21,42,42),false) +func _physics_process(delta: float) -> void: + if not is_instance_valid(_player) or bool(_player.get("_is_destroyed")): return + var distance := global_position.distance_to(_player.global_position) + if distance <= 48: + var progress: Resource = GameState.captain.expedition + if payload.has("map"): progress.distant_maps[str(payload.map.id)] = payload.map.duplicate(true) + if payload.has("orb"): + var id := str(payload.orb) + progress.orbs[id] = int(progress.orbs.get(id,0))+1 + progress.emit_changed() + set_physics_process(false) + queue_free() + elif distance < 180: global_position = global_position.move_toward(_player.global_position,200*delta) diff --git a/scripts/items/expedition_pickup.gd.uid b/scripts/items/expedition_pickup.gd.uid new file mode 100644 index 0000000..129e36e --- /dev/null +++ b/scripts/items/expedition_pickup.gd.uid @@ -0,0 +1 @@ +uid://deest4k7mhp0t diff --git a/scripts/items/item_generator.gd b/scripts/items/item_generator.gd index f40652b..8d5a3d0 100644 --- a/scripts/items/item_generator.gd +++ b/scripts/items/item_generator.gd @@ -39,18 +39,7 @@ static func generate_loot(level: int, enemy_tier: int = 0, definition: ItemDefin static func generate_for_definition(definition: ItemDefinition, level: int, enemy_tier: int = 0) -> ItemInstance: var item := ItemInstance.new() item.definition = definition - item.item_level = clampi(level, 1, 1000) - var scale_factor := 1.0 + 0.05 * (item.item_level - 1) - item.primary_value_override = item.definition.primary_value * scale_factor * randf_range(0.8, 1.2) - if definition.primary_stat == "max_speed": - item.primary_value_override = randf_range(1.0, 5.0) * scale_factor - if definition.category == ItemDefinition.Category.WEAPON: - var lower := randi_range(mini(definition.damage_lower_roll_min, definition.damage_lower_roll_max), maxi(definition.damage_lower_roll_min, definition.damage_lower_roll_max)) - var spread := randi_range(mini(definition.damage_spread_roll_min, definition.damage_spread_roll_max), maxi(definition.damage_spread_roll_min, definition.damage_spread_roll_max)) - item.damage_min = maxf(1.0, roundf(lower * scale_factor)) - item.damage_max = maxf(item.damage_min + 1.0, roundf((lower + spread) * scale_factor)) - 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) + roll_base_values(item, level) if definition.category == ItemDefinition.Category.ACTIVE_EXTENSION: if item.is_passive_extension(): item.passive_value = preload("res://scripts/items/passive_extension_rolls.gd").roll_value(definition.passive_effect) @@ -66,3 +55,43 @@ static func generate_for_definition(definition: ItemDefinition, level: int, enem item.additional_modifiers = AFFIX_DATABASE.roll_affixes(item.definition, item.item_level, count) item.rarity = clampi(item.additional_modifiers.size() - 1, 0, 4) return item + +static func roll_base_values(item: ItemInstance, level: int) -> void: + var definition := item.definition + item.item_level = clampi(level, 1, 1000) + var scale_factor := 1.0 + 0.05 * (item.item_level - 1) + item.primary_value_override = definition.primary_value * scale_factor * randf_range(0.8, 1.2) + if definition.primary_stat == "max_speed": + item.primary_value_override = randf_range(1.0, 5.0) * scale_factor + if definition.category == ItemDefinition.Category.WEAPON: + var lower := randi_range(mini(definition.damage_lower_roll_min, definition.damage_lower_roll_max), maxi(definition.damage_lower_roll_min, definition.damage_lower_roll_max)) + var spread := randi_range(mini(definition.damage_spread_roll_min, definition.damage_spread_roll_max), maxi(definition.damage_spread_roll_min, definition.damage_spread_roll_max)) + item.damage_min = maxf(1.0, roundf(lower * scale_factor)) + item.damage_max = maxf(item.damage_min + 1.0, roundf((lower + spread) * scale_factor)) + item.primary_value_override = (item.damage_min + item.damage_max) * 0.5 + item.energy_regeneration_override = definition.energy_regeneration_bonus * scale_factor * randf_range(0.8, 1.2) + +static func can_advance(item: ItemInstance) -> bool: + return item != null and item.definition != null and item.definition.category != ItemDefinition.Category.CONSUMABLE and item.item_level >= 1 and item.item_level < 1000 + +static func advance(item: ItemInstance) -> bool: + if not can_advance(item): return false + var level := item.item_level + 1 + var updated: Array[ItemStatModifier] = [] + for modifier in item.additional_modifiers: + if modifier == null: + updated.append(null) + continue + # Keep shared modifiers on other items untouched. + var copy := modifier.duplicate() as ItemStatModifier + if copy.affix == null: + for candidate in AFFIX_DATABASE.affixes: + if candidate != null and candidate.stat_key == copy.stat_key and candidate.can_roll(item.definition,level): + copy.affix = candidate + break + if copy.affix != null: copy.value = copy.affix.roll_value(level) + # Fixed legacy bonuses without a defined roll range stay fixed. + updated.append(copy) + roll_base_values(item,level) + item.additional_modifiers = updated + return true diff --git a/scripts/items/item_instance.gd b/scripts/items/item_instance.gd index fbf4f70..f07ea0f 100644 --- a/scripts/items/item_instance.gd +++ b/scripts/items/item_instance.gd @@ -7,6 +7,10 @@ const EXTENSION_COOLDOWNS := preload("res://scripts/items/extension_cooldowns.gd @export_range(5, 30, 1) var extension_cooldown: int = 30 var _instance_ability: ActiveAbility @export var passive_value: float = -1.0 +@export var special_orbs: Array[String] = [] + +func shield_piercing() -> float: + return 0.5 if definition != null and definition.category == ItemDefinition.Category.WEAPON and "piercing" in special_orbs else 0.0 func is_passive_extension() -> bool: return definition != null and definition.category == ItemDefinition.Category.ACTIVE_EXTENSION and not definition.passive_effect.is_empty() @@ -182,6 +186,9 @@ func get_tooltip_text() -> String: if modifier == null: continue lines.append(modifier.get_display_text()) + for orb in special_orbs: + if orb == "piercing": lines.append("\nORB PRZEBICIA · 50% obrażeń omija tarcze. Tylko ta broń.") + elif orb == "booster": lines.append("\nORB DOPALACZA · TAB bez kosztu energii. Działa po zamontowaniu silnika.") return "\n".join(lines) static func stat_name(key: String) -> String: diff --git a/scripts/items/special_orbs.gd b/scripts/items/special_orbs.gd new file mode 100644 index 0000000..37cbc05 --- /dev/null +++ b/scripts/items/special_orbs.gd @@ -0,0 +1,27 @@ +extends RefCounted +# Every orb is consumed once. Further advancement requires another orb. +const IDS := ["piercing", "booster", "advancement"] +const NAMES := {"piercing":"Orb przebicia", "booster":"Orb dopalacza", "advancement":"Orb awansu"} +const DESCRIPTIONS := {"piercing":"50% obrażeń broni omija tarcze i trafia bezpośrednio w kadłub. Tylko broń.", "booster":"Dopalacz pod TAB nie zużywa energii i działa również przy pustym generatorze. Tylko silnik.", "advancement":"Awansuje przedmiot o 1 poziom i ponownie losuje jego parametry oraz wartości obecnych addonów. Zachowuje ich typy, liczbę i efekty orbów. Wyniki losowania mogą być niższe. Jedna sztuka daje jeden awans. Kolejny awans wymaga nowego orba (limit poziomu 1000). Nie działa na materiały zużywalne."} +static func icon(id: String) -> Texture2D: + return load("res://assets/icons/orbs/%s.png" % id) as Texture2D +static func accepts(item: ItemInstance, id: String) -> bool: + if id not in IDS or item == null or item.definition == null: return false + if id == "advancement": return preload("res://scripts/items/item_generator.gd").can_advance(item) + if id in item.special_orbs: return false + return item.definition.category == (ItemDefinition.Category.WEAPON if id == "piercing" else ItemDefinition.Category.ENGINE) +static func apply(progress: Resource, inventory: Node, item: ItemInstance, id: String) -> bool: + if not accepts(item,id) or int(progress.orbs.get(id,0)) <= 0: return false + if inventory == null or not bool(inventory.get_parent().get("is_docked")): return false + var owned: Array = inventory.cargo + inventory.weapon_slots + inventory.module_slots + progress.freighter_storage + if not owned.has(item): return false + if id == "advancement": + if not preload("res://scripts/items/item_generator.gd").advance(item): return false + else: + item.special_orbs.append(id) + progress.orbs[id] = int(progress.orbs[id])-1 + item.emit_changed() + inventory.call("_recalculate_ship_stats") + inventory.inventory_changed.emit() + progress.emit_changed() + return true diff --git a/scripts/items/special_orbs.gd.uid b/scripts/items/special_orbs.gd.uid new file mode 100644 index 0000000..8748418 --- /dev/null +++ b/scripts/items/special_orbs.gd.uid @@ -0,0 +1 @@ +uid://dshhmuyi4vgid diff --git a/scripts/items/time_crystal_pickup.gd b/scripts/items/time_crystal_pickup.gd new file mode 100644 index 0000000..4022699 --- /dev/null +++ b/scripts/items/time_crystal_pickup.gd @@ -0,0 +1,36 @@ +extends Node2D +const PICKUP_RADIUS := 48.0 +const ATTRACTION_RADIUS := 150.0 +const POPUP := preload("res://scenes/game/damage_popup.tscn") +var amount := 0 +var _player: CharacterBody2D +var _collected := false + +func _ready() -> void: + add_to_group("time_crystal_pickups") + _player = get_parent().get_node_or_null("PlayerShip") as CharacterBody2D + z_index = 18 + +func _draw() -> void: + for radius in [27.0,22.0,18.0]: draw_circle(Vector2.ZERO,radius,Color(0.42,0.86,1.0,0.07)) + var icon := load("res://assets/icons/resources/time_crystals.png") as Texture2D + if icon != null: draw_texture_rect(icon,Rect2(-18,-18,36,36),false) + +func _physics_process(delta: float) -> void: + if _collected or not is_instance_valid(_player) or bool(_player.get("_is_destroyed")): return + var distance := global_position.distance_to(_player.global_position) + if distance <= PICKUP_RADIUS: + _collected = true + var gained := maxi(0,amount) + GameState.captain.expedition.time_crystals += gained + GameState.captain.expedition.emit_changed() + GameState.captain.emit_changed() + var popup := POPUP.instantiate() as Label + popup.text = "+%d KRYSZTAŁÓW CZASU" % gained + popup.set("text_color",Color("9be7ff")) + popup.position = _player.position+Vector2(-80,-90) + get_parent().add_child(popup) + set_physics_process(false) + queue_free() + elif distance <= ATTRACTION_RADIUS: + global_position = global_position.move_toward(_player.global_position,180.0*delta) diff --git a/scripts/items/time_crystal_pickup.gd.uid b/scripts/items/time_crystal_pickup.gd.uid new file mode 100644 index 0000000..be0a06d --- /dev/null +++ b/scripts/items/time_crystal_pickup.gd.uid @@ -0,0 +1 @@ +uid://cfmy7o1t2rlea diff --git a/scripts/levels/distant_sector_generator.gd b/scripts/levels/distant_sector_generator.gd new file mode 100644 index 0000000..64b1ca2 --- /dev/null +++ b/scripts/levels/distant_sector_generator.gd @@ -0,0 +1,40 @@ +extends RefCounted +const BALANCE := preload("res://scripts/levels/level_balance.gd") +const WORLD_SIZE := Vector2(5200,3600) +static func enemy_tiers(level: int) -> Array[int]: + var result: Array[int] = [] + for tier in BALANCE.enemy_tiers(level): + result.append(tier) + if tier != 2: result.append(tier) + return result +static func generate(map: Dictionary) -> Dictionary: + var rng := RandomNumberGenerator.new() + rng.seed = int(map.seed) + var enemies: Array[Dictionary] = [] + var index := 0 + for tier in enemy_tiers(int(map.level)): + var chosen := Vector2.ZERO + var best := -INF + for attempt in 100: + var candidate := Vector2(rng.randf_range(-2300,2300),rng.randf_range(-1480,1480)) + var clearance := candidate.length()-900.0 + for other in enemies: clearance = minf(clearance,candidate.distance_to(other.position)-220.0) + if clearance > best: + best = clearance + chosen = candidate + if clearance >= 0: break + enemies.append({"tier":tier,"position":chosen,"texture_index":rng.randi_range(0,preload("res://scripts/items/alien_ship_catalog.gd").TEXTURES.size()-1),"spawn_index":index}) + index += 1 + var structures: Array[Dictionary] = [] + for prop in 7: + var width := rng.randf_range(240,460) + var position := Vector2.ZERO + for attempt in 100: + position = Vector2(rng.randf_range(-2200,2200),rng.randf_range(-1350,1350)) + if position.length() < 650: continue + var clear := true + for other in structures: + if position.distance_to(other.position) < (width+float(other.size))*0.5+180: clear = false + if clear: break + structures.append({"position":position,"size":width,"rotation":rng.randf_range(-PI,PI),"name":["Rozbity przekaźnik","Archiwum bez głosu","Brama zapomnianych","Wrak konwoju","Pusta stocznia","Echo dawnej cywilizacji","Cmentarz maszyn"][prop]}) + return {"planets":[],"player_start":Vector2.ZERO,"enemies":enemies,"structures":structures} diff --git a/scripts/levels/distant_sector_generator.gd.uid b/scripts/levels/distant_sector_generator.gd.uid new file mode 100644 index 0000000..70f3cc9 --- /dev/null +++ b/scripts/levels/distant_sector_generator.gd.uid @@ -0,0 +1 @@ +uid://es7rqq3xu5e8 diff --git a/scripts/levels/level_balance.gd b/scripts/levels/level_balance.gd index fea09b2..66ab55e 100644 --- a/scripts/levels/level_balance.gd +++ b/scripts/levels/level_balance.gd @@ -33,7 +33,7 @@ static func enemy_tiers(level: int) -> Array[int]: return tiers static func power_scale(level: int) -> float: - return 1.0 + 0.05 * (clampi(level, 1, MAX_LEVEL) - 1) + return 1.0 + 0.05 * (maxi(level, 1) - 1) static func is_hard(level: int) -> bool: return level > NORMAL_MAX_LEVEL diff --git a/scripts/levels/system_generator.gd b/scripts/levels/system_generator.gd index da658c1..a585138 100644 --- a/scripts/levels/system_generator.gd +++ b/scripts/levels/system_generator.gd @@ -48,6 +48,8 @@ static func generate(level: int, seed_value: int) -> Dictionary: planets[index].texture = earthlike[texture_rng.randi_range(0, earthlike.size() - 1)] planets[index].earthlike = profiles.is_earthlike(planets[index].texture) planets[index].profile = profiles.generate(seed_value, index, planets[index].name) if bool(planets[index].earthlike) else {} + if not planets[index].profile.is_empty(): + planets[index].profile.system_level = level var enemies: Array[Dictionary] = [] var ship_rng := RandomNumberGenerator.new() ship_rng.seed = seed_value ^ (level * 0x53484950) diff --git a/scripts/planets/distant_expeditions.gd b/scripts/planets/distant_expeditions.gd new file mode 100644 index 0000000..e79d1de --- /dev/null +++ b/scripts/planets/distant_expeditions.gd @@ -0,0 +1,33 @@ +extends RefCounted +const HERMIT_CHANCE := 0.35 +const CREDITS_PER_LEVEL := 1000 +const DEUTER_PER_LEVEL := 1000 + +static func roll(profile: Dictionary, salt: int) -> RandomNumberGenerator: + var rng := RandomNumberGenerator.new() + rng.seed = int(profile.get("seed", 0)) ^ str(profile.get("id", "")).hash() ^ salt + return rng + +static func has_hermit(profile: Dictionary) -> bool: + return roll(profile, 0x4845524d).randf() < HERMIT_CHANCE + +static func offer(profile: Dictionary, source_level: int) -> Dictionary: + # No upper cap: expedition planets can continue the chain beyond level 120. + var level := maxi(1, source_level) + roll(profile, 0x4d415053).randi_range(1, 5) + return {"id":str(profile.id)+":distant_map", "level":level, "seed":roll(profile, 0x44495354).randi(), "source_level":maxi(1,source_level), "source_planet":str(profile.get("name","Nieznana planeta")), "name":"Odległy sektor %03d" % level} + +static func map_cost(map: Dictionary) -> int: + return maxi(1, int(map.level)) * CREDITS_PER_LEVEL + +static func fuel_cost(map: Dictionary) -> int: + return maxi(1, int(map.level)) * DEUTER_PER_LEVEL + +static func buy(progress: Resource, captain: Resource, profile: Dictionary, source_level: int) -> bool: + if not has_hermit(profile): return false + var map := offer(profile, source_level) + if progress.distant_maps.has(str(map.id)) or captain.credits < map_cost(map): return false + captain.credits -= map_cost(map) + progress.distant_maps[str(map.id)] = map.duplicate(true) + captain.emit_changed() + progress.emit_changed() + return true diff --git a/scripts/planets/distant_expeditions.gd.uid b/scripts/planets/distant_expeditions.gd.uid new file mode 100644 index 0000000..60a0cf0 --- /dev/null +++ b/scripts/planets/distant_expeditions.gd.uid @@ -0,0 +1 @@ +uid://drpa8sluf2ond diff --git a/scripts/planets/distant_expeditions_panel.gd b/scripts/planets/distant_expeditions_panel.gd new file mode 100644 index 0000000..dfe635d --- /dev/null +++ b/scripts/planets/distant_expeditions_panel.gd @@ -0,0 +1,46 @@ +extends RefCounted +const MENU := preload("res://scripts/planets/freighter_menu.gd") +const DATA := preload("res://scripts/planets/distant_expeditions.gd") +static func build(screen: Control) -> void: + var progress: Resource = screen.captain.expedition + var section := MENU.card(screen._content) + section.name = "DistantExpeditions" + var art := TextureRect.new() + art.texture = load("res://assets/planets/expedition_navigation.png") + art.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + art.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED + art.custom_minimum_size.y = 125 + art.clip_contents = true + section.add_child(art) + MENU.label(section, "DALEKIE WYPRAWY / ARCHIWUM MAP", 16, Color("e9b4fc")) + MENU.label(section, "Sektory poza znanymi szlakami. Podwójna liczba wrogów, jeden boss. Kryształy czasu z każdego wroga. Zwykli wrogowie: mapy i powerbanki po 15%. Boss zawsze zostawia dokładnie 1 mapę i 1 losowy orb.", 12) + var fuel := MENU.label(section, "", 13, Color("95dce8")) + fuel.name = "ExpeditionFuel" + screen._economy_labels["expedition_fuel"] = fuel + MENU.label(section, "Lot: poziom × 1000 deuteru. Powrót bezpłatny. Mapy pozostają w archiwum i pozwalają wracać do sektora.", 12, MENU.GOLD) + if progress.distant_maps.is_empty(): + MENU.label(section, "Archiwum jest puste. Szukaj pustelnika na planecie lub map w łupach z wypraw.", 12, MENU.MUTED) + var maps: Array = progress.distant_maps.values() + maps.sort_custom(func(a,b): return int(a.level) 0 + refresh_status.call() + progress.changed.connect(refresh_status) + status.tree_exiting.connect(func(): + if progress.changed.is_connected(refresh_status): progress.changed.disconnect(refresh_status) + , CONNECT_ONE_SHOT) diff --git a/scripts/planets/distant_expeditions_panel.gd.uid b/scripts/planets/distant_expeditions_panel.gd.uid new file mode 100644 index 0000000..45deccf --- /dev/null +++ b/scripts/planets/distant_expeditions_panel.gd.uid @@ -0,0 +1 @@ +uid://0xw0o7hogm6k diff --git a/scripts/planets/expedition_controller.gd b/scripts/planets/expedition_controller.gd index 9a14ae0..67df396 100644 --- a/scripts/planets/expedition_controller.gd +++ b/scripts/planets/expedition_controller.gd @@ -269,6 +269,12 @@ func _exit_tree() -> void: get_tree().paused = _previous_pause get_tree().auto_accept_quit = true +func request_distant_travel(map_id: String) -> bool: + if not is_instance_valid(screen) or GameState.captain.expedition.active: return false + if not game.travel_to_distant(map_id): return false + _leave() + return true + func request_system_travel(level: int) -> bool: if not is_instance_valid(screen) or GameState.captain.expedition.active or level == GameState.system_level or not GameState.can_visit_system(level): return false if not game.travel_to_system(level): return false diff --git a/scripts/planets/freighter_command.gd b/scripts/planets/freighter_command.gd index b2d9b11..f3eeb7c 100644 --- a/scripts/planets/freighter_command.gd +++ b/scripts/planets/freighter_command.gd @@ -22,20 +22,22 @@ static func build(screen: Control) -> void: travel.disabled = true destination.item_selected.connect(func(_index): travel.disabled = destination.get_selected_id() == GameState.system_level) MENU.label(navigation, "Nowe układy odblokujesz, przechodząc dalej przez tunel po pokonaniu przeciwników.", 11, MENU.MUTED) + preload("res://scripts/planets/distant_expeditions_panel.gd").build(screen) var summary := MENU.card(screen._content) MENU.label(summary, "SIEĆ WYDOBYWCZA", 14, MENU.GOLD) var stock := MENU.label(summary, "", 13, Color("95dce8")) screen._economy_labels["network"] = stock - MENU.label(summary, "Obsadzona placówka: 1 deuter/min + punkty zarządzania. Dostawy automatyczne do magazynu.", 11, MENU.MUTED) + MENU.label(summary, "Wydobycie: zasobność × (1 + zarządzanie / 100) deuteru/min. Bez załoganta: 0. Złoża nie wyczerpują się.", 11, MENU.MUTED) MENU.label(summary, "Jeden załogant obsługuje jedną placówkę. Nowy przydział przenosi go z poprzedniej.", 11, MENU.MUTED) if progress.mining_stations.is_empty(): - MENU.label(summary, "Brak placówek. Szukaj głębokich złóż na planetach (szansa 60%). Budowa: 200 części.", 12) + MENU.label(summary, "Brak placówek. Szukaj głębokich złóż na planetach (szansa 60%). Budowa: zasobność × 10 części.", 12) for station_id in progress.mining_stations: var station: Dictionary = progress.mining_stations[station_id] var card := MENU.card(screen._content) MENU.label(card, "%s · SYSTEM %d" % [station.planet_name, station.system], 13, MENU.GOLD) + MENU.label(card, "Zasobność: %d / 100" % NETWORK.station_richness(station), 12, Color("95dce8")) var member: Dictionary = progress.crew.get(str(station.get("crew_id", "")), {}) - MENU.label(card, "Czeka na załoganta" if member.is_empty() else "%s · zarządzanie %d/100 · %.0f deuteru/min" % [member.name, member.management, NETWORK.station_rate(progress, station)], 12) + MENU.label(card, "Czeka na załoganta" if member.is_empty() else "%s · zarządzanie %d/100 · %.1f deuteru/min" % [member.name, member.management, NETWORK.station_rate(progress, station)], 12) var assign_row := MENU.row(card) var option := OptionButton.new() option.name = "StationCrew_" + str(station_id).md5_text().substr(0, 8) @@ -65,7 +67,7 @@ static func cabin(screen: Control) -> void: MENU.label(card, "Przygotowana dla jednego załoganta. Specjalistów możesz zatrudniać na planetach.", 12) return MENU.label(card, str(member.name), 15, MENU.GOLD) - MENU.label(card, "Zarządzanie: %d / 100\nPremia do wydobycia: +%d deuteru/min\nPochodzenie: %s" % [member.management, member.management, member.planet_name], 12) + MENU.label(card, "Zarządzanie: %d / 100\nPremia do wydobycia: +%d%%\nPochodzenie: %s" % [member.management, member.management, member.planet_name], 12) var assignment := "Bez przydziału" for station in progress.mining_stations.values(): if str(station.get("crew_id", "")) == str(member.id): assignment = "%s · System %d" % [station.planet_name, station.system] diff --git a/scripts/planets/freighter_screen.gd b/scripts/planets/freighter_screen.gd index c119986..deee33d 100644 --- a/scripts/planets/freighter_screen.gd +++ b/scripts/planets/freighter_screen.gd @@ -123,7 +123,7 @@ func _layout() -> void: func _sync_header() -> void: if is_instance_valid(_wallet): - _wallet.text = "%d KREDYTÓW\n%d CZĘŚCI" % [captain.credits,floori(captain.parts)] + _wallet.text = "%d KREDYTÓW\n%d CZĘŚCI\n%d KRYSZTAŁÓW CZASU" % [captain.credits,floori(captain.parts),captain.expedition.time_crystals] func _hover_section(section: String) -> void: var data: Dictionary = deck.room(section) @@ -455,11 +455,12 @@ func _build_resource_overview() -> void: for key in captain.minerals: values[key] = int(captain.minerals[key]) if is_instance_valid(inventory): for key in ["ammunition","missile","irradiated_ammunition","energy_bank"]: values[key] = inventory.get_crafted_stock(key) + values.time_crystals = captain.expedition.time_crystals values.samples = captain.expedition.analysis_samples values.deuter = captain.expedition.deuter var names: Dictionary = RECIPES.NAMES.duplicate() names.merge(GEAR.MATERIAL_NAMES) - names.merge({"credits":"Kredyty","parts":"Części","ammunition":"Amunicja","missile":"Rakiety","irradiated_ammunition":"Radiacyjna","energy_bank":"Powerbanki","samples":"Próbki","deuter":"Deuter"}) + names.merge({"credits":"Kredyty","parts":"Części","ammunition":"Amunicja","missile":"Rakiety","irradiated_ammunition":"Radiacyjna","energy_bank":"Powerbanki","samples":"Próbki","deuter":"Deuter","time_crystals":"Kryształy czasu"}) for key in values: var tile := MENU.card(grid) tile.get_parent().name = "Stock_"+key @@ -537,6 +538,7 @@ func _craft_supply(id: String, batches := 1) -> void: func _build_workshop() -> void: MENU.hero(_content,"workshop","") _build_supply_production() + preload("res://scripts/planets/orb_workshop.gd").build(self) _label("EKWIPUNEK KAPITANA",13,MENU.GOLD) var grid := GridContainer.new() grid.columns = 3 @@ -574,6 +576,10 @@ func _travel_to_system(level: int) -> void: var controller := get_parent().get_parent() if controller.has_method("request_system_travel"): controller.request_system_travel(level) +func _travel_to_distant(map_id: String) -> void: + var controller := get_parent().get_parent() + if controller.has_method("request_distant_travel"): controller.request_distant_travel(map_id) + func _assign_station(station_id: String, option: OptionButton) -> void: preload("res://scripts/planets/mining_network.gd").assign(captain.expedition, station_id, str(option.get_selected_metadata())) _refresh() @@ -586,6 +592,9 @@ func _refresh_live_labels() -> void: for key in _economy_labels: var label: Label = _economy_labels[key] if not is_instance_valid(label): continue + if key == "expedition_fuel": + label.text = "Zapas paliwa: %.1f deuteru" % captain.expedition.deuter + continue label.text = "Deuter: %.1f · Produkcja: %.0f/min" % [captain.expedition.deuter, preload("res://scripts/planets/mining_network.gd").total_rate(captain.expedition)] if key == "network" else "%.1f" % captain.expedition.deuter func _craft(kind: String) -> void: diff --git a/scripts/planets/mining_network.gd b/scripts/planets/mining_network.gd index e4dfede..ca6f574 100644 --- a/scripts/planets/mining_network.gd +++ b/scripts/planets/mining_network.gd @@ -1,6 +1,7 @@ extends RefCounted ## The network belongs to expedition progress, so it survives visits and system travel. -const BUILD_COST := 200 +const PARTS_PER_RICHNESS := 10 +const CREDITS_PER_MANAGEMENT := 1000 const DEPOSIT_CHANCE := 0.60 const CREW_CHANCE := 0.40 const CABINS := ["cabin_1", "cabin_2", "cabin_3", "cabin_4", "cabin_5"] @@ -15,6 +16,38 @@ static func planet_roll(profile: Dictionary, salt: int) -> RandomNumberGenerator static func has_deep_deposit(profile: Dictionary) -> bool: return planet_roll(profile, 0x44454550).randf() < DEPOSIT_CHANCE +# Strict level bands prevent early systems from rolling endgame deposits. +static func richness_bounds(system: int) -> Vector2i: + var levels := [1, 20, 40, 60, 80, 100, 120] + var lows := [1, 8, 20, 35, 50, 65, 80] + var highs := [10, 25, 40, 55, 70, 85, 100] + var level := clampi(system, 1, 120) + for index in range(1, levels.size()): + if level <= int(levels[index]): + var weight := float(level - int(levels[index-1])) / float(int(levels[index]) - int(levels[index-1])) + return Vector2i(roundi(lerpf(lows[index-1], lows[index], weight)), roundi(lerpf(highs[index-1], highs[index], weight))) + return Vector2i(80, 100) + +static func deposit_richness(profile: Dictionary, system: int) -> int: + var bounds := richness_bounds(system) + return planet_roll(profile, 0x52494348).randi_range(bounds.x, bounds.y) + +static func build_cost(profile: Dictionary, system: int) -> int: + return deposit_richness(profile, system) * PARTS_PER_RICHNESS + +static func recruitment_cost(candidate: Dictionary) -> int: + return clampi(int(candidate.get("management", 1)), 1, 100) * CREDITS_PER_MANAGEMENT + +static func station_richness(station: Dictionary) -> int: + if not station.has("richness"): + # Upgrade existing stations using their original planet identity and system. + var identity := str(station.id).split(":") + var profile := {"id":str(station.id), "seed":int(station.get("planet_seed", 0))} + if identity.size() == 3 and identity[1] == "planet": + profile = preload("res://scripts/planets/planet_profiles.gd").generate(int(identity[0]), int(identity[2]), str(station.planet_name)) + station.richness = deposit_richness(profile, int(station.get("system", 1))) + return clampi(int(station.richness), 1, 100) + static func has_recruit(profile: Dictionary) -> bool: return planet_roll(profile, 0x43524557).randf() < CREW_CHANCE @@ -34,13 +67,16 @@ static func cabin_crew(progress: Resource, cabin: String) -> Dictionary: if str(member.cabin) == cabin: return member return {} -static func hire(progress: Resource, profile: Dictionary) -> bool: +static func hire(progress: Resource, captain: Resource, profile: Dictionary) -> bool: var candidate := recruit(profile) var cabin := free_cabin(progress) - if not has_recruit(profile) or progress.recruited_planets.has(str(profile.id)) or cabin.is_empty(): return false + var cost := recruitment_cost(candidate) + if not has_recruit(profile) or progress.recruited_planets.has(str(profile.id)) or cabin.is_empty() or captain.credits < cost: return false + captain.credits -= cost candidate.cabin = cabin progress.crew[candidate.id] = candidate progress.recruited_planets[str(profile.id)] = true + captain.emit_changed() progress.emit_changed() return true @@ -54,9 +90,10 @@ static func dismiss(progress: Resource, member_id: String) -> bool: static func build(progress: Resource, captain: Resource, profile: Dictionary, system: int) -> bool: var id := str(profile.id) - if not has_deep_deposit(profile) or progress.mining_stations.has(id) or captain.parts < BUILD_COST: return false - captain.parts -= BUILD_COST - progress.mining_stations[id] = {"id":id, "planet_name":str(profile.name), "system":system, "crew_id":"", "produced":0.0} + var cost := build_cost(profile, system) + if not has_deep_deposit(profile) or progress.mining_stations.has(id) or captain.parts < cost: return false + captain.parts -= cost + progress.mining_stations[id] = {"id":id, "planet_name":str(profile.name), "system":system, "planet_seed":int(profile.get("seed", 0)), "richness":deposit_richness(profile, system), "crew_id":"", "produced":0.0} captain.emit_changed() progress.emit_changed() return true @@ -74,7 +111,7 @@ static func assign(progress: Resource, station_id: String, member_id: String) -> static func station_rate(progress: Resource, station: Dictionary) -> float: var id := str(station.get("crew_id", "")) if not progress.crew.has(id): return 0.0 - return 1.0 + clampi(int(progress.crew[id].management), 1, 100) + return station_richness(station) * (1.0 + clampi(int(progress.crew[id].management), 1, 100) / 100.0) static func total_rate(progress: Resource) -> float: var total := 0.0 diff --git a/scripts/planets/orb_workshop.gd b/scripts/planets/orb_workshop.gd new file mode 100644 index 0000000..6b57875 --- /dev/null +++ b/scripts/planets/orb_workshop.gd @@ -0,0 +1,41 @@ +extends RefCounted +const MENU := preload("res://scripts/planets/freighter_menu.gd") +const ORBS := preload("res://scripts/items/special_orbs.gd") +static func build(screen: Control) -> void: + var progress: Resource = screen.captain.expedition + MENU.label(screen._content,"ORBY / SPECJALNE ULEPSZENIA",15,Color("d9b5ff")) + MENU.label(screen._content,"Każdy orb jest jednorazowy i znika po użyciu. Kolejny awans wymaga kolejnej sztuki. Efekty przebicia i dopalacza nie sumują się.",12,MENU.MUTED) + for id in ORBS.IDS: + var card := MENU.card(screen._content) + var header := MENU.row(card) + MENU.icon(header,ORBS.icon(id),48) + var title := MENU.label(header,"%s · %d szt." % [ORBS.NAMES[id],int(progress.orbs.get(id,0))],14,MENU.GOLD) + title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + title.custom_minimum_size.x = 180 + MENU.label(card,ORBS.DESCRIPTIONS[id],12) + var choices := OptionButton.new() + choices.size_flags_horizontal = Control.SIZE_EXPAND_FILL + choices.fit_to_longest_item = false + choices.add_theme_font_override("font",ThemeDB.fallback_font) + choices.add_theme_font_size_override("font_size",12) + card.add_child(choices) + if is_instance_valid(screen.inventory): + for kind in ["weapon","module","cargo"]: + var items: Array = screen.inventory._get_slots(kind) + for index in items.size(): + if not screen.inventory.is_slot_unlocked(kind,index): continue + _add_item(choices,items[index],id,{"weapon":"Broń","module":"Moduł","cargo":"Ładownia"}[kind]) + for item in progress.freighter_storage: _add_item(choices,item,id,"Magazyn fregaty") + if choices.item_count == 0: choices.add_item("Brak odpowiedniego przedmiotu") + var apply := MENU.button(card,"AWANSUJ +1 POZIOM" if id == "advancement" else "NAŁÓŻ ORB",func(): + var target: ItemInstance = choices.get_selected_metadata() + if ORBS.apply(progress,screen.inventory,target,id): + screen._audio.service_completed(-0.48) + screen._refresh() + ,true) + apply.name = "ApplyOrb_"+id + apply.disabled = int(progress.orbs.get(id,0)) <= 0 or choices.get_selected_metadata() == null +static func _add_item(choices: OptionButton, item: ItemInstance, orb: String, location: String) -> void: + if not ORBS.accepts(item,orb): return + choices.add_item("%s · poz. %d · %s" % [item.definition.display_name,item.item_level,location]) + choices.set_item_metadata(choices.item_count-1,item) diff --git a/scripts/planets/orb_workshop.gd.uid b/scripts/planets/orb_workshop.gd.uid new file mode 100644 index 0000000..340b551 --- /dev/null +++ b/scripts/planets/orb_workshop.gd.uid @@ -0,0 +1 @@ +uid://dlhcrumvgnr2c diff --git a/scripts/planets/planet_progress.gd b/scripts/planets/planet_progress.gd index a90f0cb..d6c043c 100644 --- a/scripts/planets/planet_progress.gd +++ b/scripts/planets/planet_progress.gd @@ -10,6 +10,9 @@ const PROFILES := preload("res://scripts/planets/planet_profiles.gd") @export var alien_skills: Dictionary = {} @export var freighter_rooms: Dictionary = {"expansion_3":"module_storage"} @export var mining_stations: Dictionary = {} +@export var distant_maps: Dictionary = {} +@export var orbs: Dictionary = {} +@export var time_crystals: int = 0 @export var crew: Dictionary = {} @export var recruited_planets: Dictionary = {} @export var deuter := 0.0 diff --git a/scripts/planets/poi_rules.gd b/scripts/planets/poi_rules.gd index ed655b6..b18957a 100644 --- a/scripts/planets/poi_rules.gd +++ b/scripts/planets/poi_rules.gd @@ -1,5 +1,5 @@ extends RefCounted -const NAMES := {"deposit":"Złoże surowców", "ruins":"Starożytna struktura", "warehouse":"Opuszczony magazyn", "village":"Opuszczona wioska", "cave":"Jaskinia", "deep_deposit":"Głębokie złoże deuteru", "crew":"Obóz specjalisty"} +const NAMES := {"deposit":"Złoże surowców", "ruins":"Starożytna struktura", "warehouse":"Opuszczony magazyn", "village":"Opuszczona wioska", "cave":"Jaskinia", "deep_deposit":"Głębokie złoże deuteru", "crew":"Domek specjalisty", "hermit":"Pustelnik / Kartograf", "time_engineer":"Inżynier chronalny"} const STAGES := { "deposit":["Zbierz odsłonięte minerały", "Wykonaj odwiert", "Wydobądź głęboką żyłę"], "ruins":["Przeszukaj przedsionek", "Otwórz komorę", "Zbadaj rdzeń"], diff --git a/scripts/planets/specialist_house.gd b/scripts/planets/specialist_house.gd new file mode 100644 index 0000000..833dafd --- /dev/null +++ b/scripts/planets/specialist_house.gd @@ -0,0 +1,34 @@ +extends Sprite2D +# A recognizable inhabited home, distinct from abandoned surface structures. +func _ready() -> void: + texture = null + scale = Vector2.ONE + queue_redraw() + +func _draw() -> void: + draw_ellipse_shadow() + draw_style_box(_walls(), Rect2(-39,-23,78,63)) + draw_colored_polygon(PackedVector2Array([Vector2(-51,-21),Vector2(0,-62),Vector2(51,-21)]), Color("426b69")) + draw_polyline(PackedVector2Array([Vector2(-51,-21),Vector2(0,-62),Vector2(51,-21)]), Color("91b9ac"), 3, true) + draw_rect(Rect2(-10,6,20,34),Color("203c3c")) + draw_circle(Vector2(4,25),2,Color("eaca8a")) + for x in [-28,17]: + draw_rect(Rect2(x,-7,14,17),Color("f0d592")) + draw_line(Vector2(x+7,-7),Vector2(x+7,10),Color("526d62"),2) + draw_rect(Rect2(24,-56,9,24),Color("647a70")) + draw_line(Vector2(60,-15),Vector2(60,42),Color("7fada1"),3) + draw_colored_polygon(PackedVector2Array([Vector2(62,-15),Vector2(84,-8),Vector2(62,0)]),Color("a7e5a2")) + draw_circle(Vector2(60,-17),4,Color("b9f1b2")) + draw_rect(Rect2(-14,40,28,8),Color("778b7e")) + +func _walls() -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = Color("879685") + style.border_color = Color("364e47") + style.set_border_width_all(3) + return style + +func draw_ellipse_shadow() -> void: + draw_set_transform(Vector2(7,40),0,Vector2(1,0.35)) + draw_circle(Vector2.ZERO,60,Color(0.02,0.06,0.04,0.3)) + draw_set_transform(Vector2.ZERO) diff --git a/scripts/planets/specialist_house.gd.uid b/scripts/planets/specialist_house.gd.uid new file mode 100644 index 0000000..ba70c31 --- /dev/null +++ b/scripts/planets/specialist_house.gd.uid @@ -0,0 +1 @@ +uid://bfhj4sh150ece diff --git a/scripts/planets/surface_flight_effects.gd b/scripts/planets/surface_flight_effects.gd index 005dba9..f19efb6 100644 --- a/scripts/planets/surface_flight_effects.gd +++ b/scripts/planets/surface_flight_effects.gd @@ -1,6 +1,6 @@ extends Node2D ## Ground wash stays in world space after emission, independently of the lander. -const LIMIT := 144 +const LIMIT := 220 var source: Control var weather: Node2D var _rng := RandomNumberGenerator.new() @@ -68,8 +68,8 @@ func _process(delta: float) -> void: var brake: bool = not source._dialog.visible and Input.is_action_pressed("ship_brake") and source._motion.velocity.length() > 12 var boost: bool = source._motion.boost_active and thrust if thrust or brake: - var rate := 60.0 if boost else (26.0 if thrust else 18.0) - if _surface_kind == "rock": rate *= 0.3 + var rate := 90.0 if boost else (46.0 if thrust else 30.0) + if _surface_kind == "rock": rate *= 0.45 _emission += delta*rate while _emission >= 1: _emission -= 1 @@ -87,8 +87,8 @@ func _emit(point: Vector2, angle: float, boost: bool) -> void: var kind := _surface_kind if _near_trees and kind == "dust" and _rng.randf() < 0.20: kind = "leaf" var nozzle := Vector2(_rng.randf_range(-29,29),_rng.randf_range(22,42)).rotated(angle) - var scatter := Vector2(_rng.randf_range(-32,32),_rng.randf_range(10,45)).rotated(angle) - _particles.append({"position":point+nozzle,"velocity":scatter+source._motion.velocity*0.1,"age":0.0,"life":_rng.randf_range(1.1,2.4),"size":_rng.randf_range(9,17)*(1.4 if boost else 1.0),"kind":kind,"angle":_rng.randf_range(-PI,PI)}) + var scatter := Vector2(_rng.randf_range(-48,48),_rng.randf_range(20,65)).rotated(angle) + _particles.append({"position":point+nozzle,"velocity":scatter+source._motion.velocity*0.1,"age":0.0,"life":_rng.randf_range(1.5,2.8),"size":_rng.randf_range(15,25)*(1.55 if boost else 1.0),"kind":kind,"angle":_rng.randf_range(-PI,PI)}) func _draw() -> void: for particle in _particles: @@ -97,10 +97,10 @@ func _draw() -> void: var kind: String = particle.kind var radius: float = particle.size*(1.0+t*1.9) var color := _ground - color.a = alpha*0.14 - if kind == "rock": color = Color(0.65,0.65,0.59,alpha*0.09) - if kind == "snow": color = Color(0.88,0.95,0.95,alpha*0.25) - if kind == "spray": color = Color(0.66,0.83,0.80,alpha*0.18) + color.a = alpha*0.32 + if kind == "rock": color = Color(0.65,0.65,0.59,alpha*0.19) + if kind == "snow": color = Color(0.88,0.95,0.95,alpha*0.38) + if kind == "spray": color = Color(0.66,0.83,0.80,alpha*0.30) if kind == "leaf": draw_set_transform(particle.position,particle.angle+t*2,Vector2(1,0.45)) draw_circle(Vector2.ZERO,2.5,Color(0.49,0.35,0.17,alpha*0.7),true,-1,true) diff --git a/scripts/planets/surface_generator.gd b/scripts/planets/surface_generator.gd index 9cfbeda..b215eeb 100644 --- a/scripts/planets/surface_generator.gd +++ b/scripts/planets/surface_generator.gd @@ -61,6 +61,8 @@ static func generate(seed_value: int, profile: Dictionary) -> Dictionary: var network := preload("res://scripts/planets/mining_network.gd") if network.has_deep_deposit(profile): kinds.append("deep_deposit") if network.has_recruit(profile): kinds.append("crew") + if preload("res://scripts/planets/distant_expeditions.gd").has_hermit(profile): kinds.append("hermit") + if preload("res://scripts/planets/time_engineer.gd").has_engineer(profile): kinds.append("time_engineer") for index in kinds.size(): var kind: String = kinds[index] var chosen := Vector2.ZERO diff --git a/scripts/planets/surface_minimap.gd b/scripts/planets/surface_minimap.gd index 9b8d91f..a2adbf5 100644 --- a/scripts/planets/surface_minimap.gd +++ b/scripts/planets/surface_minimap.gd @@ -21,8 +21,20 @@ func _draw() -> void: if poi.discovered: var at: Vector2 = (poi.position / total + Vector2.ONE * 0.5) * size var color := Color("708377") if poi.complete else Color("f4cb80") + if poi.kind == "time_engineer": + color = Color("9be7ff") + draw_colored_polygon(PackedVector2Array([at+Vector2(0,-4),at+Vector2(4,0),at+Vector2(0,4),at+Vector2(-4,0)]),color) + continue + if poi.kind == "hermit": + color = Color("e9b4fc") + draw_rect(Rect2(at-Vector2(3,3),Vector2(6,6)),color,false,1.5) + continue if poi.kind == "deep_deposit": color = Color("84dff3") - elif poi.kind == "crew" and not poi.complete: color = Color("a7e5a2") + elif poi.kind == "crew" and not poi.complete: + color = Color("a7e5a2") + draw_colored_polygon(PackedVector2Array([at+Vector2(-4,0),at+Vector2(0,-5),at+Vector2(4,0)]), color) + draw_rect(Rect2(at+Vector2(-3,0),Vector2(6,4)),color) + continue draw_circle(at, 3.0 if poi.kind == "deep_deposit" else 2.5, color) for event in world.data.events: if not event.complete: diff --git a/scripts/planets/surface_screen.gd b/scripts/planets/surface_screen.gd index 6cfc3bb..db7b66a 100644 --- a/scripts/planets/surface_screen.gd +++ b/scripts/planets/surface_screen.gd @@ -19,6 +19,8 @@ var _status: Label var _dialog: PanelContainer var _choices: VBoxContainer var _poi: Dictionary = {} +var _engineer_item: ItemInstance +var _engineer_addon := 0 var _rng := RandomNumberGenerator.new() var _motion := preload("res://scripts/planets/lander_motion.gd").new() var flight_ship: Node @@ -228,6 +230,12 @@ func _show_poi(message := "") -> void: POI_CARD.panel_style(_dialog) _choices.add_theme_constant_override("separation",8) _layout_dialog() + if _poi.kind == "time_engineer": + preload("res://scripts/planets/time_engineer_panel.gd").build(self,message) + return + if _poi.kind == "hermit": + _show_hermit(message) + return if _poi.kind in ["deep_deposit", "crew"]: _show_colony_poi(message) return @@ -415,31 +423,40 @@ func _show_colony_poi(message: String) -> void: art.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED art.custom_minimum_size.y = 170 _choices.add_child(art) - POI_CARD.text(_choices, "GŁĘBOKIE ZŁOŻE / DEUTER" if is_deposit else "SPECJALISTA / REKRUTACJA", 15, POI_CARD.GOLD) + POI_CARD.text(_choices, "GŁĘBOKIE ZŁOŻE / DEUTER" if is_deposit else "DOMEK SPECJALISTY / REKRUTACJA", 15, POI_CARD.GOLD) if not message.is_empty(): POI_CARD.text(_choices, message, 13, POI_CARD.GOLD) if is_deposit: var id := str(progress.active_profile.id) + var richness: int = network.deposit_richness(progress.active_profile, int(progress.active_profile.get("system_level", GameState.system_level))) + if progress.mining_stations.has(id): + richness = network.station_richness(progress.mining_stations[id]) + POI_CARD.text(_choices, "Zasobność: %d / 100 · złoże niewyczerpywalne" % richness, 14, POI_CARD.GOLD) + POI_CARD.text(_choices, "Z załogantem: %.1f–%.1f deuteru/min (zarządzanie 1–100)." % [richness * 1.01, richness * 2.0], 12) if progress.mining_stations.has(id): var station: Dictionary = progress.mining_stations[id] - POI_CARD.text(_choices, "Placówka zbudowana · %.0f deuteru/min" % network.station_rate(progress, station), 13) + POI_CARD.text(_choices, "Placówka zbudowana · %.1f deuteru/min" % network.station_rate(progress, station), 13) POI_CARD.text(_choices, "Przydziel załoganta w centrum dowodzenia fregaty. Deuter trafia automatycznie do magazynu, również gdy jesteś poza tą planetą.", 12) else: - POI_CARD.text(_choices, "Pod powierzchnią znajduje się głęboki pokład deuteru. Placówka wymaga 200 części i załoganta do zarządzania.", 13) - POI_CARD.text(_choices, "Bazowo 1 deuter/min + punkty zarządzania załoganta. Części: %d / 200" % int(captain.parts), 12) - var build := POI_CARD.button(_choices, "ZBUDUJ PLACÓWKĘ · 200 CZĘŚCI", _build_outpost, true) + var cost: int = network.build_cost(progress.active_profile, int(progress.active_profile.get("system_level", GameState.system_level))) + POI_CARD.text(_choices, "Budowa kosztuje zasobność × 10 części. Do wydobycia potrzebny jest załogant.", 13) + POI_CARD.text(_choices, "Wydobycie = zasobność × (1 + zarządzanie / 100). Bez załoganta: 0. Części: %d / %d" % [int(captain.parts), cost], 12) + var build := POI_CARD.button(_choices, "ZBUDUJ PLACÓWKĘ · %d CZĘŚCI" % cost, _build_outpost, true) build.name = "BuildMiningOutpost" - build.disabled = captain.parts < network.BUILD_COST + build.disabled = captain.parts < cost else: + POI_CARD.text(_choices, "Mieszkaniec domku szuka pracy. Obejrzyj umiejętności i zdecyduj — możesz wrócić później. Koszt zatrudnienia: 1000 kredytów × zarządzanie.", 12) var candidate: Dictionary = network.recruit(progress.active_profile) - POI_CARD.text(_choices, "%s\nZarządzanie: %d / 100\nPremia do wydobycia: +%d deuteru/min" % [candidate.name, candidate.management, candidate.management], 14) + var hire_cost: int = network.recruitment_cost(candidate) + POI_CARD.text(_choices, "Rekrutacja: %d kredytów · posiadasz: %d" % [hire_cost, captain.credits], 13, POI_CARD.GOLD) + POI_CARD.text(_choices, "%s\nZarządzanie: %d / 100\nPremia do wydobycia: +%d%%" % [candidate.name, candidate.management, candidate.management], 14) if progress.recruited_planets.has(str(progress.active_profile.id)): POI_CARD.text(_choices, "Specjalista został już zatrudniony.", 12) else: var cabin: String = network.free_cabin(progress) POI_CARD.text(_choices, "Brak wolnej, zbudowanej kajuty. Przygotuj pokój na fregacie." if cabin.is_empty() else "Zakwaterowanie: Kajuta %02d" % int(cabin.trim_prefix("cabin_")), 12) - var hire := POI_CARD.button(_choices, "ZATRUDNIJ ZAŁOGANTA", _hire_crew, true) + var hire := POI_CARD.button(_choices, "ZATRUDNIJ · %d KREDYTÓW" % hire_cost, _hire_crew, true) hire.name = "HireCrew" - hire.disabled = cabin.is_empty() + hire.disabled = cabin.is_empty() or captain.credits < hire_cost POI_CARD.button(_choices, "Wróć do wyprawy", func(): _dialog.hide()) func _build_outpost() -> void: @@ -450,9 +467,52 @@ func _build_outpost() -> void: _show_poi("Placówka gotowa. Przydziel operatora w centrum dowodzenia.") else: _show_poi("Nie można zbudować placówki. Sprawdź liczbę części.") +func _maximize_addon() -> void: + if _ending or not progress.active or _poi.kind != "time_engineer" or not is_instance_valid(flight_ship): return + if lander.position.distance_to(_poi.position) > 150: return + var service := preload("res://scripts/planets/time_engineer.gd") + if service.maximize(progress,flight_ship.get_node("ShipInventory"),_engineer_item,_engineer_addon): + captain.emit_changed() + _show_poi("Wybrany addon ma teraz maksymalną wartość dla obecnego poziomu przedmiotu.") + else: + _show_poi("Brakuje kryształów, addon jest już maksymalny lub przedmiot jest niedostępny.") + +func _show_hermit(message: String) -> void: + var expeditions := preload("res://scripts/planets/distant_expeditions.gd") + var profile: Dictionary = progress.active_profile + var map: Dictionary = expeditions.offer(profile, int(profile.get("system_level", GameState.system_level))) + var art := TextureRect.new() + art.texture = load("res://assets/planets/props/hermit_cartographer.png") + art.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + art.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + art.custom_minimum_size.y = 175 + _choices.add_child(art) + POI_CARD.text(_choices, "PUSTELNIK / KARTOGRAF", 18, Color("e9b4fc")) + POI_CARD.text(_choices, "W zaciszu chaty pustelnik przechowuje współrzędne miejsc poza znanymi szlakami. Oferuje ci jedną ze swoich map.", 13) + if not message.is_empty(): POI_CARD.text(_choices, message, 13, POI_CARD.GOLD) + POI_CARD.text(_choices, "%s · poziom wyprawy %d" % [map.name, map.level], 15, POI_CARD.GOLD) + POI_CARD.text(_choices, "Mapa: %d kredytów\nPaliwo na lot: %d deuteru\nPosiadasz: %d kredytów" % [expeditions.map_cost(map),expeditions.fuel_cost(map),captain.credits], 13) + if progress.distant_maps.has(str(map.id)): + POI_CARD.text(_choices, "Mapa jest już w archiwum na frachtowcu.", 13) + else: + var buy := POI_CARD.button(_choices, "KUP MAPĘ · %d KREDYTÓW" % expeditions.map_cost(map), _buy_distant_map, true) + buy.name = "BuyDistantMap" + buy.disabled = captain.credits < expeditions.map_cost(map) + POI_CARD.button(_choices, "Wróć do wyprawy", func(): _dialog.hide()) + +func _buy_distant_map() -> void: + if _ending or not progress.active or _poi.kind != "hermit": return + var profile: Dictionary = progress.active_profile + if preload("res://scripts/planets/distant_expeditions.gd").buy(progress,captain,profile,int(profile.get("system_level",GameState.system_level))): + world.refresh_pois(lander.position) + _show_poi("Mapa zapisana w archiwum dalekich wypraw na frachtowcu.") + else: + _show_poi("Mapa została już kupiona lub brakuje kredytów.") + func _hire_crew() -> void: if _ending or not progress.active or _poi.kind != "crew": return - if preload("res://scripts/planets/mining_network.gd").hire(progress, progress.active_profile): + if preload("res://scripts/planets/mining_network.gd").hire(progress, captain, progress.active_profile): _poi.complete = true - _show_poi("Załogant zajął wolną kajutę na fregacie.") - else: _show_poi("Zatrudnienie wymaga wolnej, zbudowanej kajuty.") + world.refresh_pois(lander.position) + _show_poi("Załogant zajął wolną kajutę na fregacie. Przydziel go do stacji w centrum dowodzenia.") + else: _show_poi("Zatrudnienie wymaga wolnej, zbudowanej kajuty i kredytów: 1000 × zarządzanie.") diff --git a/scripts/planets/surface_world.gd b/scripts/planets/surface_world.gd index 86f1f35..8b8a429 100644 --- a/scripts/planets/surface_world.gd +++ b/scripts/planets/surface_world.gd @@ -69,12 +69,14 @@ func _ready() -> void: sprite.set_script(preload("res://scripts/planets/surface_cave.gd")) sprite.configure(data,poi) else: - var textures := {"ruins":"ruins", "warehouse":"props/warehouse", "village":"landmarks/abandoned_hamlet", "crew":"props/engineer_camp"} + var textures := {"ruins":"ruins", "warehouse":"props/warehouse", "village":"landmarks/abandoned_hamlet", "crew":"props/engineer_camp", "hermit":"props/hermit_cartographer", "time_engineer":"props/time_engineer"} sprite.texture = load("res://assets/planets/%s.png" % textures[poi.kind]) sprite.position = poi.position if sprite.texture != null and poi.kind != "cave": sprite.scale = Vector2.ONE * 125.0*BUILDING_SCALE / maxf(sprite.texture.get_width(), sprite.texture.get_height()) sprite.z_index = 10 + if poi.kind == "crew": + sprite.set_script(preload("res://scripts/planets/specialist_house.gd")) add_child(sprite) _poi_sprites[poi.id] = sprite var label := Label.new() @@ -148,9 +150,21 @@ func refresh_pois(lander_position: Vector2) -> void: sprite.rotation = 0 sprite.scale = Vector2.ONE * 185.0 / maxf(sprite.texture.get_width(), sprite.texture.get_height()) sprite.set_meta("outpost", true) - label.text = "Placówka górnicza" if built else "Głębokie złoże deuteru" + var network := preload("res://scripts/planets/mining_network.gd") + var richness: int = network.station_richness(GameState.captain.expedition.mining_stations[str(data.profile.id)]) if built else network.deposit_richness(data.profile, int(data.profile.get("system_level", GameState.system_level))) + label.text = ("%s · %d/100" % ["Placówka górnicza" if built else "Głębokie złoże deuteru", richness]) if lander_position.distance_to(poi.position) <= 150: label.text += "\n[F] Placówka" if built else "\n[F] Buduj" continue + if poi.kind == "time_engineer": + label.text = "Inżynier chronalny" + ("\n[F] Maksymalizuj addon" if lander_position.distance_to(poi.position) <= 150 else "") + continue + if poi.kind == "hermit": + var owned: bool = GameState.captain.expedition.distant_maps.has(str(data.profile.id)+":distant_map") + label.text = "Pustelnik / Kartograf" + (" · mapa kupiona" if owned else ("\n[F] Kup mapę" if lander_position.distance_to(poi.position) <= 150 else "")) + continue + if poi.kind == "crew": + label.text = "Domek specjalisty" + (" · zatrudniono" if poi.complete else ("\n[F] Porozmawiaj" if lander_position.distance_to(poi.position) <= 150 else "")) + continue var title: String = preload("res://scripts/planets/poi_rules.gd").NAMES[poi.kind] label.text = title + ((" · zatrudniono" if poi.kind == "crew" else " · zbadano") if poi.complete else ("\n[F] Zbadaj" if lander_position.distance_to(poi.position) <= 150 else "")) diff --git a/scripts/planets/time_engineer.gd b/scripts/planets/time_engineer.gd new file mode 100644 index 0000000..527dd67 --- /dev/null +++ b/scripts/planets/time_engineer.gd @@ -0,0 +1,46 @@ +extends RefCounted +const CHANCE := 0.30 +const CRYSTALS_PER_LEVEL := 10 +const AFFIXES := preload("res://resources/items/affix_database.tres") + +static func has_engineer(profile: Dictionary) -> bool: + var rng := RandomNumberGenerator.new() + rng.seed = int(profile.get("seed",0)) ^ str(profile.get("id","")).hash() ^ 0x54494d45 + return rng.randf() < CHANCE + +static func cost(item: ItemInstance) -> int: + return maxi(1,item.item_level)*CRYSTALS_PER_LEVEL if item != null else 0 + +static func affix_for(item: ItemInstance, addon: int) -> ItemAffixDefinition: + if item == null or item.definition == null or addon < 0 or addon >= item.additional_modifiers.size(): return null + var modifier: ItemStatModifier = item.additional_modifiers[addon] + if modifier == null: return null + if modifier.affix != null: return modifier.affix + for candidate in AFFIXES.affixes: + if candidate != null and candidate.stat_key == modifier.stat_key and candidate.can_roll(item.definition,item.item_level): return candidate + return null + +static func maximum(item: ItemInstance, addon: int) -> float: + var affix := affix_for(item,addon) + return affix.get_value_range(item.item_level).y if affix != null else 0.0 + +static func can_maximize(item: ItemInstance, addon: int) -> bool: + var affix := affix_for(item,addon) + if affix == null: return false + return item.additional_modifiers[addon].value < maximum(item,addon) - 0.000001 + +static func maximize(progress: Resource, inventory: Node, item: ItemInstance, addon: int) -> bool: + if not progress.active or inventory == null or not bool(inventory.get_parent().get("is_docked")): return false + if not can_maximize(item,addon) or progress.time_crystals < cost(item): return false + var owned: Array = inventory.cargo + inventory.weapon_slots + inventory.module_slots + inventory.extension_slots + if not owned.has(item): return false + var modifier := item.additional_modifiers[addon].duplicate() as ItemStatModifier + modifier.affix = affix_for(item,addon) + modifier.value = maximum(item,addon) + item.additional_modifiers[addon] = modifier + progress.time_crystals -= cost(item) + item.emit_changed() + inventory.call("_recalculate_ship_stats") + inventory.inventory_changed.emit() + progress.emit_changed() + return true diff --git a/scripts/planets/time_engineer.gd.uid b/scripts/planets/time_engineer.gd.uid new file mode 100644 index 0000000..4b21d13 --- /dev/null +++ b/scripts/planets/time_engineer.gd.uid @@ -0,0 +1 @@ +uid://diwvjtb7pfgn diff --git a/scripts/planets/time_engineer_panel.gd b/scripts/planets/time_engineer_panel.gd new file mode 100644 index 0000000..78aec3a --- /dev/null +++ b/scripts/planets/time_engineer_panel.gd @@ -0,0 +1,103 @@ +extends RefCounted +const CARD := preload("res://scripts/planets/poi_presentation.gd") +const SERVICE := preload("res://scripts/planets/time_engineer.gd") +const CYAN := Color("9be7ff") + +static func build(screen: Control, message: String = "") -> void: + var parent: VBoxContainer = screen._choices + var progress: Resource = screen.progress + var art := TextureRect.new() + art.texture = load("res://assets/planets/props/time_engineer.png") + art.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + art.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + art.custom_minimum_size.y = 125 + parent.add_child(art) + CARD.text(parent,"INŻYNIER CHRONALNY",18,CYAN) + CARD.text(parent,"Wybierz przedmiot i konkretny addon. Inżynier ustawi tylko jego wartość na maksimum dla obecnego poziomu przedmiotu.",12) + if not message.is_empty(): CARD.text(parent,message,12,CARD.GOLD) + CARD.text(parent,"Kryształy czasu: %d" % progress.time_crystals,14,CYAN) + CARD.text(parent,"Koszt: 10 kryształów × poziom przedmiotu. Gotowe maksima nie pobierają opłaty.",12) + if not is_instance_valid(screen.flight_ship): + CARD.text(parent,"Brak połączenia z wyposażeniem statku.",12) + _back(parent,screen) + return + var inventory: Node = screen.flight_ship.get_node("ShipInventory") + var items: Array[ItemInstance] = [] + var locations: Array[String] = [] + for kind in ["weapon","module","extension","cargo"]: + var slots: Array = inventory._get_slots(kind) + for index in slots.size(): + var item: ItemInstance = slots[index] + if not inventory.is_slot_unlocked(kind,index) or item == null or item.definition == null or item.additional_modifiers.is_empty() or items.has(item): continue + items.append(item) + locations.append({"weapon":"Broń","module":"Moduł","extension":"Rozszerzenie","cargo":"Ładownia"}[kind]) + if items.is_empty(): + CARD.text(parent,"Brak przedmiotów z addonami na statku. Zabierz wyposażenie z magazynu fregaty przed lądowaniem.",12,CARD.GOLD) + _back(parent,screen) + return + if not items.has(screen._engineer_item): + screen._engineer_item = items[0] + screen._engineer_addon = 0 + CARD.text(parent,"PRZEDMIOT",11,CARD.MUTED) + var choice := _choice(parent,"EngineerItem") + for index in items.size(): + var item: ItemInstance = items[index] + choice.add_item("%s · poz. %d · %s" % [item.definition.display_name,item.item_level,locations[index]]) + choice.set_item_metadata(index,item) + choice.select(items.find(screen._engineer_item)) + choice.item_selected.connect(func(index): + screen._engineer_item = choice.get_item_metadata(index) + screen._engineer_addon = 0 + screen._show_poi() + ) + var item: ItemInstance = screen._engineer_item + CARD.text(parent,"ADDON DO MAKSYMALIZACJI",11,CARD.MUTED) + var addons := _choice(parent,"EngineerAddon") + var selected := 0 + for index in item.additional_modifiers.size(): + var modifier: ItemStatModifier = item.additional_modifiers[index] + if modifier == null: continue + var affix := SERVICE.affix_for(item,index) + var suffix := " · brak zakresu" if affix == null else (" · MAX" if not SERVICE.can_maximize(item,index) else "") + addons.add_item(modifier.get_display_text()+suffix) + var option := addons.item_count-1 + addons.set_item_metadata(option,index) + if index == screen._engineer_addon: selected = option + if addons.item_count == 0: + CARD.text(parent,"Brak dostępnych addonów.",12) + _back(parent,screen) + return + addons.select(selected) + screen._engineer_addon = int(addons.get_item_metadata(selected)) + addons.item_selected.connect(func(index): + screen._engineer_addon = int(addons.get_item_metadata(index)) + screen._show_poi() + ) + var index: int = screen._engineer_addon + var affix := SERVICE.affix_for(item,index) + if affix != null: + var multiplier := ItemInstance.ENERGY_REGENERATION_BONUS_MULTIPLIER if item.additional_modifiers[index].stat_key == "energy_regeneration" else 1.0 + CARD.text(parent,"Obecnie: %.2f %s → maksimum: %.2f %s" % [item.additional_modifiers[index].value*multiplier,affix.unit,SERVICE.maximum(item,index)*multiplier,affix.unit],13,CARD.GOLD) + var cost := SERVICE.cost(item) + var ready := SERVICE.can_maximize(item,index) + var apply := CARD.button(parent,"MAKSYMALIZUJ · %d KRYSZTAŁÓW" % cost,screen._maximize_addon,true) + apply.name = "MaximizeSelectedAddon" + apply.disabled = not ready or progress.time_crystals < cost + if not ready: + CARD.text(parent,"Ten addon ma już maksymalną wartość lub nie posiada zakresu losowania.",12,CARD.MUTED) + elif progress.time_crystals < cost: + CARD.text(parent,"Brakuje %d kryształów czasu." % (cost-progress.time_crystals),12,CARD.GOLD) + _back(parent,screen) + +static func _choice(parent: Node, node_name: String) -> OptionButton: + var choice := OptionButton.new() + choice.name = node_name + choice.size_flags_horizontal = Control.SIZE_EXPAND_FILL + choice.fit_to_longest_item = false + choice.add_theme_font_override("font",ThemeDB.fallback_font) + choice.add_theme_font_size_override("font_size",12) + parent.add_child(choice) + return choice + +static func _back(parent: Node, screen: Control) -> void: + CARD.button(parent,"Wróć do wyprawy",func(): screen._dialog.hide()) diff --git a/scripts/planets/time_engineer_panel.gd.uid b/scripts/planets/time_engineer_panel.gd.uid new file mode 100644 index 0000000..5723650 --- /dev/null +++ b/scripts/planets/time_engineer_panel.gd.uid @@ -0,0 +1 @@ +uid://42o40yk3604n diff --git a/scripts/ui/mission_objectives.gd b/scripts/ui/mission_objectives.gd index 2454b11..672eb57 100644 --- a/scripts/ui/mission_objectives.gd +++ b/scripts/ui/mission_objectives.gd @@ -34,7 +34,7 @@ func _process(_delta: float) -> void: if not is_instance_valid(game): return if bool(game.get("system_cleared")): - _objective.text = "Znajdź zamieszkaną planetę" + _objective.text = "Zbierz łup i wróć do frachtowca" if not GameState.active_distant_map.is_empty() else "Znajdź zamieszkaną planetę" elif bool(game.get("_boss_spawned")): _objective.text = "Pokonaj bossa" else: diff --git a/tests/advancement_checks.gd b/tests/advancement_checks.gd new file mode 100644 index 0000000..274b11d --- /dev/null +++ b/tests/advancement_checks.gd @@ -0,0 +1,76 @@ +@tool +extends Node +const GENERATOR := preload("res://scripts/items/item_generator.gd") +const ORBS := preload("res://scripts/items/special_orbs.gd") +class DockedShip: + extends Node + var is_docked := true +class TestInventory: + extends Node + signal inventory_changed + var cargo: Array[ItemInstance] = [] + var weapon_slots: Array[ItemInstance] = [] + var module_slots: Array[ItemInstance] = [] + var recalculations := 0 + func _recalculate_ship_stats() -> void: recalculations += 1 +func _ready() -> void: + if not Engine.is_editor_hint(): print("ADVANCEMENT_CHECKS: "+JSON.stringify(run_checks())) + +func run_checks() -> Dictionary: + var failures: Array[String] = [] + var ship := DockedShip.new() + var inventory := TestInventory.new() + ship.add_child(inventory) + var progress = preload("res://scripts/planets/planet_progress.gd").new() + var checked := 0 + for definition in GENERATOR.DEFINITIONS: + var item := GENERATOR.generate_for_definition(definition,20,2) + item.special_orbs.assign(["piercing"] if definition.category == ItemDefinition.Category.WEAPON else []) + var original_orbs := item.special_orbs.duplicate() + var modifiers := item.additional_modifiers.duplicate() + var rarity := item.rarity + var cooldown := item.extension_cooldown + var passive := item.passive_value + for modifier in modifiers: + if modifier != null: modifier.value = -999 + inventory.cargo.assign([item]) + progress.orbs["advancement"] = 2 + if not ORBS.apply(progress,inventory,item,"advancement"): failures.append("Apply failed: "+definition.item_id) + if item.item_level != 21 or progress.orbs.advancement != 1: failures.append("Level or payment") + if item.rarity != rarity or item.additional_modifiers.size() != modifiers.size(): failures.append("Changed rarity or count") + if item.special_orbs != original_orbs: failures.append("Lost special orb") + if item.extension_cooldown != cooldown or item.passive_value != passive: failures.append("Rerolled unrelated extension ability") + for index in modifiers.size(): + var old: ItemStatModifier = modifiers[index] + var current: ItemStatModifier = item.additional_modifiers[index] + if current.stat_key != old.stat_key or current.affix != old.affix: failures.append("Changed affix type") + if old.value != -999 or current == old: failures.append("Mutated shared modifier") + var bounds := current.affix.get_value_range(21) + if current.value < bounds.x or current.value > bounds.y: failures.append("Wrong value range") + if not ORBS.apply(progress,inventory,item,"advancement") or item.item_level != 22: failures.append("Cannot repeat") + if ORBS.apply(progress,inventory,item,"advancement"): failures.append("Used absent orb") + progress.orbs.advancement = 1 + item.item_level = 999 + if not ORBS.apply(progress,inventory,item,"advancement") or item.item_level != 1000: failures.append("999 to 1000") + progress.orbs.advancement = 1 + if ORBS.apply(progress,inventory,item,"advancement") or progress.orbs.advancement != 1: failures.append("Exceeded cap or charged rejection") + checked += 1 + var bank := ItemInstance.new() + bank.definition = preload("res://resources/items/energy_bank.tres") + if ORBS.accepts(bank,"advancement"): failures.append("Consumable accepted") + var unowned := GENERATOR.generate(1) + if ORBS.apply(progress,inventory,unowned,"advancement"): failures.append("Unowned item accepted") + var legacy := GENERATOR.generate_for_definition(preload("res://resources/items/plasma_cannon.tres"),5) + legacy.additional_modifiers.clear() + var modifier := ItemStatModifier.new() + modifier.stat_key = "damage_percent" + modifier.value = -999 + legacy.additional_modifiers.append(modifier) + inventory.cargo.assign([legacy]) + progress.orbs.advancement = 1 + if not ORBS.apply(progress,inventory,legacy,"advancement") or legacy.additional_modifiers[0].affix == null: failures.append("Legacy affix unresolved") + var drops: Array[String] = [] + for seed_value in 3: drops.append(preload("res://scripts/game/distant_loot.gd").roll(125,true,seed_value,1.0,0.0)[1].orb) + if not drops.has("advancement") or not drops.has("piercing") or not drops.has("booster"): failures.append("Boss orb pool") + ship.free() + return {"failures":failures,"definitions_checked":checked,"passed":failures.is_empty()} diff --git a/tests/advancement_checks.gd.uid b/tests/advancement_checks.gd.uid new file mode 100644 index 0000000..d7fd0a4 --- /dev/null +++ b/tests/advancement_checks.gd.uid @@ -0,0 +1 @@ +uid://cxds8nq24djkt diff --git a/tests/advancement_checks.tscn b/tests/advancement_checks.tscn new file mode 100644 index 0000000..442ac0f --- /dev/null +++ b/tests/advancement_checks.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3 uid="uid://citpy72dly08j"] + +[ext_resource type="Script" uid="uid://cxds8nq24djkt" path="res://tests/advancement_checks.gd" id="1_6jvym"] + +[node name="advancement_checks" type="Node" unique_id=1289129973] +script = ExtResource("1_6jvym") diff --git a/tests/distant_expeditions_checks.gd b/tests/distant_expeditions_checks.gd new file mode 100644 index 0000000..b2e3e5b --- /dev/null +++ b/tests/distant_expeditions_checks.gd @@ -0,0 +1,37 @@ +@tool +extends Node +const DATA := preload("res://scripts/planets/distant_expeditions.gd") +func run_checks() -> Dictionary: + var failures: Array[String] = [] + var profile := {} + for seed_value in 1000: + var candidate := {"id":"test:%d" % seed_value,"seed":seed_value,"name":"Test"} + if DATA.has_hermit(candidate): + profile = candidate + break + for source in [1,120,125,130,500]: + for seed_value in 100: + var candidate := {"id":"test:%d" % seed_value,"seed":seed_value,"name":"Test"} + var map := DATA.offer(candidate,source) + if int(map.level) < source+1 or int(map.level) > source+5: failures.append("Wrong destination range") + if map != DATA.offer(candidate,source): failures.append("Offer rerolled") + if DATA.map_cost(map) != int(map.level)*1000: failures.append("Wrong credits") + if DATA.fuel_cost(map) != int(map.level)*1000: failures.append("Wrong fuel") + var captain = preload("res://scripts/captain/captain_progress.gd").new() + var progress: Resource = captain.expedition + var map := DATA.offer(profile,120) + var price := DATA.map_cost(map) + captain.credits = price-1 + if DATA.buy(progress,captain,profile,120): failures.append("Bought without credits") + if captain.credits != price-1 or not progress.distant_maps.is_empty(): failures.append("Failed purchase mutated state") + captain.credits = price + progress.deuter = 500000 + if not DATA.buy(progress,captain,profile,120): failures.append("Purchase failed") + if captain.credits != 0 or progress.distant_maps.size() != 1: failures.append("Wrong payment or archive") + captain.credits = price + if DATA.buy(progress,captain,profile,120): failures.append("Duplicate purchase") + if captain.credits != price: failures.append("Duplicate charged") + if progress.deuter != 500000: failures.append("Purchase consumed fuel") + var clone: Resource = progress.duplicate(true) + if clone.distant_maps != progress.distant_maps: failures.append("Archive not preserved") + return {"failures":failures,"offers_checked":500,"beyond_120_supported":true} diff --git a/tests/distant_expeditions_checks.gd.uid b/tests/distant_expeditions_checks.gd.uid new file mode 100644 index 0000000..0a0db38 --- /dev/null +++ b/tests/distant_expeditions_checks.gd.uid @@ -0,0 +1 @@ +uid://k0din7ww7xhn diff --git a/tests/distant_expeditions_preview.gd b/tests/distant_expeditions_preview.gd new file mode 100644 index 0000000..099b0a0 --- /dev/null +++ b/tests/distant_expeditions_preview.gd @@ -0,0 +1,48 @@ +extends Control +var captain: Resource +var _content: VBoxContainer +var _economy_labels: Dictionary = {} +func _ready() -> void: + captain = preload("res://scripts/captain/captain_progress.gd").new() + captain.expedition.deuter = 8000 + captain.expedition.distant_maps = { + "preview_1":{"id":"preview_1","level":5,"name":"Odległy sektor 005","source_planet":"Eden","source_level":1}, + "preview_2":{"id":"preview_2","level":125,"name":"Odległy sektor 125","source_planet":"Ostatni Horyzont","source_level":120} + } + var bg := ColorRect.new() + bg.color = Color("0b171e") + bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(bg) + var art := TextureRect.new() + art.texture = load("res://assets/planets/props/hermit_cartographer.png") + art.position = Vector2(20,80) + art.size = Vector2(650,520) + art.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + art.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + add_child(art) + var label := Label.new() + label.text = "PUSTELNIK / KARTOGRAF" + label.position = Vector2(110,35) + label.add_theme_font_size_override("font_size",25) + label.modulate = Color("e9b4fc") + add_child(label) + var scroll := ScrollContainer.new() + scroll.position = Vector2(705,24) + scroll.size = Vector2(550,670) + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + add_child(scroll) + _content = VBoxContainer.new() + _content.size_flags_horizontal = Control.SIZE_EXPAND_FILL + scroll.add_child(_content) + preload("res://scripts/planets/distant_expeditions_panel.gd").build(self) + _economy_labels.expedition_fuel.text = "Zapas paliwa: %.1f deuteru" % captain.expedition.deuter + var fuel_before: float = captain.expedition.deuter + var maps_before: Dictionary = captain.expedition.distant_maps.duplicate(true) + var buttons := find_children("FlyDistant_*","Button",true,false) + for button in buttons: button.pressed.emit() + assert(captain.expedition.deuter == fuel_before) + assert(captain.expedition.distant_maps == maps_before) + print("DISTANT_PREVIEW_OK: display fixture only; actual flights are covered by distant_integration") + +func _travel_to_distant(_map_id: String) -> void: + pass diff --git a/tests/distant_expeditions_preview.gd.uid b/tests/distant_expeditions_preview.gd.uid new file mode 100644 index 0000000..971e00f --- /dev/null +++ b/tests/distant_expeditions_preview.gd.uid @@ -0,0 +1 @@ +uid://ea26uuwagnd5 diff --git a/tests/distant_expeditions_preview.tscn b/tests/distant_expeditions_preview.tscn new file mode 100644 index 0000000..dd38699 --- /dev/null +++ b/tests/distant_expeditions_preview.tscn @@ -0,0 +1,12 @@ +[gd_scene format=3 uid="uid://c3lleuuycm025"] + +[ext_resource type="Script" uid="uid://ea26uuwagnd5" path="res://tests/distant_expeditions_preview.gd" id="1_dvuhr"] + +[node name="distant_expeditions_preview" type="Control" unique_id=340557901] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_dvuhr") diff --git a/tests/distant_integration.gd b/tests/distant_integration.gd new file mode 100644 index 0000000..a3ea228 --- /dev/null +++ b/tests/distant_integration.gd @@ -0,0 +1,152 @@ +extends Node +var is_runner := false +var failures: Array[String] = [] +const GENERATOR := preload("res://scripts/levels/distant_sector_generator.gd") +const ORBS := preload("res://scripts/items/special_orbs.gd") +const LOOT := preload("res://scripts/game/distant_loot.gd") +func _ready() -> void: + if not is_runner: + var runner = get_script().new() + runner.is_runner = true + get_tree().root.add_child.call_deferred(runner) + else: + _run.call_deferred() +func check(value: bool, message: String) -> void: + if not value: failures.append(message); push_error("DISTANT_TEST: "+message) +func frames(count: int = 4) -> void: + for index in count: await get_tree().process_frame +func freeze_game(game: Node) -> void: + game.get_node("PlayerShip").set_physics_process(false) + for enemy in get_tree().get_nodes_in_group("enemies"): enemy.set_physics_process(false) +func _run() -> void: + for level in [1,60,120,125,500]: + var ordinary := preload("res://scripts/levels/level_balance.gd").enemy_tiers(level) + var tiers := GENERATOR.enemy_tiers(level) + check(tiers.count(2)==1,"One boss") + check(tiers.count(0)==ordinary.count(0)*2,"Double normals") + check(tiers.count(1)==ordinary.count(1)*2,"Double elites") + check(preload("res://scripts/levels/level_balance.gd").enemy_stat_scale(125)>preload("res://scripts/levels/level_balance.gd").enemy_stat_scale(120),"Stats beyond 120") + check(LOOT.roll(125,false,2,0.149,0.0).size()==1,"Map 15 percent") + check(LOOT.roll(125,false,2,0.15,0.0).is_empty(),"Map boundary and no regular orb") + for seed_value in 100: + for chance in [0.0,0.15,1.0]: + var boss_drops := LOOT.roll(125,true,seed_value,chance,1.0) + check(boss_drops.size()==2 and boss_drops[0].has("map") and boss_drops[1].has("orb"),"Exactly one guaranteed boss map and orb") + var next_map: Dictionary = LOOT.roll(125,false,2,0.0,1.0)[0].map + check(int(next_map.level)>125 and int(next_map.level)<=130,"Map chain") + GameState.start_new_run() + GameState.system_level = 7 + GameState.system_seed = 777 + GameState.remember_system() + GameState.captain.expedition.distant_maps["integration"] = {"id":"integration","level":125,"seed":123456,"name":"Sektor testowy","source_planet":"Test","source_level":120} + GameState.captain.expedition.deuter = 124999 + check(not GameState.depart_distant("integration"),"No flight without fuel") + check(GameState.system_level==7 and GameState.captain.expedition.deuter==124999,"Failed departure preserved state") + GameState.captain.expedition.deuter = 125000 + get_tree().change_scene_to_file("res://scenes/game/game.tscn") + await frames() + var game := get_tree().current_scene + freeze_game(game) + var player := game.get_node("PlayerShip") + var inventory := player.get_node("ShipInventory") + player.is_docked = true + var weapon: ItemInstance = inventory.weapon_slots[0] + var engine := ItemInstance.new() + engine.definition = preload("res://resources/items/engine_extension.tres") + inventory.module_slots[2] = engine + var progress: Resource = GameState.captain.expedition + progress.orbs = {"piercing":2,"booster":1} + check(not ORBS.apply(progress,inventory,engine,"piercing"),"Piercing category restriction") + check(not ORBS.apply(progress,inventory,weapon,"booster"),"Booster category restriction") + check(ORBS.apply(progress,inventory,weapon,"piercing"),"Apply piercing") + check(not ORBS.apply(progress,inventory,weapon,"piercing") and progress.orbs.piercing==1,"No stacking or extra cost") + check(ORBS.apply(progress,inventory,engine,"booster"),"Apply booster") + check(player.has_free_mini_boost(),"Equipped engine") + Input.action_press("ship_mini_boost") + player.energy = 0 + player._update_mini_boost(1.0) + check(player._mini_boost_active and player.energy==0,"Free TAB with empty generator") + Input.action_release("ship_mini_boost") + inventory.module_slots[2] = null + check(not player.has_free_mini_boost(),"Unequipped engine no effect") + inventory.module_slots[2] = engine + check(game.travel_to_distant("integration"),"Flight requested") + await frames(8) + game = get_tree().current_scene + freeze_game(game) + check(GameState.captain.expedition.deuter==0,"Exactly one fuel payment") + check(GameState.system_level==125 and GameState.highest_system==7,"Isolated progression") + check(game.get_node_or_null("CelestialBodies")==null,"No celestial bodies") + check(game.get_node_or_null("PlanetExpeditions")==null,"No planet expeditions") + check(game.get_node("AncientStructures").get_child_count()==14,"Decorative structures") + check(game.get_remaining_enemies()==46 and not game._boss_spawned,"Doubled encounter before boss") + player = game.get_node("PlayerShip") + inventory = player.get_node("ShipInventory") + player.is_docked = true + check("piercing" in inventory.weapon_slots[0].special_orbs,"Orb persisted across travel") + check(player.has_free_mini_boost(),"Engine orb persisted") + var enemy: Node = get_tree().get_first_node_in_group("enemies") + enemy.absorption = Vector3.ZERO + enemy.shields = 100 + enemy.hull = 1000 + enemy.take_piercing_weapon_hit(100,false,0,0.5) + check(is_equal_approx(enemy.shields,50) and is_equal_approx(enemy.hull,950),"50 percent shield bypass") + enemy.shields = 100 + enemy.hull = 1000 + enemy.take_weapon_hit(100,false,0) + check(is_equal_approx(enemy.shields,0) and is_equal_approx(enemy.hull,1000),"Other weapons unchanged") + enemy.shields = 10 + enemy.hull = 1000 + enemy.take_piercing_weapon_hit(100,false,0,0.5) + check(is_equal_approx(enemy.shields,0) and is_equal_approx(enemy.hull,910),"Shield overflow") + enemy.shields = 100 + enemy.hull = 1000 + player.get_node("WeaponController")._apply_damage(enemy,100,false,0.5) + check(is_equal_approx(enemy.hull,950),"Laser piercing") + enemy.shields = 100 + enemy.hull = 1000 + var projectile := preload("res://scenes/game/weapon_projectile.tscn").instantiate() + projectile.damage = 100 + projectile.shield_piercing = 0.5 + projectile.weapon_kind = ItemDefinition.WeaponKind.PLASMA + projectile._apply_damage_once(enemy) + check(is_equal_approx(enemy.hull,950),"Projectile piercing") + projectile.free() + var payload := Node2D.new() + payload.set_script(preload("res://scripts/items/expedition_pickup.gd")) + payload.payload = {"orb":"booster"} + payload.position = player.position + game.add_child(payload) + var count: int = progress.orbs.booster + payload._physics_process(0) + check(progress.orbs.booster==count+1,"Collect orb") + await frames() + for victim in get_tree().get_nodes_in_group("enemies"): victim.queue_free() + await frames() + game.get_node("DistantSector")._physics_process(0.3) + await frames() + check(game._boss_spawned and game.get_remaining_enemies()==1,"Exactly one boss after wave") + var boss: Node = get_tree().get_first_node_in_group("enemies") + boss.shields = 0 + boss.take_damage(1000000000.0,false,0,true) + await frames() + var maps := 0 + var orbs := 0 + for pickup in get_tree().get_nodes_in_group("loot"): + if pickup.get_script() == preload("res://scripts/items/expedition_pickup.gd"): + if pickup.payload.has("map"): maps += 1 + if pickup.payload.has("orb"): orbs += 1 + check(maps==1 and orbs==1,"Actual boss death drops exactly one map and orb") + check(get_tree().get_nodes_in_group("time_crystal_pickups").size()==1,"Boss also drops time crystals") + game.get_node("DistantSector")._physics_process(0.3) + check(game.system_cleared,"Sector cleared") + player.landing_combat_lock = 0 + game.get_node("DistantSector")._return_home() + await frames(8) + game = get_tree().current_scene + freeze_game(game) + check(GameState.active_distant_map.is_empty() and GameState.system_level==7 and GameState.system_seed==777,"Return home") + check(GameState.captain.expedition.deuter==0,"Return free") + check(game.has_node("PlanetExpeditions") and game.has_node("CelestialBodies"),"Normal scene restored") + check(is_instance_valid(game.get_node("PlanetExpeditions").screen) and game.get_node("PlayerShip").is_docked,"Return docks at freighter") + print("DISTANT_INTEGRATION_RESULT: "+JSON.stringify({"failures":failures,"passed":failures.is_empty()})) diff --git a/tests/distant_integration.gd.uid b/tests/distant_integration.gd.uid new file mode 100644 index 0000000..3d20545 --- /dev/null +++ b/tests/distant_integration.gd.uid @@ -0,0 +1 @@ +uid://66jnn06l46y7 diff --git a/tests/distant_integration.tscn b/tests/distant_integration.tscn new file mode 100644 index 0000000..532b75b --- /dev/null +++ b/tests/distant_integration.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3 uid="uid://onfp606db0mx"] + +[ext_resource type="Script" uid="uid://66jnn06l46y7" path="res://tests/distant_integration.gd" id="1_8yv68"] + +[node name="distant_integration" type="Node" unique_id=979516099] +script = ExtResource("1_8yv68") diff --git a/tests/economy_audit.gd b/tests/economy_audit.gd new file mode 100644 index 0000000..180e8a0 --- /dev/null +++ b/tests/economy_audit.gd @@ -0,0 +1,18 @@ +@tool +extends Node +func inspect_costs() -> Dictionary: + var result := {} + _scan("res://scripts", result) + return result +func _scan(path: String, result: Dictionary) -> void: + for file in DirAccess.get_files_at(path): + if not file.ends_with(".gd"): continue + var matches: Array[String] = [] + var lines := FileAccess.get_file_as_string(path+"/"+file).split("\n") + for index in lines.size(): + var line := lines[index] + if ("cost" in line.to_lower() or "deuter" in line.to_lower() or "upkeep" in line.to_lower() or "salary" in line.to_lower()) and not line.strip_edges().begins_with("#"): + matches.append("%d: %s" % [index+1,line.strip_edges()]) + if not matches.is_empty(): result[path+"/"+file] = matches + for folder in DirAccess.get_directories_at(path): + _scan(path+"/"+folder,result) diff --git a/tests/economy_audit.gd.uid b/tests/economy_audit.gd.uid new file mode 100644 index 0000000..71f2a1d --- /dev/null +++ b/tests/economy_audit.gd.uid @@ -0,0 +1 @@ +uid://hjh7mupd7vkq diff --git a/tests/mining_network_checks.gd b/tests/mining_network_checks.gd new file mode 100644 index 0000000..64a8ec1 --- /dev/null +++ b/tests/mining_network_checks.gd @@ -0,0 +1,61 @@ +@tool +extends Node +const NETWORK = preload("res://scripts/planets/mining_network.gd") +func run_checks() -> Dictionary: + var failures: Array[String] = [] + var previous := Vector2i.ZERO + for level in range(1, 121): + var bounds := NETWORK.richness_bounds(level) + if bounds.x < previous.x or bounds.y < previous.y: failures.append("Non-monotonic bands") + previous = bounds + for seed_value in 100: + var profile := {"id":"%d:planet:0" % seed_value, "seed":seed_value, "name":"Test", "difficulty":0.5} + var richness := NETWORK.deposit_richness(profile, level) + if richness < bounds.x or richness > bounds.y: failures.append("Out of band") + if NETWORK.build_cost(profile, level) != richness * 10: failures.append("Wrong scaled price") + if richness != NETWORK.deposit_richness(profile, level): failures.append("Rerolled") + var progress = preload("res://scripts/planets/planet_progress.gd").new() + var captain = preload("res://scripts/captain/captain_progress.gd").new() + var profile := {} + for seed_value in 1000: + var candidate := {"id":"%d:planet:0" % seed_value,"seed":seed_value,"name":"Test","difficulty":0.5} + if NETWORK.has_recruit(candidate) and NETWORK.has_deep_deposit(candidate): + profile = candidate + break + if profile.is_empty(): return {"failures":["No fixture planet"]} + var hire_cost := NETWORK.recruitment_cost(NETWORK.recruit(profile)) + captain.credits = hire_cost + if NETWORK.hire(progress, captain, profile): failures.append("Hired without cabin") + if captain.credits != hire_cost: failures.append("Charged failed hire") + progress.freighter_rooms["cabin_1"] = "crew_cabin" + captain.credits = hire_cost - 1 + if NETWORK.hire(progress, captain, profile): failures.append("Hired without credits") + captain.credits = hire_cost + if not NETWORK.hire(progress, captain, profile): failures.append("Hire failed") + if captain.credits != 0: failures.append("Wrong hire charge") + if NETWORK.hire(progress, captain, profile): failures.append("Duplicate hire") + var station_cost := NETWORK.build_cost(profile, 1) + captain.parts = station_cost - 1 + if NETWORK.build(progress, captain, profile, 1): failures.append("Built without parts") + if captain.parts != station_cost-1: failures.append("Charged failed build") + captain.parts = station_cost + if not NETWORK.build(progress, captain, profile, 1): failures.append("Build failed") + if captain.parts != 0: failures.append("Wrong build charge") + if NETWORK.build(progress, captain, profile, 1): failures.append("Duplicate build") + var station: Dictionary = progress.mining_stations[str(profile.id)] + if NETWORK.station_rate(progress, station) != 0: failures.append("Unstaffed production") + var member_id := str(NETWORK.recruit(profile).id) + NETWORK.assign(progress, str(profile.id), member_id) + station.richness = 50 + progress.crew[member_id].management = 50 + if not is_equal_approx(NETWORK.station_rate(progress, station), 75): failures.append("Rate formula") + var before: float = progress.deuter + NETWORK.advance(progress, 60) + if not is_equal_approx(progress.deuter-before, 75): failures.append("Production") + if int(station.richness) != 50: failures.append("Deposit depleted") + NETWORK.dismiss(progress, member_id) + if NETWORK.station_rate(progress, station) != 0: failures.append("Dismiss production") + var old_profile = preload("res://scripts/planets/planet_profiles.gd").generate(1234, 0, "Legacy") + var old_station := {"id":old_profile.id,"planet_name":"Legacy","system":40} + if NETWORK.station_richness(old_station) != NETWORK.deposit_richness(old_profile,40): failures.append("Legacy migration") + return {"failures":failures,"sample_rolls":12000,"production_example":75,"level_1":NETWORK.richness_bounds(1),"level_120":NETWORK.richness_bounds(120)} diff --git a/tests/mining_network_checks.gd.uid b/tests/mining_network_checks.gd.uid new file mode 100644 index 0000000..a802b09 --- /dev/null +++ b/tests/mining_network_checks.gd.uid @@ -0,0 +1 @@ +uid://fcnfvnprqexj diff --git a/tests/time_crystals_checks.gd b/tests/time_crystals_checks.gd new file mode 100644 index 0000000..28f162b --- /dev/null +++ b/tests/time_crystals_checks.gd @@ -0,0 +1,112 @@ +extends "res://tests/distant_integration.gd" +const SERVICE := preload("res://scripts/planets/time_engineer.gd") +const ITEM_GENERATOR := preload("res://scripts/items/item_generator.gd") +func _run() -> void: + await super._run() + var game := get_tree().current_scene + var player := game.get_node("PlayerShip") + var inventory := player.get_node("ShipInventory") + var progress: Resource = GameState.captain.expedition + var saved_item: ItemInstance = inventory.cargo[0] + progress.active = true + for definition in ITEM_GENERATOR.DEFINITIONS: + var item := ITEM_GENERATOR.generate_for_definition(definition,20,2) + if item.additional_modifiers.is_empty(): continue + item.special_orbs.assign(["piercing"] if definition.category == ItemDefinition.Category.WEAPON else []) + var effects := item.special_orbs.duplicate() + inventory.cargo[0] = item + for addon in item.additional_modifiers.size(): + item.additional_modifiers[addon].value = item.additional_modifiers[addon].affix.get_value_range(20).x + var selected := -1 + for addon in item.additional_modifiers.size(): + if SERVICE.can_maximize(item,addon): + selected = addon + break + if selected < 0: + progress.time_crystals = 200 + check(not SERVICE.maximize(progress,inventory,item,0) and progress.time_crystals==200,"Fixed addons already maximum and free") + continue + var old: ItemStatModifier = item.additional_modifiers[selected] + var values: Array[float] = [] + for modifier in item.additional_modifiers: values.append(modifier.value) + progress.time_crystals = 199 + check(not SERVICE.maximize(progress,inventory,item,selected),"Cannot maximize without crystals") + check(progress.time_crystals==199,"Rejected service free") + progress.time_crystals = 200 + check(SERVICE.maximize(progress,inventory,item,selected),"Maximize available addon") + check(progress.time_crystals==0,"Exact crystal price") + check(is_equal_approx(item.additional_modifiers[selected].value,SERVICE.maximum(item,selected)),"Maximum at current level") + check(item.additional_modifiers[selected] != old and old.value==values[selected],"Shared modifier unchanged") + check(item.special_orbs==effects and item.item_level==20,"Effects and level unchanged") + for index in values.size(): + if index != selected: check(item.additional_modifiers[index].value==values[index],"Only selected addon changed") + progress.time_crystals = 200 + check(not SERVICE.maximize(progress,inventory,item,selected) and progress.time_crystals==200,"Already maximum not charged") + check(not SERVICE.maximize(progress,inventory,item,-1),"Invalid index rejected") + var test_item := ITEM_GENERATOR.generate_for_definition(preload("res://resources/items/plasma_cannon.tres"),125,2) + check(SERVICE.cost(test_item)==1250,"Level 125 price") + test_item.item_level = 1000 + check(SERVICE.cost(test_item)==10000,"Level 1000 price") + check(not SERVICE.maximize(progress,inventory,test_item,0),"Unowned item rejected") + inventory.cargo[0] = saved_item + progress.active = false + var crystals := Node2D.new() + crystals.set_script(preload("res://scripts/items/time_crystal_pickup.gd")) + crystals.amount = 7 + crystals.position = player.position + game.add_child(crystals) + var previous: int = progress.time_crystals + crystals._physics_process(0) + crystals._physics_process(0) + check(progress.time_crystals==previous+7,"Crystal collected once") + await frames() + check(LOOT.crystal_amount(125,0,2)==26 and LOOT.crystal_amount(125,2,2)==130,"Crystal level and rank scaling") + var cloned: Resource = progress.duplicate(true) + check(cloned.time_crystals==progress.time_crystals,"Currency preserved") + var profile := {} + for seed_value in 100: + var candidate := preload("res://scripts/planets/planet_profiles.gd").generate(seed_value,0,"Test") + if SERVICE.has_engineer(candidate): + profile = candidate + break + check(not profile.is_empty(),"Engineer exists") + var surface := preload("res://scripts/planets/surface_generator.gd").generate(int(profile.seed),profile) + var engineers := 0 + for poi in surface.pois: + if poi.kind=="time_engineer": engineers += 1 + check(engineers==1 and SERVICE.has_engineer(profile),"Engineer POI generated") + # Exercise the real landing screen and the actual item/addon selectors. + game.set_physics_process(false) + for enemy in get_tree().get_nodes_in_group("enemies"): enemy.queue_free() + await frames() + var controller := game.get_node("PlanetExpeditions") + controller._leave() + player.position = game.protected_planet.position + progress.lander_hp = 100 + progress.scan(game.protected_planet.planet_profile) + var weapon: ItemInstance + for attempt in 100: + weapon = ITEM_GENERATOR.generate_for_definition(preload("res://resources/items/plasma_cannon.tres"),20,2) + var bounds := weapon.additional_modifiers[1].affix.get_value_range(20) + if bounds.y > bounds.x + 0.000001: break + for modifier in weapon.additional_modifiers: modifier.value = modifier.affix.get_value_range(20).x + inventory.weapon_slots[0] = weapon + progress.time_crystals = 400 + controller.land_nearby() + await frames() + var screen: Control = controller.screen + check(is_instance_valid(screen),"Actual surface screen opened") + screen._poi = {"kind":"time_engineer","position":screen.lander.position} + screen._engineer_item = weapon + screen._engineer_addon = 0 + screen._show_poi() + var addon_choice: OptionButton = screen.find_child("EngineerAddon",true,false) + addon_choice.item_selected.emit(1) + var chosen_index: int = screen._engineer_addon + check(chosen_index==1,"Addon selector targets requested addon") + var before_first: float = weapon.additional_modifiers[0].value + screen.find_child("MaximizeSelectedAddon",true,false).pressed.emit() + check(progress.time_crystals==200,"UI service charged once") + check(weapon.additional_modifiers[0].value==before_first and is_equal_approx(weapon.additional_modifiers[1].value,SERVICE.maximum(weapon,1)),"UI maximizes only chosen addon") + check(screen.find_child("MaximizeSelectedAddon",true,false).disabled,"UI blocks already-max addon") + print("TIME_CRYSTALS_CHECKS: "+JSON.stringify({"failures":failures,"passed":failures.is_empty()})) diff --git a/tests/time_crystals_checks.gd.uid b/tests/time_crystals_checks.gd.uid new file mode 100644 index 0000000..c3d1818 --- /dev/null +++ b/tests/time_crystals_checks.gd.uid @@ -0,0 +1 @@ +uid://jgxiaf5f4avb diff --git a/tests/time_crystals_checks.tscn b/tests/time_crystals_checks.tscn new file mode 100644 index 0000000..c50bbc1 --- /dev/null +++ b/tests/time_crystals_checks.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3 uid="uid://bn25yax4w7ki1"] + +[ext_resource type="Script" uid="uid://jgxiaf5f4avb" path="res://tests/time_crystals_checks.gd" id="1_nug5d"] + +[node name="time_crystals_checks" type="Node" unique_id=1574606779] +script = ExtResource("1_nug5d") diff --git a/tests/workspace_reader.gd b/tests/workspace_reader.gd new file mode 100644 index 0000000..cb5ec2d --- /dev/null +++ b/tests/workspace_reader.gd @@ -0,0 +1,18 @@ +@tool +extends Node +func read_text(path: String) -> String: + return FileAccess.get_file_as_string(path) +func list_files(path: String) -> Array[String]: + var result: Array[String] = [] + for file in DirAccess.get_files_at(path): result.append(path+"/"+file) + for folder in DirAccess.get_directories_at(path): result.append_array(list_files(path+"/"+folder)) + return result +func write_text(path: String, content: String) -> void: + var file := FileAccess.open(path,FileAccess.WRITE) + file.store_string(content) + +func copy_image(source: String, target: String) -> int: + var image := Image.load_from_file(source) + if image == null: return ERR_FILE_NOT_FOUND + DirAccess.make_dir_recursive_absolute(target.get_base_dir()) + return image.save_png(target) diff --git a/tests/workspace_reader.gd.uid b/tests/workspace_reader.gd.uid new file mode 100644 index 0000000..6f3b0a3 --- /dev/null +++ b/tests/workspace_reader.gd.uid @@ -0,0 +1 @@ +uid://cb74x2a4obg4m