Balans
This commit is contained in:
Tomasz Boruc 2026-10-17 18:58:40 +02:00
parent f4b3fe79ae
commit 5b1d284440
27 changed files with 366 additions and 48 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -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

BIN
assets/other/enemy_mine.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -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

View File

@ -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.

View File

@ -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")

View File

@ -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)

View File

@ -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")):

View File

@ -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")

View File

@ -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"))

View File

@ -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 ""

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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()

View File

@ -0,0 +1 @@
uid://cikioub3c81bc

View File

@ -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")

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -0,0 +1 @@
uid://dgiintcrvlgxq

View File

@ -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")

View File

@ -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()

View File

@ -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")

View File

@ -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()

View File

@ -0,0 +1 @@
uid://8s6rjx62wqdl

View File

@ -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")