diff --git a/artifacts/enemy_mine_preview.png b/artifacts/enemy_mine_preview.png new file mode 100644 index 0000000..9fb28ce Binary files /dev/null and b/artifacts/enemy_mine_preview.png differ diff --git a/artifacts/enemy_mine_preview.png.import b/artifacts/enemy_mine_preview.png.import new file mode 100644 index 0000000..a390bef --- /dev/null +++ b/artifacts/enemy_mine_preview.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cia7d4gk8betx" +path="res://.godot/imported/enemy_mine_preview.png-ca9fee582f6cc4883f4e3d003a47c2b2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://artifacts/enemy_mine_preview.png" +dest_files=["res://.godot/imported/enemy_mine_preview.png-ca9fee582f6cc4883f4e3d003a47c2b2.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/other/enemy_mine.png b/assets/other/enemy_mine.png new file mode 100644 index 0000000..1316d08 Binary files /dev/null and b/assets/other/enemy_mine.png differ diff --git a/assets/other/enemy_mine.png.import b/assets/other/enemy_mine.png.import new file mode 100644 index 0000000..ce670bc --- /dev/null +++ b/assets/other/enemy_mine.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dgdqtbrkivkpt" +path="res://.godot/imported/enemy_mine.png-cbe7ead53abadbefaca3c3e913b8b0c4.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/other/enemy_mine.png" +dest_files=["res://.godot/imported/enemy_mine.png-cbe7ead53abadbefaca3c3e913b8b0c4.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=true +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/other/enemy_mine_artwork.md b/assets/other/enemy_mine_artwork.md new file mode 100644 index 0000000..a543f02 --- /dev/null +++ b/assets/other/enemy_mine_artwork.md @@ -0,0 +1,5 @@ +# Enemy mine artwork +Generated with the built-in image_gen tool, transparent background. +Saved asset: assets/other/enemy_mine.png +Final prompt: +Use case: stylized-concept. Asset type: production sprite for enemy proximity mine in a top-down realistic sci-fi space shooter. One single symmetrical hostile alien mechanical mine, orthographic directly overhead view, no perspective. Compact circular obsidian gunmetal armored hub, six sharp swept titanium spikes arranged radially, crimson red glowing reactor lens in center and small red luminous nodes at the six spike tips. Premium painted 3D game sprite with convincing machined panels, bevels, restrained scratches, rich deep steel violet metal, crisp readable silhouette when rendered 50 pixels wide. Main hull opaque; completely transparent exterior and gaps between spikes, clean antialiased alpha with restrained tiny red light spill only. Centered square composition with 15% empty transparent margin around entire object. No background, no stars, no ground, no cast shadow outside object, no text, no letters, no frame, no multiple objects, no presentation mockup. \ No newline at end of file diff --git a/scenes/game/weapon_projectile.tscn b/scenes/game/weapon_projectile.tscn index c0fb936..8a80db5 100644 --- a/scenes/game/weapon_projectile.tscn +++ b/scenes/game/weapon_projectile.tscn @@ -7,7 +7,7 @@ radius = 6.0 [node name="WeaponProjectile" type="Area2D"] collision_layer = 0 -collision_mask = 13 +collision_mask = 77 monitoring = true monitorable = false script = ExtResource("1_projectile") diff --git a/scripts/game/enemy_mine.gd b/scripts/game/enemy_mine.gd index 7e51853..26f5963 100644 --- a/scripts/game/enemy_mine.gd +++ b/scripts/game/enemy_mine.gd @@ -3,19 +3,32 @@ extends Node2D const ARM_TIME := 0.7 const LIFETIME := 60.0 const TRIGGER_RADIUS := 54.0 +const DAMAGE_FRACTION := 0.5 +const MINE_TEXTURE := preload("res://assets/other/enemy_mine.png") var player: CharacterBody2D var drift := Vector2.ZERO var _age := 0.0 var _triggered := false +var _sprite: Sprite2D func _ready() -> void: add_to_group("enemy_mines") z_index = 12 + _sprite = Sprite2D.new() + _sprite.name = "MineArtwork" + _sprite.texture = MINE_TEXTURE + _sprite.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS + _sprite.scale = Vector2.ONE * 72.0 / maxf(MINE_TEXTURE.get_width(), MINE_TEXTURE.get_height()) + add_child(_sprite) func _physics_process(delta: float) -> void: _age += delta position += drift * delta drift = drift.move_toward(Vector2.ZERO, 100.0 * delta) + if is_instance_valid(_sprite): + _sprite.rotation = _age * 0.16 + _sprite.modulate = Color.WHITE if _age >= ARM_TIME else Color(0.65, 0.65, 0.7) + _sprite.modulate.a = clampf(LIFETIME - _age, 0.0, 1.0) queue_redraw() if _age >= LIFETIME: queue_free() @@ -24,7 +37,7 @@ func _physics_process(delta: float) -> void: return if global_position.distance_to(player.global_position) <= TRIGGER_RADIUS: _triggered = true - player.call("take_damage", float(player.get("max_hull")) * 0.05, false, DamageTypes.Type.KINETIC) + player.call("take_damage", float(player.get("max_hull")) * DAMAGE_FRACTION, false, DamageTypes.Type.KINETIC) var explosion := Node2D.new() explosion.set_script(preload("res://scripts/game/deployment_flash.gd")) explosion.set("color", Color("ff4265")) @@ -38,18 +51,8 @@ func _draw() -> void: var pulse := 0.65 + 0.35 * sin(_age * 7.0) var accent := Color("ff4265") if armed else Color("ffb45c") var fade := clampf(LIFETIME - _age, 0.0, 1.0) - draw_circle(Vector2.ZERO, 30.0, Color(accent, 0.08 * pulse * fade)) - var shell := PackedVector2Array() - for index in 16: - var radius := 24.0 if index % 2 == 0 else 13.0 - shell.append(Vector2.from_angle(TAU * index / 16.0) * radius) - draw_colored_polygon(shell, Color("292236") * Color(1, 1, 1, fade)) - var outline := shell.duplicate() - outline.append(shell[0]) - draw_polyline(outline, Color(accent, 0.9 * fade), 1.5, true) - draw_circle(Vector2.ZERO, 10.0, Color("13101e") * Color(1, 1, 1, fade)) - draw_arc(Vector2.ZERO, 11.0, 0.0, TAU, 32, Color(accent, fade), 2.0, true) - for index in 3: - var direction := Vector2.from_angle(-PI * 0.5 + TAU * index / 3.0) - draw_line(direction * 4.0, direction * 8.0, Color(accent, pulse * fade), 2.5, true) - draw_circle(Vector2.ZERO, 3.0, Color(accent, pulse * fade)) + # The artwork carries the hull; soft warning rings remain visible on dark space. + draw_circle(Vector2.ZERO, 33.0, Color(accent, 0.07 * pulse * fade)) + draw_circle(Vector2.ZERO, 25.0, Color(accent, 0.1 * pulse * fade)) + var progress := clampf(_age / ARM_TIME, 0.0, 1.0) + draw_arc(Vector2.ZERO, 31.0, -PI * 0.5, -PI * 0.5 + TAU * progress, 64, Color(accent, (0.22 + 0.28 * pulse) * fade), 1.0, true) diff --git a/scripts/game/enemy_ship.gd b/scripts/game/enemy_ship.gd index f3d4bc4..6f84c0e 100644 --- a/scripts/game/enemy_ship.gd +++ b/scripts/game/enemy_ship.gd @@ -243,13 +243,6 @@ func select_combat_target() -> Node2D: if distance <= maxf(activation_range, weapon_range) and distance < closest_distance: closest = flare closest_distance = distance - for ally in get_tree().get_nodes_in_group("virus_allies"): - if ally.is_queued_for_deletion() or float(ally.get("hull")) <= 0.0: - continue - var distance := global_position.distance_to(ally.global_position) - if distance <= activation_range and distance < closest_distance: - closest = ally - closest_distance = distance if closest != null: return closest if is_instance_valid(_player) and not bool(_player.get("_is_destroyed")) and not bool(_player.call("is_cloaked")): diff --git a/scripts/game/weapon_controller.gd b/scripts/game/weapon_controller.gd index 63fe2f2..4a74e79 100644 --- a/scripts/game/weapon_controller.gd +++ b/scripts/game/weapon_controller.gd @@ -3,7 +3,7 @@ extends Node const PROJECTILE_SCENE := preload("res://scenes/game/weapon_projectile.tscn") const IMPACT_EFFECT_SCENE := preload("res://scenes/game/impact_effect.tscn") const WEAPON_AUDIO_SCRIPT := preload("res://scripts/game/weapon_audio.gd") -const RAY_COLLISION_MASK := 13 # World, enemies, and the weapon test target. +const RAY_COLLISION_MASK := 77 # World, enemies, weapon test targets and infected ships. @onready var ship: CharacterBody2D = get_parent() @onready var inventory: ShipInventory = ship.get_node("ShipInventory") diff --git a/scripts/game/weapon_projectile.gd b/scripts/game/weapon_projectile.gd index c422b05..b707eae 100644 --- a/scripts/game/weapon_projectile.gd +++ b/scripts/game/weapon_projectile.gd @@ -61,8 +61,6 @@ func configure( func _physics_process(delta: float) -> void: var travel_velocity := direction * projectile_speed if weapon_kind == ItemDefinition.WeaponKind.MISSILE and missile_homing: - if is_instance_valid(homing_target) and homing_target.is_in_group("virus_allies") and not enemy_shot: - homing_target = null _flight_time += delta if not is_instance_valid(homing_target): _has_locked_target = false @@ -281,9 +279,9 @@ func _explode_at_target(impact_target: Node) -> void: var query := PhysicsShapeQueryParameters2D.new() query.shape = explosion_shape query.transform = Transform2D(0.0, global_position) - # Converted ships fire at hostiles; hostile blasts can also hit converted ships. + # Player blasts can damage infected ships; converted fire still targets hostiles. var converted_shot := enemy_shot and (collision_mask & 4) != 0 - query.collision_mask = 4 if converted_shot or not enemy_shot else 66 + query.collision_mask = 4 if converted_shot else (66 if enemy_shot else 68) for result in get_world_2d().direct_space_state.intersect_shape(query, 32): _apply_damage_once(result.get("collider")) diff --git a/scripts/items/item_instance.gd b/scripts/items/item_instance.gd index 099be56..f2637f3 100644 --- a/scripts/items/item_instance.gd +++ b/scripts/items/item_instance.gd @@ -24,7 +24,7 @@ func get_passive_description() -> String: var value := get_passive_value() match definition.passive_effect: "recycler": return "Każdy zebrany odłamek asteroidy odnawia %.1f%% maksymalnych tarcz i energii." % value - "virus": return "Przejmuje uciekających wrogów (poniżej 10%% kadłuba). Zamiast uciekać atakują swoich dawnych sojuszników. Limit jednocześnie kontrolowanych: %d." % int(value) + "virus": return "Przejmuje uciekających wrogów (poniżej 10%% kadłuba). Zamiast uciekać atakują swoich dawnych sojuszników. Wrogowie nie wybierają ich jako celu. Nadal możesz uszkodzić i zniszczyć je własną bronią. Limit jednocześnie kontrolowanych: %d." % int(value) "titanium_armor": return "Pochłania 50%% otrzymywanych obrażeń, gdy kadłub ma nie więcej niż %d%% maksimum. Próg sprawdzany przed trafieniem." % int(value) return "" diff --git a/scripts/levels/exploration_poi.gd b/scripts/levels/exploration_poi.gd index 5eb7ca5..2be0acb 100644 --- a/scripts/levels/exploration_poi.gd +++ b/scripts/levels/exploration_poi.gd @@ -1,6 +1,7 @@ extends StaticBody2D const GENERATOR := preload("res://scripts/items/item_generator.gd") +const RECIPES := preload("res://scripts/items/crafting_recipes.gd") const ASTEROID_TEXTURES := { "iron": preload("res://assets/icons/resources/iron.png"), "titanium": preload("res://assets/icons/resources/titanium.png"), @@ -80,6 +81,16 @@ func _process(delta: float) -> void: if is_instance_valid(_asteroid_sprite) and not _claimed: _asteroid_sprite.rotation += _asteroid_spin * delta +# One mutually exclusive reward: 65% minerals, 20% ammunition/rockets, 15% module. +static func wreck_reward_from_roll(roll: int) -> String: + if roll < 65: + return "minerals" + if roll < 75: + return "ammunition" + if roll < 85: + return "missile" + return "module" + func reward_scale() -> float: return 1.0 + 0.05 * (clampi(GameState.system_level, 1, 1000) - 1) @@ -137,19 +148,19 @@ func claim() -> bool: summary = "+%d doświadczenia · +1 moduł" % xp _show_discovery(summary) else: - match _rng.randi_range(0, 3): - 0: - var amount := roundi(_rng.randi_range(3, 8) * scale) - GameState.captain.parts += amount - summary = "+%d części" % amount - 1: + match wreck_reward_from_roll(_rng.randi_range(0, 99)): + "minerals": + var amount := _rng.randi_range(3, 8) + GameState.captain.minerals[_mineral] = int(GameState.captain.minerals.get(_mineral, 0)) + amount + summary = "+%d · %s" % [amount, RECIPES.NAMES[_mineral]] + "module": _drop_module(false) summary = "Znaleziono moduł" - 2: + "missile": var amount := maxi(1, roundi(scale)) inventory.missile_ammunition += amount summary = "+%d rakiet" % amount - 3: + "ammunition": var amount := _rng.randi_range(40, 60) inventory.ammunition += amount summary = "+%d amunicji" % amount diff --git a/scripts/ui/player_guide.gd b/scripts/ui/player_guide.gd index aef3431..5bff50a 100644 --- a/scripts/ui/player_guide.gd +++ b/scripts/ui/player_guide.gd @@ -6,7 +6,7 @@ const PAGES := [ ["Pierwsza wyprawa", "01", "[font_size=26][color=#f1bd79]Twój pierwszy układ[/color][/font_size]\n\nWybierz statek przyciskiem [b]Nowa wyprawa[/b]. Każdy kadłub ma inne parametry — na początek wybierz ten, który pasuje do Twojego stylu.\n\n[color=#91dbea][b]1. Pokonaj zwykłych wrogów[/b][/color]\nOdszukuj przeciwników, rozwijaj wyposażenie i pilnuj zasobów. Aktualny cel znajdziesz w lewym dolnym rogu ekranu.\n\n[color=#91dbea][b]2. Pokonaj bossa[/b][/color]\nGdy zniszczysz pozostałych wrogów, w układzie pojawi się boss. Przygotuj zapas amunicji i sprawdź kadłub przed walką.\n\n[color=#91dbea][b]3. Znajdź zamieszkaną planetę[/b][/color]\nPo zwycięstwie podleć do zamieszkanej planety. Skorzystaj z usług i otwórz wormhole. Wleć w jego środek, żeby przejść do następnego, trudniejszego układu."], ["Sterowanie", "02", "[font_size=26][color=#f1bd79]Opanuj statek[/color][/font_size]\n\n[color=#91dbea][b]W[/b][/color] — ciąg do przodu.\n[color=#91dbea][b]S[/b][/color] — hamowanie; po zatrzymaniu lot wstecz.\n[color=#91dbea][b]A / D[/b][/color] — obracanie statku.\n[color=#91dbea][b]Q / E[/b][/color] — ruch boczny w lewo / prawo.\n[color=#91dbea][b]TAB[/b][/color] — dopalanie: większa prędkość kosztem energii.\n\n[color=#91dbea][b]Kursor myszy[/b][/color] — celowanie.\n[color=#91dbea][b]LPM / PPM / ŚPM[/b][/color] — strzelanie z trzech slotów broni.\n[color=#91dbea][b]1–5[/b][/color] — użycie zamontowanych rozszerzeń.\n[color=#91dbea][b]F1–F5[/b][/color] — aktywne umiejętności kapitana.\n[color=#91dbea][b]R[/b][/color] — użycie powerbanku, jeśli masz go w zapasach.\n\nStatek ma bezwładność. Obrót nie zatrzymuje ruchu — hamuj wcześniej i wykorzystuj ruch boczny, żeby unikać ostrzału."], ["Walka i przetrwanie", "03", "[font_size=26][color=#f1bd79]Wróć z wyprawy[/color][/font_size]\n\n[color=#91dbea][b]Tarcze, kadłub i energia[/b][/color]\nTarcze pochłaniają obrażenia przed kadłubem. Utrata całego kadłuba oznacza zniszczenie statku. Energia zasila część broni oraz dopalanie; odnawia się z czasem, szybciej przy gwieździe.\n\n[color=#91dbea][b]Dobieraj broń do zapasów[/b][/color]\nBroń energetyczna zużywa energię. Działko maszynowe potrzebuje zwykłej amunicji, gamma — napromieniowanej, a wyrzutnie — rakiet. Puste zapasy oznaczają brak strzału. Absorpcja ogranicza obrażenia danego typu.\n\n[color=#91dbea][b]Korzystaj z rozszerzeń[/b][/color]\nMiny atakują obszarowo, flary odciągają uwagę, kamuflaż pomaga zerwać kontakt, teleport przenosi w bezpieczne miejsce, a drony wspierają ostrzał. Doładowanie tarcz działa przy gwieździe. Sprawdzaj koszt i cooldown w opisie.\n\n[color=#f1bd79][b]Uważaj na uciekających przeciwników[/b][/color]\nWróg z kadłubem poniżej 10% ucieka wolniej, ale może zostawić minę. Nie leć bezmyślnie prosto za nim."], - ["Łup i wytwarzanie", "04", "[font_size=26][color=#f1bd79]Zbieraj. Porównuj. Twórz.[/color][/font_size]\n\n[color=#91dbea][b]Łup zbierasz przez podlecenie[/b][/color]\nPrzedmioty w pobliżu są przyciągane do statku. Magazyn mieści 20 przedmiotów; przy pełnym magazynie zwolnij miejsce, żeby zebrać kolejny.\n\n[color=#91dbea][b]Asteroidy zostawiają fizyczne odłamki[/b][/color]\nStrzelaj do asteroid. Po zniszczeniu rozpadają się bazowo na 5–7 poszarpanych fragmentów. Każdy to jedna jednostka surowca — musisz go zebrać. Umiejętność górnika zwiększa uzysk.\n\n[color=#91dbea][b]Przeszukuj wraki[/b][/color]\nPodleć do wraku i przytrzymaj E, aby go przeszukać. Możesz znaleźć zapasy lub wyposażenie.\n\n[color=#91dbea][b]Warsztat — przycisk TWÓRZ[/b][/color]\nZ minerałów wytworzysz części, amunicję, rakiety i powerbanki. Receptura pokazuje wymagane składniki oraz ilość produktu.\n\n[color=#91dbea][b]Odzyskuj części[/b][/color]\nNajedź na zbędny przedmiot w magazynie i użyj przycisku rozłożenia. Części przydadzą się do napraw."], + ["Łup i wytwarzanie", "04", "[font_size=26][color=#f1bd79]Zbieraj. Porównuj. Twórz.[/color][/font_size]\n\n[color=#91dbea][b]Łup zbierasz przez podlecenie[/b][/color]\nPrzedmioty w pobliżu są przyciągane do statku. Magazyn mieści 20 przedmiotów; przy pełnym magazynie zwolnij miejsce, żeby zebrać kolejny.\n\n[color=#91dbea][b]Asteroidy zostawiają fizyczne odłamki[/b][/color]\nStrzelaj do asteroid. Po zniszczeniu rozpadają się bazowo na 5–7 poszarpanych fragmentów. Każdy to jedna jednostka surowca — musisz go zebrać. Umiejętność górnika zwiększa uzysk.\n\n[color=#91dbea][b]Przeszukuj wraki[/b][/color]\nPodleć do wraku i przytrzymaj E, aby go przeszukać. Wraki dają surowce (65%), amunicję lub rakiety (łącznie 20%) albo moduł (15%).\n\n[color=#91dbea][b]Warsztat — przycisk TWÓRZ[/b][/color]\nZ minerałów wytworzysz części, amunicję, rakiety i powerbanki. Receptura pokazuje wymagane składniki oraz ilość produktu.\n\n[color=#91dbea][b]Odzyskuj części[/b][/color]\nNajedź na zbędny przedmiot w magazynie i użyj przycisku rozłożenia. Części przydadzą się do napraw."], ["Statek i kapitan", "05", "[font_size=26][color=#f1bd79]Buduj własny styl gry[/color][/font_size]\n\n[color=#91dbea][b]Wyposażenie[/b][/color]\nKliknij panel statku w prawym górnym rogu i otwórz magazyn. Przeciągaj przedmioty do pasujących slotów: 3 bronie, 5 modułów i 5 rozszerzeń. Aktywne rozszerzenia uruchamiasz klawiszami 1–5; pasywne (oznaczone P) działają automatycznie w tych samych slotach. Najedź kursorem, żeby sprawdzić parametry i porównanie wyposażenia.\n\n[color=#91dbea][b]Nie każdy duplikat jest taki sam[/b][/color]\nBroń i moduły mogą mieć różne parametry oraz addony. Aktywne rozszerzenia losują cooldown od 30 do 5 pełnych sekund. Pasywne mają losowaną siłę efektu: Recykler odnawia tarcze i energię po zebraniu odłamka, Wirus przejmuje uciekających wrogów, a Pancerz z tytanu ogranicza obrażenia przy niskim kadłubie. Krótsze odnowienia są rzadkie — przed rozłożeniem porównaj egzemplarze. Rozszerzenia tego samego typu dzielą aktywne odnowienie.\n\n[color=#91dbea][b]Umiejętności kapitana[/b][/color]\nZa pokonanych wrogów zdobywasz doświadczenie i kredyty. Trudniejsze układy dają więcej bazowego XP. Jeśli kapitan przewyższa poziom układu o ponad 2 poziomy, XP za przeciwników i ruiny maleje; przy przewadze 8 poziomów spada do zera. Kredyty i surowce nadal zbierasz. Po awansie wybierasz umiejętność. Pasywne działają stale, aktywne przypisujesz do F1–F5.\n\n[color=#f1bd79][b]Okno umiejętności nie pauzuje gry.[/b][/color]\nOtwieraj je w bezpiecznym miejscu. Przed kolejnym układem uzupełnij zapasy i sprawdź wyposażenie."] ] var _panel: PanelContainer diff --git a/tests/abilities/passive_extensions_test.gd b/tests/abilities/passive_extensions_test.gd index 7e5b998..9e89ca1 100644 --- a/tests/abilities/passive_extensions_test.gd +++ b/tests/abilities/passive_extensions_test.gd @@ -113,7 +113,7 @@ func _ready() -> void: assert(first.virus_controlled and not second.virus_controlled) assert(not first.is_in_group("enemies") and first.is_in_group("virus_allies")) assert(not first.is_retreating() and first.select_combat_target() != ship) - assert(second.select_combat_target() == first) + assert(second.select_combat_target() == ship) await get_tree().physics_frame assert(first.collision_layer == 64) # Controlled fire uses enemy damage, cannot hit the player and can hit hostiles. diff --git a/tests/abilities/virus_player_damage_test.gd b/tests/abilities/virus_player_damage_test.gd new file mode 100644 index 0000000..2bb9eeb --- /dev/null +++ b/tests/abilities/virus_player_damage_test.gd @@ -0,0 +1,75 @@ +extends Node2D +const ENEMY = preload("res://scenes/game/enemy_ship.tscn") +const SHOT = preload("res://scenes/game/weapon_projectile.tscn") +var ship: CharacterBody2D + +func make_enemy(point: Vector2) -> CharacterBody2D: + var enemy := ENEMY.instantiate() as CharacterBody2D + enemy.position = point + add_child(enemy) + enemy.set_physics_process(false) + enemy.set("_player", ship) + return enemy + +func _ready() -> void: + GameState.start_new_run() + ship = preload("res://scenes/game/player_ship.tscn").instantiate() + add_child(ship) + ship.set_physics_process(false) + ship.get_node("WeaponController").set_physics_process(false) + ship.get_node("ExtensionSystems").set_physics_process(false) + var infected := make_enemy(Vector2(200, 0)) + var hostile := make_enemy(Vector2(400, 0)) + infected.set_virus_controlled(true) + infected.hull = infected.max_hull + infected.shields = infected.max_shields + assert(hostile.select_combat_target() == ship) + assert(infected.select_combat_target() == hostile) + var xp_after_conversion := GameState.captain.experience + await get_tree().physics_frame + await get_tree().physics_frame + # Actual physics ray used by the player laser detects the converted ship. + var query := PhysicsRayQueryParameters2D.create(Vector2(100, 0), Vector2(250, 0), preload("res://scripts/game/weapon_controller.gd").RAY_COLLISION_MASK) + var hit := get_world_2d().direct_space_state.intersect_ray(query) + assert(hit.get("collider") == infected) + # Real swept projectile collision: plasma, bullets and gamma all hit layer 64. + for kind in [ItemDefinition.WeaponKind.PLASMA, ItemDefinition.WeaponKind.MACHINE_GUN, ItemDefinition.WeaponKind.GAMMA]: + infected.hull = infected.max_hull + infected.shields = infected.max_shields + var shot := SHOT.instantiate() + shot.configure(Vector2(100, 0), Vector2.RIGHT, kind, 1000.0, 5.0, 400.0) + add_child(shot) + shot.set_physics_process(false) + var before: float = infected.hull + infected.shields + shot._physics_process(0.15) + assert(infected.hull + infected.shields < before) + # Player rocket splash reaches infected ships; converted rockets cannot hurt player. + infected.shields = infected.max_shields + var rocket := SHOT.instantiate() + rocket.configure(Vector2(220, 0), Vector2.RIGHT, ItemDefinition.WeaponKind.MISSILE, 100.0, 5.0, 400.0) + add_child(rocket) + rocket.set_physics_process(false) + var before: float = infected.hull + infected.shields + rocket._explode_at_target(hostile) + assert(infected.hull + infected.shields < before) + rocket.queue_free() + # A missile already tracking this ship retains it after infection. + var seeker := SHOT.instantiate() + seeker.configure(Vector2(-500, 0), Vector2.RIGHT, ItemDefinition.WeaponKind.MISSILE, 10.0, 5.0, 1000.0, infected) + add_child(seeker) + seeker.set_physics_process(false) + seeker._physics_process(0.01) + assert(seeker.homing_target == infected) + seeker.queue_free() + # Destruction remains possible, with no second XP payout after conversion. + infected.shields = 0.0 + infected.hull = 1.0 + var lethal := SHOT.instantiate() + lethal.configure(Vector2(100, 0), Vector2.RIGHT, ItemDefinition.WeaponKind.MACHINE_GUN, 1000.0, 1000.0, 400.0) + add_child(lethal) + lethal.set_physics_process(false) + lethal._physics_process(0.15) + assert(infected.is_queued_for_deletion()) + assert(GameState.captain.experience == xp_after_conversion) + print("VIRUS_PLAYER_DAMAGE_TEST: PASS target selection, laser ray, plasma/bullet/gamma collisions, rocket splash, retained seeker target, destruction and single XP payout") + get_tree().quit() diff --git a/tests/abilities/virus_player_damage_test.gd.uid b/tests/abilities/virus_player_damage_test.gd.uid new file mode 100644 index 0000000..8817f43 --- /dev/null +++ b/tests/abilities/virus_player_damage_test.gd.uid @@ -0,0 +1 @@ +uid://cikioub3c81bc diff --git a/tests/abilities/virus_player_damage_test.tscn b/tests/abilities/virus_player_damage_test.tscn new file mode 100644 index 0000000..f0948f1 --- /dev/null +++ b/tests/abilities/virus_player_damage_test.tscn @@ -0,0 +1,4 @@ +[gd_scene load_steps=2 format=3] +[ext_resource type="Script" path="res://tests/abilities/virus_player_damage_test.gd" id="1"] +[node name="VirusPlayerDamageTest" type="Node2D"] +script = ExtResource("1") \ No newline at end of file diff --git a/tests/levels/balance_adjustments_test.gd b/tests/levels/balance_adjustments_test.gd index 3a672b9..21b6883 100644 --- a/tests/levels/balance_adjustments_test.gd +++ b/tests/levels/balance_adjustments_test.gd @@ -53,7 +53,7 @@ func _ready() -> void: var ammo_wrecks := 0 for level in [1, 50]: GameState.system_level = level - for reward_seed in range(1, 50): + for reward_seed in range(1, 101): var poi := StaticBody2D.new() poi.set_script(POI) poi.kind = "wreck" @@ -62,9 +62,9 @@ func _ready() -> void: poi.poi_id = "balance:%d:%d" % [level, reward_seed] add_child(poi) var state: int = poi._rng.state - var choice: int = poi._rng.randi_range(0, 3) + var choice: String = POI.wreck_reward_from_roll(poi._rng.randi_range(0, 99)) poi._rng.state = state - if choice == 3: + if choice == "ammunition": var before := inv.ammunition assert(poi.claim()) assert(inv.ammunition - before >= 40 and inv.ammunition - before <= 60) diff --git a/tests/levels/balance_simulation_50.gd b/tests/levels/balance_simulation_50.gd index ad5df39..deb286e 100644 --- a/tests/levels/balance_simulation_50.gd +++ b/tests/levels/balance_simulation_50.gd @@ -89,11 +89,13 @@ func supplies(level: int) -> void: for index in wrecks: if randf() > collect: continue - match randi_range(0, 3): - 0: GameState.captain.parts += roundi(randi_range(3, 8) * scale) - 1: loot(level, 0, true) - 2: inv.missile_ammunition += maxi(1, roundi(scale)) - 3: inv.ammunition += roundi(randi_range(40, 60)) + match preload("res://scripts/levels/exploration_poi.gd").wreck_reward_from_roll(randi_range(0, 99)): + "minerals": + var mineral: String = RECIPES.MINERALS.pick_random() + GameState.captain.minerals[mineral] += randi_range(3, 8) + "module": loot(level, 0, true) + "missile": inv.missile_ammunition += maxi(1, roundi(scale)) + "ammunition": inv.ammunition += randi_range(40, 60) # Rare ruins: mean XP, module generation uses the real loot rules. if randf() < 0.08 * collect: GameState.captain.add_rewards(GameState.captain.encounter_xp(15 * scale, level), 0) diff --git a/tests/levels/enemy_mine_test.gd b/tests/levels/enemy_mine_test.gd new file mode 100644 index 0000000..9d3669c --- /dev/null +++ b/tests/levels/enemy_mine_test.gd @@ -0,0 +1,52 @@ +extends Node2D +const MINE = preload("res://scripts/game/enemy_mine.gd") + +func create_mine(ship: CharacterBody2D) -> Node2D: + var mine := Node2D.new() + mine.set_script(MINE) + mine.player = ship + mine.position = Vector2.ZERO + add_child(mine) + mine.set_physics_process(false) + return mine + +func _ready() -> void: + GameState.start_new_run() + var ship := preload("res://scenes/game/player_ship.tscn").instantiate() + add_child(ship) + ship.set_physics_process(false) + ship.get_node("WeaponController").set_physics_process(false) + ship.get_node("ExtensionSystems").set_physics_process(false) + ship.max_hull = 300.0 + ship.hull = 210.0 + ship.shields = 0.0 + var mine := create_mine(ship) + assert(mine.has_node("MineArtwork")) + var image := MINE.MINE_TEXTURE.get_image() + if image.is_compressed(): + image.decompress() + assert(image.get_pixel(0, 0).a == 0.0) + assert(image.get_pixel(image.get_width() / 2, image.get_height() / 2).a > 0.9) + mine._physics_process(0.69) + assert(ship.hull == 210.0 and not mine._triggered) + ship.position = Vector2(MINE.TRIGGER_RADIUS + 1.0, 0.0) + mine._physics_process(0.02) + assert(ship.hull == 210.0 and not mine._triggered) + ship.position = Vector2(MINE.TRIGGER_RADIUS, 0.0) + mine._physics_process(0.01) + assert(mine._triggered and mine.is_queued_for_deletion()) + assert(is_equal_approx(ship.hull, 60.0), "Damage must use 50% of maximum hull (150), not current hull") + mine._physics_process(0.01) + assert(ship.hull == 60.0, "Mine must hit only once") + ship.position = Vector2.ZERO + ship.max_hull = 200.0 + ship.hull = 150.0 + ship.shields = 100.0 + var shielded_mine := create_mine(ship) + shielded_mine._physics_process(0.8) + assert(ship.shields == 0.0 and ship.hull == 150.0, "Normal shield absorption remains in effect") + var expired := create_mine(ship) + expired._physics_process(MINE.LIFETIME) + assert(expired.is_queued_for_deletion() and not expired._triggered) + print("ENEMY_MINE_TEST: PASS transparent artwork, 50% maximum hull, shields, arming, proximity, one explosion and expiry") + get_tree().quit() diff --git a/tests/levels/enemy_mine_test.gd.uid b/tests/levels/enemy_mine_test.gd.uid new file mode 100644 index 0000000..6fcfd94 --- /dev/null +++ b/tests/levels/enemy_mine_test.gd.uid @@ -0,0 +1 @@ +uid://dgiintcrvlgxq diff --git a/tests/levels/enemy_mine_test.tscn b/tests/levels/enemy_mine_test.tscn new file mode 100644 index 0000000..ccc9b80 --- /dev/null +++ b/tests/levels/enemy_mine_test.tscn @@ -0,0 +1,4 @@ +[gd_scene load_steps=2 format=3] +[ext_resource type="Script" path="res://tests/levels/enemy_mine_test.gd" id="1"] +[node name="EnemyMineTest" type="Node2D"] +script = ExtResource("1") diff --git a/tests/levels/wreck_rewards_test.gd b/tests/levels/wreck_rewards_test.gd new file mode 100644 index 0000000..49561ad --- /dev/null +++ b/tests/levels/wreck_rewards_test.gd @@ -0,0 +1,55 @@ +extends Node2D +const POI = preload("res://scripts/levels/exploration_poi.gd") + +func _ready() -> void: + var weights := {"minerals": 0, "ammunition": 0, "missile": 0, "module": 0} + for roll in 100: + weights[POI.wreck_reward_from_roll(roll)] += 1 + assert(weights == {"minerals": 65, "ammunition": 10, "missile": 10, "module": 15}) + GameState.start_new_run() + GameState.system_level = 1 + var ship := preload("res://scenes/game/player_ship.tscn").instantiate() + add_child(ship) + ship.set_physics_process(false) + ship.get_node("WeaponController").set_physics_process(false) + ship.get_node("ExtensionSystems").set_physics_process(false) + var inv: ShipInventory = ship.get_node("ShipInventory") + var counts := {"minerals": 0, "ammunition": 0, "missile": 0, "module": 0} + for reward_seed in range(1, 251): + var poi := StaticBody2D.new() + poi.set_script(POI) + poi.kind = "wreck" + poi.player = ship + poi.reward_seed = reward_seed + poi.poi_id = "wreck-rewards:%d" % reward_seed + add_child(poi) + poi.set_physics_process(false) + var state: int = poi._rng.state + var reward: String = POI.wreck_reward_from_roll(poi._rng.randi_range(0, 99)) + poi._rng.state = state + counts[reward] += 1 + var minerals_before: Dictionary = GameState.captain.minerals.duplicate() + var ammo_before := inv.ammunition + var rockets_before := inv.missile_ammunition + var mineral: String = poi._mineral + assert(poi.claim()) + assert(not poi.claim(), "A wreck can be claimed only once") + assert(GameState.captain.parts == 0.0) + if reward == "minerals": + var delta: int = GameState.captain.minerals[mineral] - minerals_before[mineral] + assert(delta >= 3 and delta <= 8) + for key in minerals_before: + if key != mineral: + assert(GameState.captain.minerals[key] == minerals_before[key]) + else: + assert(GameState.captain.minerals == minerals_before) + if reward == "ammunition": + assert(inv.ammunition - ammo_before >= 40 and inv.ammunition - ammo_before <= 60) + else: + assert(inv.ammunition == ammo_before) + assert(inv.missile_ammunition - rockets_before == (1 if reward == "missile" else 0)) + await get_tree().process_frame + assert(get_tree().get_nodes_in_group("loot").size() == counts.module, "Every module reward must physically drop one item") + assert(counts.minerals > counts.ammunition + counts.missile) + print("WRECK_REWARDS_TEST: PASS exact 65/10/10/15 weights, 250 actual wrecks, mineral/ammo amounts, single exclusive rewards, physical modules and no duplicate claims") + get_tree().quit() diff --git a/tests/levels/wreck_rewards_test.tscn b/tests/levels/wreck_rewards_test.tscn new file mode 100644 index 0000000..3700a21 --- /dev/null +++ b/tests/levels/wreck_rewards_test.tscn @@ -0,0 +1,4 @@ +[gd_scene load_steps=2 format=3] +[ext_resource type="Script" path="res://tests/levels/wreck_rewards_test.gd" id="1"] +[node name="WreckRewardsTest" type="Node2D"] +script = ExtResource("1") \ No newline at end of file diff --git a/tests/previews/enemy_mine_preview.gd b/tests/previews/enemy_mine_preview.gd new file mode 100644 index 0000000..621c65a --- /dev/null +++ b/tests/previews/enemy_mine_preview.gd @@ -0,0 +1,24 @@ +extends "res://scripts/game/game.gd" + +func _ready() -> void: + super._ready() + set_physics_process(false) + player.set_physics_process(false) + player.get_node("WeaponController").set_physics_process(false) + for enemy in get_tree().get_nodes_in_group("enemies"): + enemy.set_physics_process(false) + var index := 0 + for offset in [Vector2(-135, -70), Vector2(130, -90), Vector2(160, 105)]: + var mine := Node2D.new() + mine.set_script(preload("res://scripts/game/enemy_mine.gd")) + mine.position = player.position + offset + add_child(mine) + mine.set_physics_process(false) + mine._physics_process(0.3 if index == 0 else 1.0 + index * 0.25) + index += 1 + if "--capture-preview" in OS.get_cmdline_user_args(): + await get_tree().create_timer(0.6).timeout + await RenderingServer.frame_post_draw + get_viewport().get_texture().get_image().save_png("res://artifacts/enemy_mine_preview.png") + print("ENEMY_MINE_PREVIEW: captured") + get_tree().quit() diff --git a/tests/previews/enemy_mine_preview.gd.uid b/tests/previews/enemy_mine_preview.gd.uid new file mode 100644 index 0000000..3f05a7c --- /dev/null +++ b/tests/previews/enemy_mine_preview.gd.uid @@ -0,0 +1 @@ +uid://8s6rjx62wqdl diff --git a/tests/previews/enemy_mine_preview.tscn b/tests/previews/enemy_mine_preview.tscn new file mode 100644 index 0000000..c4f3866 --- /dev/null +++ b/tests/previews/enemy_mine_preview.tscn @@ -0,0 +1,5 @@ +[gd_scene load_steps=3 format=3] +[ext_resource type="PackedScene" path="res://scenes/game/game.tscn" id="1"] +[ext_resource type="Script" path="res://tests/previews/enemy_mine_preview.gd" id="2"] +[node name="EnemyMinePreview" instance=ExtResource("1")] +script = ExtResource("2")