Prepare Void Bastion alpha 7 release

This commit is contained in:
Emporex committed 2026-10-06 23:59:12 +02:00
1 parent 56077e88a5
commit e63e08ddde
18 files changed
+799 -95

No files matched your search

+13 -2
View File
@@ -69,12 +69,23 @@ Diese Liste wird fortlaufend erweitert und vor jeder öffentlichen Version abgea
## Gegnervielfalt und Balance
- [ ] **M** Neue gegnerische Flugzeuge/Schiffe mit unterschiedlichen Rollen spezifizieren (z. B. schneller leichter Jäger, gepanzertes Schiff, Schildträger, Unterstützer und Schwarmziel); jede Rolle braucht erkennbare Silhouette und passende Konter.
- [ ] **A/M** Pro Gegnertyp Werte und Verhalten dokumentieren: Lebenspunkte, Tempo, Schild/Resistenz, Reaktor-Schaden, Belohnung und passende Türme.
- [x] **A** Drei Bodeneinheiten ergänzt: schneller Rover, Sturmpanzer und Belagerungsläufer; Werte enthalten Lebenspunkte, Tempo, Reaktorschaden und Belohnung.
- [x] **A** Türme besitzen Luft-/Boden-Zielklassen; Plasma und Railgun treffen beide Kategorien mit reduziertem Schaden.
- [x] **A** Aktuelle und nächste Welle werden im HUD mit Luft-/Bodensymbolen, kleinen Einheitengrafiken und Anzahlen angezeigt.
- [x] **A** Die Vorschau zeigt die Gesamtzahl der Schiffe und lesbare Stückzahlen neben den Gegnerminiaturen.
- [x] **A** Lasertreffer bauen ein separates Schild-Lebenskonto ab; der blaue Schutzring zeigt den verbleibenden Anteil gegen den Uhrzeigersinn an und Überschussschaden trifft die Hülle.
- [x] **A** Lokaler Entwicklungsmultiplikator für Gegner-Lebenspunkte (0,5×–3,0×) ist über F9 und die +/- Schaltflächen einstellbar; Online-Koop bleibt davon unabhängig.
- [x] **A** Entwicklermodus kann den Kristallstand in 25er-Schritten erhöhen oder senken und speichert den Teststand.
- [ ] **M** Neue Luft- und Bodentypen im laufenden Spiel visuell prüfen: Silhouetten, Trefferreaktionen, Flug-/Bodenbewegung und Zielauswahl der Türme.
- [ ] **M** Die neuen leuchtend grünen Bodenfahrzeuge im Spiel und in der Wellenvorschau auf Lesbarkeit bei kleiner Darstellung prüfen.
- [ ] **M** Turmprioritäten erste/letzte/MEISTE HP/SCHILD testen; Laser muss bei mehreren Zielen immer ein erreichbares Schildziel vor ungeschützten Zielen wählen.
- [ ] **M** Bei einem Schildgegner den Laser einsetzen und prüfen, dass der blaue Ring sichtbar gegen den Uhrzeigersinn schrumpft und erst danach der überschüssige Schaden die Hülle trifft.
- [ ] **M** Pro Gegnertyp Konter und Werte im Spiel prüfen: Lebenspunkte, Tempo, Schild/Resistenz, Reaktor-Schaden, Belohnung und passende Türme.
- [ ] **M** Alle sieben Karten auf Leicht, Mittel und Schwer vollständig testen und Schwierigkeit, Ressourcen, Gegnerzusammensetzung sowie Sieg-/Niederlagenquote protokollieren.
- [ ] **M** Schwer muss spürbar fordernd sein, aber mit sinnvoller Strategie schaffbar; Leicht und Mittel bleiben fair abgestuft.
- [ ] **A/M** Wellenmischung je Schwierigkeit testen; Schwierigkeit soll nicht allein durch mehr Lebenspunkte entstehen.
- [ ] **M** Testläufe mit Siegquote, Reaktorleben und verwendeten Türmen festhalten; Balance iterativ nach den Ergebnissen anpassen.
- [ ] **M** Gegner-Stärkemultiplikator für Entwicklung verwenden, Testwert notieren und vor dem Release auf 1,0× zurücksetzen.
## Android-Test und Store-Vorbereitung
+9 -2
View File
@@ -16,7 +16,14 @@ Ein plattformübergreifender 2D-Tower-Defense-Prototyp für Godot 4. Der aktuell
- jede geeignete Plattformzelle ist bebaubar; ausschließlich Wegzellen, Reaktoren und Bereiche außerhalb der Plattform sind gesperrt
- neu erzeugte, straßenfreie HD-Kartenbasen mit 3344 × 1882 Pixeln und Mipmaps für eine schärfere Windows-Darstellung
- sechs Türme: Kinetik, Laser, Rakete, Plasma, EMP-Puls und Railgun
- sechs Gegnerklassen: Jäger, Fregatte, Kreuzer, schneller Abfangjäger, Schildkreuzer und schwerer Belagerer
- neun Gegnerklassen mit Luft- und Bodeneinheiten: Jäger, Fregatte, Kreuzer, Abfangjäger, Schildkreuzer, Luft-Belagerer, schneller Boden-Rover, Panzer und schwerer Läufer
- neue Bodeneinheiten-Grafiken mit leuchtend grünen Warnmarkierungen und Energielichtern
- Zielprioritäten für Türme: erste, letzte, höchste verbleibende Hülle oder Schildziele zuerst; Laser priorisiert Schilde immer automatisch
- Luft-/Bodenvorschau wird durch gleich gestaltete grüne Pfeile unterschieden: Luft nach oben, Boden nach unten
- Schildgegner haben einen blauen, gegen den Uhrzeigersinn abnehmenden Schildring; Lasertreffer bauen zuerst Schildpunkte ab und übertragen Überschuss auf die Hülle
- sechs Türme mit klar ausgewiesenen Zielen: Boden, Luft oder beide Kategorien; universelle Türme verursachen reduziert Schaden
- HUD-Vorschau mit kleinen Einheitensymbolen und Stückzahl für aktuelle und nächste Welle
- Entwickler-Testregler für Gegner-Lebenspunkte (F9 ein-/ausblenden, +/− verändern; Faktor 0,5×–3,0×, im Online-Koop deaktiviert)
- 15 automatisch skalierende Missionswellen
- drei wählbare Schwierigkeitsgrade: Leicht behält die bisherige Kurve; Schwer startet knapper und erhöht ab Missionsmitte Gegnerhülle, Gegnerzahl und fortgeschrittene Gegnertypen stärker
- höhere Schwierigkeitsgrade beginnen mit knapperen Ressourcen und belohnen einen Kartensieg mit mehr Kernkristallen
@@ -86,7 +93,7 @@ Der mitgelieferte Logiktest lässt sich bei Bedarf mit `godot --headless --path
Für reproduzierbare Windows-Builds steht `packaging/windows/build.ps1` bereit. Godot und NSIS werden als Parameter übergeben; das Skript exportiert das Spiel, erstellt den Installer und gibt anschließend Größe und SHA-256-Prüfsumme aus. Die manuellen Freigabeprüfungen stehen in `ALPHA_TEST_CHECKLIST.md`.
Die fertigen Hinweise für die erste öffentliche Testversion stehen in `RELEASE_NOTES_0.1.0-alpha.md`.
Die Hinweise zum aktuellen Windows-Testbuild stehen in `RELEASE_NOTES_0.1.0-alpha.7.md`.
## Werbung und Datenschutz
+18
View File
@@ -0,0 +1,18 @@
# Void Bastion 0.1.0-alpha.6
## Luft- und Bodeneinheiten
- Drei neue Bodengegner: schneller Rover, schwerer Panzer und Belagerungsläufer.
- Alle drei Bodeneinheiten haben jetzt deutlich sichtbare leuchtend grüne Akzente.
- Türme zeigen ihre Zielklasse an und greifen nur passende Einheiten an. Laser, Plasma und Railgun können beide Klassen treffen, verursachen dabei aber weniger Schaden.
- Für ausgewählte Türme lässt sich die Zielpriorität zwischen erster, letzter, höchster verbleibender Hülle und Schildzielen wechseln. Lasertürme priorisieren Schildgegner immer automatisch, sowohl lokal als auch im Online-Koop.
- Schildgegner haben jetzt ein eigenes Schild-Lebenskonto. Lasertreffer bauen es zuerst ab; überschüssiger Schaden geht danach auf die Hülle. Das Schild wird als blauer Ring dargestellt, dessen verbleibender Anteil gegen den Uhrzeigersinn schrumpft.
- In der Wellenvorschau zeigen gleich gestaltete grüne Richtungspfeile nach oben für Luft und nach unten für Boden.
- Die Vorschau nennt jetzt zusätzlich die Gesamtzahl aller Schiffe der Welle und zeigt die Stückzahl pro sichtbarem Gegnertyp gut lesbar neben dessen Miniatur.
- Gegnerwellen werden vorab deterministisch geplant. Die HUD-Leiste zeigt kompakt die aktuelle und nächste Welle mit Kategorie, Einheitensymbol und Anzahl.
- Im lokalen Testmodus lässt sich die Gegner-Lebenspunkte-Stärke mit **F9** einblenden und über **− / +** zwischen **0,5× und 3,0×** einstellen. Der Faktor gilt auch für bereits aktive lokale Gegner und ist im Online-Koop gesperrt.
- Dasselbe Testfeld kann mit **−25 / +25** den lokalen Kernkristallstand für Shop- und Freischalttests verändern; der Teststand wird gespeichert und fällt nie unter null.
## Hinweis
Diese Änderung ist ein Entwicklungsstand und wurde noch nicht als öffentliches Setup veröffentlicht. Online-Koop-Wellen verwenden dieselben Einheitentypen, Klassenregeln und Wellenplanung; der lokale Testmultiplikator verändert keine Online-Partie.
+16
View File
@@ -0,0 +1,16 @@
# Void Bastion 0.1.0-alpha.7
## Neue Einheiten und Türme
- Drei Bodengegner – Rover, Panzer und Belagerungsläufer – sind mit grünen Leuchtelementen besser von Lufteinheiten unterscheidbar.
- Die Turmauswahl ist nach Zielklasse sortiert: zuerst reine Luftabwehr, dann Bodenabwehr, danach Mehrzwecktürme. Aktuell gibt es noch keinen reinen Luftabwehrturm.
- Die Felder der Turmauswahl sind leicht eingefärbt: Rot für Luft, Grün für Boden und Blau-Cyan für Luft/Boden.
- Der Railgun-Strahl ist lila und durchschlägt alle Gegner auf seiner Schusslinie, auch Ziele hinter dem zuerst anvisierten Gegner. Schilde werden weiterhin durchschlagen.
- Der EMP-Puls trifft Luft- und Bodeneinheiten im Explosionsradius mit je 20 Schaden.
- Schildgegner, Zielprioritäten und die Wellenübersicht zeigen jetzt die Luft-/Bodenklassen und Stückzahlen.
- Im lokalen Entwicklermodus lassen sich Gegnerstärke und Kernkristalle zum Testen anpassen.
## Testhinweise
- Spiel- und Servertests wurden im Headless-Modus ausgeführt.
- Windows-Setup und Online-Koop müssen vor einer öffentlichen Freigabe zusätzlich praktisch auf dem Zielsystem geprüft werden.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bnkdjurvbhyxn"
path="res://.godot/imported/enemy_ground_rover.png-78b1629e1d50e53aefbd44cf187db541.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/enemy_ground_rover.png"
dest_files=["res://.godot/imported/enemy_ground_rover.png-78b1629e1d50e53aefbd44cf187db541.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dw874rt1gjjq4"
path="res://.godot/imported/enemy_ground_tank.png-a69deadc2b1612bb3304c457aae904a7.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/enemy_ground_tank.png"
dest_files=["res://.godot/imported/enemy_ground_tank.png-a69deadc2b1612bb3304c457aae904a7.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cpa443bn3712c"
path="res://.godot/imported/enemy_ground_walker.png-97fb77912dc8b92904da3219a9ad1771.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/enemy_ground_walker.png"
dest_files=["res://.godot/imported/enemy_ground_walker.png-97fb77912dc8b92904da3219a9ad1771.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
+2 -2
View File
@@ -24,8 +24,8 @@ application/modify_resources=true
application/icon="res://assets/app_icon.png"
application/console_wrapper_icon=""
application/icon_interpolation=4
application/file_version="0.1.0.5"
application/product_version="0.1.0.5"
application/file_version="0.1.0.7"
application/product_version="0.1.0.7"
application/company_name="GerGames"
application/product_name="Void Bastion"
application/file_description="Void Bastion – Alpha-Testversion"
+375 -52
View File
@@ -17,6 +17,9 @@ const ENEMY_TEXTURE := preload("res://assets/enemy_ships.png")
const ENEMY_INTERCEPTOR_TEXTURE := preload("res://assets/enemy_interceptor.png")
const ENEMY_SHIELD_CARRIER_TEXTURE := preload("res://assets/enemy_shield_carrier.png")
const ENEMY_SIEGE_DESTROYER_TEXTURE := preload("res://assets/enemy_siege_destroyer.png")
const ENEMY_GROUND_ROVER_TEXTURE := preload("res://assets/enemy_ground_rover.png")
const ENEMY_GROUND_TANK_TEXTURE := preload("res://assets/enemy_ground_tank.png")
const ENEMY_GROUND_WALKER_TEXTURE := preload("res://assets/enemy_ground_walker.png")
const ROAD_STRAIGHT_TEXTURE := preload("res://assets/road_straight.svg")
const ROAD_CORNER_TEXTURE := preload("res://assets/road_corner.svg")
const ROAD_SURFACE_TEXTURE := preload("res://assets/road_surface_material_v1.png")
@@ -81,21 +84,24 @@ const DIFFICULTY_DESCRIPTIONS := [
]
const TOWER_DATA := [
{"name": "KINETIK", "cost": 120, "damage": 22.0, "range": 190.0, "rate": 0.55, "shot_speed": 620.0, "damage_type": "EINZELZIEL", "color": Color("7de7ff"), "unlock_cost": 0, "description": "Zuverlässiges Projektilgeschütz."},
{"name": "LASER", "cost": 180, "damage": 12.0, "range": 165.0, "rate": 0.18, "shot_speed": 1100.0, "damage_type": "EINZELZIEL", "color": Color("62fff1"), "unlock_cost": 0, "description": "Schneller Strahl, stark gegen Schilde."},
{"name": "RAKETE", "cost": 250, "damage": 65.0, "range": 260.0, "rate": 1.40, "shot_speed": 360.0, "damage_type": "FLÄCHE 76", "color": Color("ffb35a"), "unlock_cost": 0, "description": "Explosionsschaden gegen Gruppen."},
{"name": "PLASMA", "cost": 230, "damage": 48.0, "range": 240.0, "rate": 1.05, "shot_speed": 480.0, "damage_type": "PLASMA-FLÄCHE 92", "color": Color("e27bff"), "unlock_cost": 18, "description": "Plasmaexplosion trifft Gegner im Umkreis."},
{"name": "EMP-PULS", "cost": 210, "damage": 34.0, "range": 215.0, "rate": 0.92, "shot_speed": 540.0, "damage_type": "EMP-FLÄCHE 118", "color": Color("77e8ff"), "unlock_cost": 24, "description": "Breiter Impuls trifft ganze Gegnergruppen."},
{"name": "RAILGUN", "cost": 300, "damage": 110.0, "range": 330.0, "rate": 1.85, "shot_speed": 1450.0, "damage_type": "SCHILD-DURCHSCHLAG", "color": Color("87efff"), "unlock_cost": 30, "description": "Weitreichender Schuss, ignoriert Schildschutz."}
{"name": "KINETIK", "cost": 120, "damage": 22.0, "range": 190.0, "rate": 0.55, "shot_speed": 620.0, "damage_type": "EINZELZIEL", "target_class": "ground", "color": Color("7de7ff"), "unlock_cost": 0, "description": "Zuverlässig gegen Bodenziele."},
{"name": "LASER", "cost": 180, "damage": 12.0, "range": 165.0, "rate": 0.18, "shot_speed": 1100.0, "damage_type": "EINZELZIEL", "target_class": "both", "both_damage_scale": 0.82, "color": Color("62fff1"), "unlock_cost": 0, "description": "Mehrzwecklaser; durchdringt Schilde von Luft- und Bodenzielen."},
{"name": "RAKETE", "cost": 250, "damage": 65.0, "range": 260.0, "rate": 1.40, "shot_speed": 360.0, "damage_type": "FLÄCHE 76", "target_class": "ground", "color": Color("ffb35a"), "unlock_cost": 0, "description": "Bodenraketen treffen Gegnergruppen."},
{"name": "PLASMA", "cost": 230, "damage": 34.0, "range": 240.0, "rate": 1.05, "shot_speed": 480.0, "damage_type": "PLASMA-FLÄCHE 92", "target_class": "both", "both_damage_scale": 0.72, "color": Color("e27bff"), "unlock_cost": 18, "description": "Mehrzweckwaffe mit reduziertem Schaden."},
{"name": "EMP-PULS", "cost": 210, "damage": 20.0, "range": 215.0, "rate": 0.92, "shot_speed": 540.0, "damage_type": "EMP-FLÄCHE 118 · ALLE", "target_class": "both", "both_damage_scale": 1.0, "color": Color("77e8ff"), "unlock_cost": 24, "description": "Impuls trifft alle Luft- und Bodenziele im Radius mit 20 Schaden."},
{"name": "RAILGUN", "cost": 300, "damage": 77.0, "range": 330.0, "rate": 1.85, "shot_speed": 1450.0, "damage_type": "PIERCING-STRAHL", "target_class": "both", "both_damage_scale": 0.70, "color": Color("c86bff"), "unlock_cost": 30, "description": "Lila Durchschlagsstrahl trifft alle Gegner auf seiner Schusslinie."}
]
const ENEMY_DATA := [
{"name": "JAEGER", "hp": 70.0, "speed": 105.0, "reward": 18, "size": 58.0, "breach_damage": 1, "shield_resistance": 0.28},
{"name": "FREGATTE", "hp": 185.0, "speed": 72.0, "reward": 34, "size": 78.0, "breach_damage": 2, "shield_resistance": 0.28},
{"name": "KREUZER", "hp": 520.0, "speed": 43.0, "reward": 78, "size": 112.0, "breach_damage": 3, "shield_resistance": 0.28},
{"name": "ABFANGJAEGER", "hp": 52.0, "speed": 178.0, "reward": 22, "size": 48.0, "breach_damage": 2, "shield_resistance": 0.28},
{"name": "SCHILDKREUZER", "hp": 260.0, "speed": 58.0, "reward": 52, "size": 92.0, "breach_damage": 2, "shield_resistance": 0.18, "default_shield": true},
{"name": "BELAGERER", "hp": 740.0, "speed": 48.0, "reward": 108, "size": 128.0, "breach_damage": 4, "shield_resistance": 0.28}
{"name": "JAEGER", "category": "air", "hp": 70.0, "speed": 105.0, "reward": 18, "size": 58.0, "breach_damage": 1, "shield_resistance": 0.28},
{"name": "FREGATTE", "category": "air", "hp": 185.0, "speed": 72.0, "reward": 34, "size": 78.0, "breach_damage": 2, "shield_resistance": 0.28},
{"name": "KREUZER", "category": "air", "hp": 520.0, "speed": 43.0, "reward": 78, "size": 112.0, "breach_damage": 3, "shield_resistance": 0.28},
{"name": "ABFANGJAEGER", "category": "air", "hp": 52.0, "speed": 178.0, "reward": 22, "size": 48.0, "breach_damage": 2, "shield_resistance": 0.28},
{"name": "SCHILDKREUZER", "category": "air", "hp": 260.0, "speed": 58.0, "reward": 52, "size": 92.0, "breach_damage": 2, "shield_resistance": 0.18, "default_shield": true},
{"name": "BELAGERER", "category": "air", "hp": 740.0, "speed": 48.0, "reward": 108, "size": 128.0, "breach_damage": 4, "shield_resistance": 0.28},
{"name": "SPAEHER-ROVER", "category": "ground", "hp": 88.0, "speed": 142.0, "reward": 22, "size": 54.0, "breach_damage": 1, "shield_resistance": 0.28},
{"name": "STURMPANZER", "category": "ground", "hp": 390.0, "speed": 61.0, "reward": 61, "size": 92.0, "breach_damage": 3, "shield_resistance": 0.28},
{"name": "BELAGERUNGSLAEUFER", "category": "ground", "hp": 980.0, "speed": 36.0, "reward": 132, "size": 124.0, "breach_damage": 5, "shield_resistance": 0.28}
]
var path_points := PackedVector2Array()
@@ -139,6 +145,11 @@ var next_enemy_id := 1
var wave_active := false
var enemies_to_spawn := 0
var spawn_timer := 0.0
var wave_type_schedule: Array[Dictionary] = []
var wave_spawn_index := 0
var online_wave_types_remaining: Array[int] = []
var enemy_strength_multiplier := 1.0
var developer_panel_open := false
var status_text := ""
var status_timer := 0.0
var elapsed := 0.0
@@ -163,6 +174,13 @@ var wave_button := Rect2(1320, 18, 255, 54)
var pause_button := Rect2(1242, 18, 62, 54)
var speed_button := Rect2(1170, 18, 62, 54)
var auto_wave_button := Rect2(1320, 73, 255, 22)
var wave_current_preview_rect := Rect2(520, 67, 258, 44)
var wave_next_preview_rect := Rect2(786, 67, 258, 44)
var developer_panel_rect := Rect2(1060, 104, 515, 78)
var developer_strength_down_button := Rect2(1074, 140, 42, 30)
var developer_strength_up_button := Rect2(1260, 140, 42, 30)
var developer_crystals_down_button := Rect2(1320, 140, 50, 30)
var developer_crystals_up_button := Rect2(1512, 140, 50, 30)
var back_button := Rect2(1070, 18, 90, 54)
var tower_buttons := [
Rect2(15, 768, 255, 122), Rect2(278, 768, 255, 122), Rect2(541, 768, 255, 122),
@@ -173,6 +191,7 @@ var sell_button := Rect2(1430, 187, 145, 44)
var tutorial_skip_button := Rect2(365, 205, 145, 30)
var reward_ad_button := Rect2(1070, 690, 300, 54)
var tower_info_rect := Rect2(1275, 103, 300, 76)
var target_priority_button := Rect2(1275, 236, 300, 34)
var endless_button := Rect2(545, 515, 510, 70)
var finish_mission_button := Rect2(625, 610, 350, 62)
var profile_button := Rect2(1280, 25, 270, 52)
@@ -314,10 +333,14 @@ func _update_wave(delta: float) -> void:
func _spawn_enemy() -> void:
var is_boss := wave > 0 and wave % BOSS_WAVE_INTERVAL == 0 and enemies_to_spawn <= (2 if coop_mode else 1)
var type := _enemy_type_for_wave(wave, randf(), is_boss)
var schedule_entry: Dictionary = wave_type_schedule[wave_spawn_index] if wave_spawn_index < wave_type_schedule.size() else {}
var is_boss := bool(schedule_entry.get("boss", wave > 0 and wave % BOSS_WAVE_INTERVAL == 0 and enemies_to_spawn <= (2 if coop_mode else 1)))
var type := int(schedule_entry.get("type", _enemy_type_for_wave(wave, randf(), is_boss)))
wave_spawn_index += 1
var data: Dictionary = ENEMY_DATA[type]
var health_scale := _enemy_health_scale(wave, is_boss)
var health_scale := _enemy_health_scale(wave, is_boss) * enemy_strength_multiplier
var has_shield := bool(data.get("default_shield", false)) or (wave >= 4 and randf() < minf(0.42, 0.10 + wave * 0.018 + selected_difficulty * 0.06))
var max_shield_hp: float = data.hp * health_scale * 0.55 if has_shield else 0.0
var route_id := 0
var route := path_points
if coop_mode:
@@ -332,13 +355,16 @@ func _spawn_enemy() -> void:
"route_id": route_id,
"hp": data.hp * health_scale,
"max_hp": data.hp * health_scale,
"base_max_hp": data.hp * _enemy_health_scale(wave, is_boss),
"speed": data.speed * (1.0 + wave * 0.012) * (0.82 if is_boss else 1.0) * float(_difficulty().speed),
"reward": int((data.reward + wave) * (4 if is_boss else 1) * float(_difficulty().income)),
"angle": 0.0,
"flash": 0.0,
"boss": is_boss,
"size_scale": 1.34 if is_boss else 1.0,
"shield": bool(data.get("default_shield", false)) or (wave >= 4 and randf() < minf(0.42, 0.10 + wave * 0.018 + selected_difficulty * 0.06)),
"shield": has_shield,
"shield_hp": max_shield_hp,
"max_shield_hp": max_shield_hp,
"shield_resistance": float(data.shield_resistance)
})
if is_boss:
@@ -348,8 +374,14 @@ func _spawn_enemy() -> void:
func _enemy_type_for_wave(for_wave: int, roll: float, is_boss: bool) -> int:
if is_boss:
return 5
return 8 if for_wave >= 10 else 5
var advanced_offset := 2 if selected_difficulty == 2 else (1 if selected_difficulty == 1 else 0)
if for_wave >= 10 and roll >= 0.96:
return 8
if for_wave >= 8 and roll >= 0.84 and roll < 0.94:
return 7
if for_wave >= 3 and roll >= 0.52 and roll < 0.64:
return 6
if for_wave >= 10 - advanced_offset and roll >= 0.94:
return 5
if for_wave >= 7 - advanced_offset and roll >= 0.80:
@@ -363,6 +395,78 @@ func _enemy_type_for_wave(for_wave: int, roll: float, is_boss: bool) -> int:
return 0
func _wave_population(for_wave: int) -> int:
if for_wave <= 0:
return 0
var population_scale := 1.0 + maxf(0.0, for_wave - 3.0) * 0.08
if selected_difficulty == 2:
population_scale += maxf(0.0, for_wave - 4.0) * 0.025
if for_wave > MAX_WAVES:
population_scale += (for_wave - MAX_WAVES) * 0.035
return ceili((7 + for_wave * 2) * float(_difficulty().count) * population_scale) * (2 if coop_mode else 1)
func _build_wave_schedule(for_wave: int, count: int) -> Array[Dictionary]:
var schedule: Array[Dictionary] = []
var boss_count := mini(2 if coop_mode else 1, count) if for_wave > 0 and for_wave % BOSS_WAVE_INTERVAL == 0 else 0
for index in count:
var is_boss := index >= count - boss_count
var roll := fposmod(float(index + 1) * 0.61803398875 + float(for_wave) * 0.38196601125, 1.0)
var type := _enemy_type_for_wave(for_wave, roll, is_boss)
schedule.append({"type": type, "boss": is_boss})
return schedule
func _enemy_category(enemy_type: int) -> String:
return str(ENEMY_DATA[clampi(enemy_type, 0, ENEMY_DATA.size() - 1)].category)
func _tower_target_class(tower_type: int) -> String:
return str(TOWER_DATA[clampi(tower_type, 0, TOWER_DATA.size() - 1)].get("target_class", "both"))
func _tower_display_order() -> Array[int]:
var ordered_types: Array[int] = []
for target_class in ["air", "ground", "both"]:
for tower_type in TOWER_DATA.size():
if _tower_target_class(tower_type) == target_class:
ordered_types.append(tower_type)
return ordered_types
func _tower_can_target(tower_type: int, enemy_type: int) -> bool:
var target_class := _tower_target_class(tower_type)
return target_class == "both" or target_class == _enemy_category(enemy_type)
func _tower_target_label(tower_type: int) -> String:
match _tower_target_class(tower_type):
"air": return "LUFT"
"ground": return "BODEN"
_: return "BEIDE"
func _set_enemy_strength_multiplier(value: float) -> void:
if coop_online_mode:
_show_status("TESTMULTIPLIKATOR IST IM ONLINE-KOOP DEAKTIVIERT")
return
enemy_strength_multiplier = clampf(roundf(value * 10.0) / 10.0, 0.5, 3.0)
for enemy in enemies:
var health_ratio := clampf(float(enemy.hp) / maxf(1.0, float(enemy.max_hp)), 0.0, 1.0)
var adjusted_max := float(enemy.get("base_max_hp", enemy.max_hp)) * enemy_strength_multiplier
enemy.max_hp = adjusted_max
enemy.hp = adjusted_max * health_ratio
_show_status("TEST: GEGNERSTÄRKE ×%.1f" % enemy_strength_multiplier)
queue_redraw()
func _adjust_core_crystals(amount: int) -> void:
core_crystals = clampi(core_crystals + amount, 0, 999999)
_save_progress()
_show_status("TEST: KERNKRISTALLE %s25 • STAND %d" % ["+" if amount >= 0 else "−", core_crystals])
queue_redraw()
func _enemy_health_scale(for_wave: int, is_boss: bool) -> float:
var early_ramp := maxf(0.0, for_wave - 1.0) * 0.20
var post_four_ramp := maxf(0.0, for_wave - 3.0) * 0.09
@@ -407,24 +511,77 @@ func _update_towers(delta: float) -> void:
func _find_target(tower: Dictionary) -> Dictionary:
var best: Dictionary = {}
var best_progress := -1.0
var priority := "shield" if int(tower.type) == 1 else str(tower.get("target_priority", "first"))
var best_progress := -INF if priority != "last" else INF
var best_hp := -1.0
var best_is_shielded := false
var effective_range := _tower_range(tower.type, tower.level)
for enemy in enemies:
if tower.pos.distance_to(enemy.pos) <= effective_range:
if _tower_can_target(tower.type, int(enemy.type)) and tower.pos.distance_to(enemy.pos) <= effective_range:
var route: PackedVector2Array = _enemy_route(enemy)
var progress: float = enemy.path_index * 1000.0 - enemy.pos.distance_to(route[enemy.path_index])
if progress > best_progress:
var enemy_hp := float(enemy.hp)
var is_shielded := bool(enemy.get("shield", false))
var is_better := best.is_empty()
match priority:
"last": is_better = is_better or progress < best_progress
"strongest": is_better = is_better or enemy_hp > best_hp
"shield":
is_better = is_better or (is_shielded and not best_is_shielded)
if is_shielded == best_is_shielded and not best.is_empty():
is_better = progress > best_progress
_:
is_better = is_better or progress > best_progress
if is_better:
best_progress = progress
best_hp = enemy_hp
best_is_shielded = is_shielded
best = enemy
return best
func _tower_priority_label(priority: String) -> String:
match priority:
"shield": return "SCHILD ZUERST"
"last": return "LETZTE"
"strongest": return "MEISTE HP"
_: return "ERSTE"
func _cycle_selected_tower_priority() -> void:
if selected_tower_index < 0 or selected_tower_index >= towers.size():
return
var tower: Dictionary = towers[selected_tower_index]
if int(tower.type) == 1:
_show_status("LASER VISIERT SCHILDGEGNER IMMER ZUERST AN")
return
var priorities := ["first", "last", "strongest", "shield"]
var current := priorities.find(str(tower.get("target_priority", "first")))
tower.target_priority = priorities[(current + 1) % priorities.size()]
_show_status("TURMZIEL: %s" % _tower_priority_label(str(tower.target_priority)))
queue_redraw()
func _fire_tower(tower: Dictionary, target: Dictionary) -> void:
var data: Dictionary = TOWER_DATA[tower.type]
var damage := _tower_damage(tower.type, tower.level)
if _tower_target_class(tower.type) == "both":
damage *= float(data.get("both_damage_scale", 0.7))
if tower.type == 1:
_apply_damage(target, damage, int(tower.get("owner", 0)), tower.type)
beams.append({"from": tower.pos, "to": target.pos, "ttl": 0.09, "color": data.color})
elif tower.type == 5:
var direction: Vector2 = tower.pos.direction_to(target.pos)
var beam_end: Vector2 = tower.pos + direction * _tower_range(tower.type, tower.level)
for enemy in enemies.duplicate():
if not _tower_can_target(tower.type, int(enemy.type)):
continue
var offset: Vector2 = enemy.pos - tower.pos
var along_beam: float = offset.dot(direction)
var across_beam: float = absf(offset.cross(direction))
if along_beam >= 0.0 and along_beam <= _tower_range(tower.type, tower.level) and across_beam <= 18.0:
_apply_damage(enemy, damage, int(tower.get("owner", 0)), tower.type)
beams.append({"from": tower.pos, "to": beam_end, "ttl": 0.14, "color": data.color, "piercing": true})
else:
projectiles.append({
"pos": tower.pos,
@@ -448,9 +605,9 @@ func _update_projectiles(delta: float) -> void:
if shot.pos.distance_to(target.pos) < 14.0:
if shot.type in [2, 3, 4]:
var blast_radius := 76.0 if shot.type == 2 else (92.0 if shot.type == 3 else 118.0)
var splash_factor := 0.45 if shot.type == 2 else (0.62 if shot.type == 3 else 0.38)
var splash_factor := 0.45 if shot.type == 2 else (0.62 if shot.type == 3 else 1.0)
for enemy in enemies.duplicate():
if enemy.pos.distance_to(target.pos) < blast_radius:
if _tower_can_target(int(shot.type), int(enemy.type)) and enemy.pos.distance_to(target.pos) < blast_radius:
_apply_damage(enemy, shot.damage if enemy.id == target.id else shot.damage * splash_factor, int(shot.get("owner", 0)), int(shot.type))
var blast_color: Color = Color("ff9b4a") if shot.type == 2 else (Color("db65ff") if shot.type == 3 else Color("71eaff"))
_spawn_burst(target.pos, blast_color, 14 if shot.type == 2 else 18)
@@ -464,8 +621,19 @@ func _apply_damage(enemy: Dictionary, amount: float, owner := 0, weapon_type :=
if not enemies.has(enemy):
return
var applied_damage := amount
if bool(enemy.get("shield", false)) and weapon_type not in [1, 5]:
applied_damage *= float(enemy.get("shield_resistance", 0.28))
if bool(enemy.get("shield", false)):
if weapon_type == 1:
var shield_hp := float(enemy.get("shield_hp", enemy.max_hp * 0.55))
var shield_damage := minf(shield_hp, amount)
shield_hp -= shield_damage
enemy.shield_hp = shield_hp
if shield_hp <= 0.0:
enemy.shield = false
applied_damage = amount - shield_damage
else:
applied_damage = 0.0
elif weapon_type != 5:
applied_damage *= float(enemy.get("shield_resistance", 0.28))
enemy.hp -= applied_damage
enemy.flash = 0.06
if enemy.hp <= 0.0:
@@ -508,6 +676,10 @@ func _update_effects(delta: float) -> void:
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_F9 and game_mode in ["playing", "paused"]:
developer_panel_open = not developer_panel_open
queue_redraw()
return
if event is InputEventKey and event.pressed and not event.echo and event.keycode in [KEY_ESCAPE, KEY_BACK, KEY_Q]:
if audio_settings_open:
audio_settings_open = false
@@ -573,6 +745,21 @@ func _unhandled_input(event: InputEvent) -> void:
func _handle_pointer(position: Vector2, is_touch := false) -> void:
_play_ui_sound()
if developer_panel_open and game_mode in ["playing", "paused"]:
if developer_strength_down_button.has_point(position):
_set_enemy_strength_multiplier(enemy_strength_multiplier - 0.1)
return
if developer_strength_up_button.has_point(position):
_set_enemy_strength_multiplier(enemy_strength_multiplier + 0.1)
return
if developer_crystals_down_button.has_point(position):
_adjust_core_crystals(-25)
return
if developer_crystals_up_button.has_point(position):
_adjust_core_crystals(25)
return
if developer_panel_rect.has_point(position):
return
if audio_settings_open:
if audio_close_button.has_point(position) or not audio_panel_rect.has_point(position):
audio_settings_open = false
@@ -812,9 +999,10 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void:
if wave_button.has_point(position):
_start_wave()
return
var tower_display_order := _tower_display_order()
for index in tower_buttons.size():
if tower_buttons[index].has_point(position):
var tower_type := index
var tower_type: int = tower_display_order[index]
if not unlocked_towers.has(tower_type):
store_return_mode = game_mode
game_mode = "store"
@@ -827,6 +1015,12 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void:
tutorial_step = 1
return
if selected_tower_index >= 0:
if target_priority_button.has_point(position):
if coop_online_mode:
_show_status("ZIELPRIORITÄT IST IM ONLINE-KOOP NOCH NICHT EINSTELLBAR")
else:
_cycle_selected_tower_priority()
return
if upgrade_button.has_point(position):
_upgrade_selected()
return
@@ -1164,12 +1358,9 @@ func _start_wave() -> void:
_show_status("WELLENSTART AN SERVER GESENDET")
return
wave += 1
var wave_population_scale := 1.0 + maxf(0.0, wave - 3.0) * 0.08
if selected_difficulty == 2:
wave_population_scale += maxf(0.0, wave - 4.0) * 0.025
if endless_mode:
wave_population_scale += maxf(0.0, wave - MAX_WAVES) * 0.035
enemies_to_spawn = ceili((7 + wave * 2) * float(_difficulty().count) * wave_population_scale) * (2 if coop_mode else 1)
enemies_to_spawn = _wave_population(wave)
wave_spawn_index = 0
wave_type_schedule = _build_wave_schedule(wave, enemies_to_spawn)
spawn_timer = 0.15
wave_active = true
if coop_ai_mode:
@@ -1339,7 +1530,7 @@ func _place_tower(position: Vector2) -> void:
return
_spend_credits(0, int(data.cost))
towers_built_this_run += 1
towers.append({"pos": position, "type": selected_tower_type, "level": 1, "cooldown": 0.15, "angle": 0.0, "owner": 0})
towers.append({"pos": position, "type": selected_tower_type, "level": 1, "cooldown": 0.15, "angle": 0.0, "owner": 0, "target_priority": "shield" if selected_tower_type == 1 else "first"})
selected_tower_index = towers.size() - 1
selected_tower_type = -1
pending_touch_build_position = Vector2(-1, -1)
@@ -1611,6 +1802,9 @@ func _apply_online_snapshot(snapshot: Dictionary) -> void:
wave = int(snapshot.get("wave", wave))
wave_active = bool(snapshot.get("wave_active", false))
enemies_to_spawn = int(snapshot.get("enemies_to_spawn", 0))
online_wave_types_remaining.clear()
for enemy_type in snapshot.get("wave_types_remaining", []):
online_wave_types_remaining.append(int(enemy_type))
credits = _player_credits(local_player_slot)
var previous_enemy_positions: Dictionary = {}
@@ -1896,8 +2090,13 @@ func _draw_world() -> void:
draw_circle(shot.pos, 6.0 if shot.type == 2 else 3.5, shot.color)
draw_circle(shot.pos, 12.0 if shot.type == 2 else 7.0, Color(shot.color, 0.20))
for beam in beams:
draw_line(beam.from, beam.to, Color(beam.color, 0.92), 3.0)
draw_line(beam.from, beam.to, Color.WHITE, 1.0)
if bool(beam.get("piercing", false)):
draw_line(beam.from, beam.to, Color("c86bff", 0.18), 18.0)
draw_line(beam.from, beam.to, Color("c86bff", 0.9), 8.0)
draw_line(beam.from, beam.to, Color("f3ddff", 1.0), 2.0)
else:
draw_line(beam.from, beam.to, Color(beam.color, 0.92), 3.0)
draw_line(beam.from, beam.to, Color.WHITE, 1.0)
for particle in particles:
draw_circle(particle.pos, maxf(1.0, particle.ttl * 8.0), Color(particle.color, minf(1.0, particle.ttl * 3.0)))
@@ -2045,22 +2244,17 @@ func _draw_tower(tower: Dictionary) -> void:
func _draw_enemy(enemy: Dictionary) -> void:
var enemy_type := int(enemy.type)
var enemy_texture: Texture2D = ENEMY_TEXTURE
var source: Rect2
var destination: Rect2
var size: float = ENEMY_DATA[enemy.type].size * enemy.size_scale
if enemy_type < 3:
var source_width: float = ENEMY_TEXTURE.get_width() / 3.0
source = Rect2(source_width * enemy_type, 0, source_width, ENEMY_TEXTURE.get_height())
destination = Rect2(-size * 0.5, -size * 0.5, size, size)
else:
enemy_texture = [ENEMY_INTERCEPTOR_TEXTURE, ENEMY_SHIELD_CARRIER_TEXTURE, ENEMY_SIEGE_DESTROYER_TEXTURE][enemy_type - 3]
source = Rect2(Vector2.ZERO, enemy_texture.get_size())
destination = Rect2(-size * 0.75, -size * 0.5, size * 1.5, size)
var enemy_texture := _enemy_texture(enemy_type)
var source := _enemy_source_rect(enemy_type)
destination = Rect2(-size * 0.75, -size * 0.5, size * 1.5, size)
var tint := Color.WHITE if enemy.flash <= 0.0 else Color("ffb6a3")
if bool(enemy.get("shield", false)):
draw_circle(enemy.pos, size * 0.63, Color(0.25, 0.80, 1.0, 0.12))
draw_arc(enemy.pos, size * 0.63, 0.0, TAU, 28, Color(0.38, 0.86, 1.0, 0.76), 2.2)
var shield_ratio := clampf(float(enemy.get("shield_hp", 1.0)) / maxf(1.0, float(enemy.get("max_shield_hp", 1.0))), 0.0, 1.0)
draw_circle(enemy.pos, size * 0.63, Color(0.25, 0.80, 1.0, 0.08))
draw_arc(enemy.pos, size * 0.63, 0.0, TAU, 40, Color(0.28, 0.66, 0.82, 0.28), 2.0)
draw_arc(enemy.pos, size * 0.63, -PI * 0.5, -PI * 0.5 - TAU * shield_ratio, 48, Color(0.38, 0.86, 1.0, 0.92), 3.2)
draw_set_transform(enemy.pos - camera_offset, enemy.angle, Vector2.ONE)
draw_texture_rect_region(enemy_texture, destination, source, tint)
draw_set_transform(-camera_offset, 0.0, Vector2.ONE)
@@ -2074,6 +2268,22 @@ func _draw_enemy(enemy: Dictionary) -> void:
draw_string(ThemeDB.fallback_font, enemy.pos + Vector2(-45, -size * 0.58), "KOMMANDOSCHIFF", HORIZONTAL_ALIGNMENT_CENTER, 90, 11, Color("ffab78"))
func _enemy_texture(enemy_type: int) -> Texture2D:
if enemy_type < 3:
return ENEMY_TEXTURE
if enemy_type < 6:
return [ENEMY_INTERCEPTOR_TEXTURE, ENEMY_SHIELD_CARRIER_TEXTURE, ENEMY_SIEGE_DESTROYER_TEXTURE][enemy_type - 3]
return [ENEMY_GROUND_ROVER_TEXTURE, ENEMY_GROUND_TANK_TEXTURE, ENEMY_GROUND_WALKER_TEXTURE][enemy_type - 6]
func _enemy_source_rect(enemy_type: int) -> Rect2:
var texture := _enemy_texture(enemy_type)
if enemy_type < 3:
var source_width := texture.get_width() / 3.0
return Rect2(source_width * enemy_type, 0.0, source_width, texture.get_height())
return Rect2(Vector2.ZERO, texture.get_size())
func _draw_hud() -> void:
var font := ThemeDB.fallback_font
draw_rect(Rect2(0, 0, 1600, 90), Color(0.015, 0.035, 0.07, 0.92))
@@ -2101,15 +2311,27 @@ func _draw_hud() -> void:
_draw_button(reward_ad_button, "VIDEO: +150 NOTFALLENERGIE", false, Color("285044"))
draw_rect(Rect2(0, 760, 1600, 140), Color(0.012, 0.028, 0.055, 0.97))
draw_line(Vector2(0, 760), Vector2(1600, 760), Color("2e7593"), 2.0)
var tower_display_order := _tower_display_order()
for index in tower_buttons.size():
_draw_tower_card(index, index, selected_tower_type == index)
var tower_type: int = tower_display_order[index]
_draw_tower_card(index, tower_type, selected_tower_type == tower_type)
if selected_tower_index >= 0 and selected_tower_index < towers.size():
var tower: Dictionary = towers[selected_tower_index]
var upgrade_label := "MAX. STUFE" if tower.level >= 3 else "UPGRADE %d" % _upgrade_cost(tower)
_draw_button(upgrade_button, upgrade_label, false)
_draw_button(sell_button, "VERKAUFEN", false, Color("6b3542"))
if not coop_online_mode:
var priority_label := "ZIEL: %s" % _tower_priority_label(str(tower.get("target_priority", "first")))
if int(tower.type) == 1:
priority_label = "ZIEL: SCHILD ZUERST (LASER FIX)"
_draw_button(target_priority_button, priority_label, int(tower.type) == 1, Color("26394a"))
if selected_tower_type >= 0 or selected_tower_index >= 0:
_draw_tower_info_panel()
_draw_wave_preview_panel(wave_current_preview_rect, "JETZT", wave)
var next_wave := wave + 1
if wave >= MAX_WAVES and not endless_mode:
next_wave = 0
_draw_wave_preview_panel(wave_next_preview_rect, "DANN", next_wave)
draw_rect(auto_wave_button, Color("102d40"), true)
draw_rect(auto_wave_button, Color("37677b"), false, 1.0)
draw_rect(Rect2(auto_wave_button.position + Vector2(7, 4), Vector2(14, 14)), Color("07131f"), true)
@@ -2122,11 +2344,99 @@ func _draw_hud() -> void:
draw_rect(option_rect, Color("102d40"), true)
draw_rect(option_rect, Color("37677b"), false, 1.0)
draw_string(font, option_rect.position + Vector2(0, 18), "%.0f×" % SPEED_OPTIONS[speed_index], HORIZONTAL_ALIGNMENT_CENTER, option_rect.size.x, 13, Color("8ff4ff") if is_equal_approx(game_speed, SPEED_OPTIONS[speed_index]) else Color.WHITE)
if developer_panel_open:
draw_rect(developer_panel_rect, Color(0.008, 0.024, 0.045, 0.96), true)
draw_rect(developer_panel_rect, Color("ffad62"), false, 1.5)
draw_string(font, developer_panel_rect.position + Vector2(12, 22), "TESTMODUS • F9 ZUM SCHLIESSEN", HORIZONTAL_ALIGNMENT_LEFT, 490, 11, Color("ffc277"))
_draw_button(developer_strength_down_button, "−", false, Color("44333a"))
draw_string(font, developer_panel_rect.position + Vector2(58, 57), "GEGNER ×%.1f" % enemy_strength_multiplier, HORIZONTAL_ALIGNMENT_CENTER, 128, 13, Color.WHITE)
_draw_button(developer_strength_up_button, "+", false, Color("44333a"))
_draw_button(developer_crystals_down_button, "−25", false, Color("44333a"))
draw_string(font, developer_panel_rect.position + Vector2(310, 57), "KRISTALLE %04d" % core_crystals, HORIZONTAL_ALIGNMENT_CENTER, 136, 13, Color("8ff4ff"))
_draw_button(developer_crystals_up_button, "+25", false, Color("44333a"))
if status_timer > 0.0:
draw_rect(Rect2(560, 108, 480, 44), Color(0.01, 0.035, 0.07, 0.88), true)
draw_string(font, Vector2(570, 138), status_text, HORIZONTAL_ALIGNMENT_CENTER, 460, 18, Color("a9efff"))
func _draw_wave_preview_panel(rect: Rect2, heading: String, for_wave: int) -> void:
var font := ThemeDB.fallback_font
draw_rect(rect, Color(0.008, 0.028, 0.052, 0.90), true)
draw_rect(rect, Color(0.22, 0.54, 0.66, 0.68), false, 1.0)
var summary := _wave_preview_summary(for_wave)
var title := "%s --" % heading if for_wave <= 0 else "%s %02d" % [heading, for_wave]
draw_string(font, rect.position + Vector2(8, 12), title, HORIZONTAL_ALIGNMENT_LEFT, 82, 9, Color("96d8e8"))
draw_string(font, rect.position + Vector2(99, 12), "%d SCHIFFE" % int(summary.total), HORIZONTAL_ALIGNMENT_RIGHT, 150, 9, Color("dceaf0"))
var type_counts: Dictionary = summary.types
var type_order: Array = summary.get("type_order", [])
for category_info in [{"name": "air", "x": 7.0}, {"name": "ground", "x": 133.0}]:
var category: String = category_info.name
var category_x: float = float(category_info.x)
_draw_wave_category_icon(Vector2(rect.position.x + category_x + 7.0, rect.position.y + 32.0), category, Color("78f29a"))
var drawn := 0
for type in type_order:
if _enemy_category(type) != category:
continue
if drawn >= 3:
break
var icon_rect := Rect2(rect.position.x + category_x + 17.0 + drawn * 35.0, rect.position.y + 19.0, 15.0, 13.0)
_draw_enemy_miniature(type, icon_rect)
draw_string(font, icon_rect.position + Vector2(16, 12), "×%d" % int(type_counts[type]), HORIZONTAL_ALIGNMENT_LEFT, 19, 8, Color("dceaf0"))
drawn += 1
func _wave_preview_summary(for_wave: int) -> Dictionary:
var summary := {"total": 0, "air": 0, "ground": 0, "types": {}, "type_order": []}
for enemy_type in _wave_preview_types(for_wave):
var type := int(enemy_type)
var category := _enemy_category(type)
if not summary.types.has(type):
summary.types[type] = 0
summary.type_order.append(type)
summary.types[type] = int(summary.types[type]) + 1
summary[category] = int(summary[category]) + 1
summary.total = int(summary.total) + 1
return summary
func _wave_preview_types(for_wave: int) -> Array[int]:
var result: Array[int] = []
if for_wave <= 0:
return result
if coop_online_mode and for_wave == wave:
for enemy in enemies:
result.append(int(enemy.type))
result.append_array(online_wave_types_remaining)
return result
if for_wave == wave and not wave_type_schedule.is_empty():
for entry in wave_type_schedule:
result.append(int(entry.type))
return result
for entry in _build_wave_schedule(for_wave, _wave_population(for_wave)):
result.append(int(entry.type))
return result
func _draw_wave_category_icon(center: Vector2, category: String, color: Color) -> void:
var direction := 1.0 if category == "air" else -1.0
var arrow := PackedVector2Array([
center + Vector2(0, -7.0 * direction),
center + Vector2(-5, -1.0 * direction),
center + Vector2(-2, -1.0 * direction),
center + Vector2(-2, 5.0 * direction),
center + Vector2(2, 5.0 * direction),
center + Vector2(2, -1.0 * direction),
center + Vector2(5, -1.0 * direction)
])
draw_colored_polygon(arrow, color)
func _draw_enemy_miniature(enemy_type: int, rect: Rect2) -> void:
draw_rect(rect.grow(1.0), Color(0.008, 0.018, 0.03, 0.88), true)
draw_texture_rect_region(_enemy_texture(enemy_type), rect, _enemy_source_rect(enemy_type), Color.WHITE)
draw_rect(rect.grow(1.0), Color("4e8194", 0.75), false, 1.0)
func _speed_option_rect(index: int) -> Rect2:
return Rect2(speed_button.position.x, speed_button.end.y + 2.0 + index * 28.0, speed_button.size.x, 27.0)
@@ -2136,16 +2446,29 @@ func _draw_tower_card(slot: int, type: int, active: bool) -> void:
var data: Dictionary = TOWER_DATA[type]
var font := ThemeDB.fallback_font
var unlocked := unlocked_towers.has(type)
draw_rect(rect, Color("226c88") if active else (Color("173c55") if unlocked else Color("102b3b")), true)
draw_rect(rect, Color("65dfff") if active else (Color("37677b") if unlocked else Color("29485a")), false, 1.5)
var class_color := _tower_class_color(type)
var panel_color := class_color.lightened(0.28) if active else (class_color.darkened(0.42) if unlocked else class_color.darkened(0.62))
draw_rect(rect, panel_color, true)
draw_rect(rect, class_color.lightened(0.30) if active else class_color.darkened(0.18), false, 1.5)
draw_rect(Rect2(rect.position, Vector2(5, rect.size.y)), class_color.lightened(0.25) if unlocked else class_color.darkened(0.15), true)
draw_string(font, rect.position + Vector2(9, 22), data.name, HORIZONTAL_ALIGNMENT_LEFT, 150, 15, Color.WHITE if unlocked else Color("9ab0ba"))
draw_string(font, rect.position + Vector2(158, 22), "%d EN" % data.cost, HORIZONTAL_ALIGNMENT_RIGHT, 86, 12, Color("7ce7ff"))
draw_string(font, rect.position + Vector2(9, 47), "SCH %.0f • %.2f SCHUSS/S" % [data.damage, _tower_shots_per_second(type, 1)], HORIZONTAL_ALIGNMENT_LEFT, 237, 11, Color("dceaf0"))
draw_string(font, rect.position + Vector2(9, 72), "RW %.0f • %s" % [data.range, data.damage_type], HORIZONTAL_ALIGNMENT_LEFT, 237, 10, Color("9fdce9"))
draw_string(font, rect.position + Vector2(9, 72), "RW %.0f • %s" % [data.range, _tower_target_label(type)], HORIZONTAL_ALIGNMENT_LEFT, 237, 10, Color("9fdce9"))
var hint := "ANTIPPEN • BAUPLATZ WÄHLEN" if unlocked else "GESPERRT • IM SHOP FREISCHALTEN"
draw_string(font, rect.position + Vector2(9, 105), hint, HORIZONTAL_ALIGNMENT_LEFT, 237, 10, Color("719aaa") if unlocked else Color("ffcf7a"))
func _tower_class_color(tower_type: int) -> Color:
match _tower_target_class(tower_type):
"air":
return Color("bb5260") # Rot: Luft
"ground":
return Color("668c65") # Grün: Boden
_:
return Color("347b98") # Blau-Cyan: Luft + Boden
func _draw_store() -> void:
var font := ThemeDB.fallback_font
draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.70), true)
@@ -2181,7 +2504,7 @@ func _draw_store() -> void:
draw_texture_rect(textures[slot], Rect2(card.position + Vector2(112, 18), Vector2(136, 136)), false)
draw_string(font, card.position + Vector2(20, 190), data.name, HORIZONTAL_ALIGNMENT_CENTER, 320, 23, Color(data.color))
draw_string(font, card.position + Vector2(22, 224), "SCH %.0f • %.2f SCHUSS/S" % [data.damage, _tower_shots_per_second(tower_type, 1)], HORIZONTAL_ALIGNMENT_CENTER, 316, 15, Color.WHITE)
draw_string(font, card.position + Vector2(22, 251), "RW %.0f • %s" % [data.range, data.damage_type], HORIZONTAL_ALIGNMENT_CENTER, 316, 14, Color("9fdce9"))
draw_string(font, card.position + Vector2(22, 251), "RW %.0f • %s" % [data.range, _tower_target_label(tower_type)], HORIZONTAL_ALIGNMENT_CENTER, 316, 14, Color("9fdce9"))
draw_string(font, card.position + Vector2(22, 295), data.description, HORIZONTAL_ALIGNMENT_CENTER, 316, 14, Color("c4d7df"))
var is_unlocked := unlocked_towers.has(tower_type)
var button_text := "FREIGESCHALTET" if is_unlocked else "FREISCHALTEN • %d KERNKRISTALLE" % int(data.unlock_cost)
@@ -2212,7 +2535,7 @@ func _draw_tower_info_panel() -> void:
elif is_built:
draw_string(font, tower_info_rect.position + Vector2(10, 62), "MAXIMALE AUSBAUSTUFE", HORIZONTAL_ALIGNMENT_LEFT, 280, 9, Color("84ffca"))
else:
draw_string(font, tower_info_rect.position + Vector2(10, 62), str(data.damage_type), HORIZONTAL_ALIGNMENT_LEFT, 280, 9, Color("84ffca"))
draw_string(font, tower_info_rect.position + Vector2(10, 62), "%s • %s" % [_tower_target_label(type), str(data.damage_type)], HORIZONTAL_ALIGNMENT_LEFT, 280, 9, Color("84ffca"))
func _draw_button(rect: Rect2, text: String, active: bool, base := Color("173c55")) -> void:
+20 -1
View File
@@ -2,11 +2,30 @@ param(
[Parameter(Mandatory = $true)]
[string]$GodotExe,
[Parameter(Mandatory = $true)]
[string]$MakeNsisExe
)
$ErrorActionPreference = "Stop"
$nsisCandidates = @()
if (-not [string]::IsNullOrWhiteSpace($MakeNsisExe)) {
$nsisCandidates += $MakeNsisExe
} else {
$pathNsis = Get-Command "makensis.exe" -ErrorAction SilentlyContinue
if ($pathNsis) {
$nsisCandidates += $pathNsis.Source
}
$nsisCandidates += @(
(Join-Path $env:LOCALAPPDATA "Programs\NSIS-3.13\makensis.exe"),
"C:\Program Files (x86)\NSIS\makensis.exe",
"C:\Program Files\NSIS\makensis.exe"
)
$MakeNsisExe = $nsisCandidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
}
if ([string]::IsNullOrWhiteSpace($MakeNsisExe)) {
throw "NSIS wurde nicht gefunden. Installiere NSIS oder übergib -MakeNsisExe mit dem Pfad zu makensis.exe."
}
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
$gameExe = Join-Path $projectRoot "build\windows\Void-Bastion.exe"
$installer = Join-Path $projectRoot "dist\Void-Bastion-Setup-x64.exe"
+2 -2
View File
@@ -6,7 +6,7 @@ RequestExecutionLevel user
!include "LogicLib.nsh"
!define PRODUCT_NAME "Void Bastion"
!define PRODUCT_VERSION "0.1.0-alpha.5"
!define PRODUCT_VERSION "0.1.0-alpha.7"
!define PRODUCT_PUBLISHER "GerGames"
!define PRODUCT_WEBSITE "https://void-bastion.gergames.de/"
!define PRODUCT_EXE "Void-Bastion.exe"
@@ -21,7 +21,7 @@ BrandingText "GerGames"
ShowInstDetails show
ShowUninstDetails show
VIProductVersion "0.1.0.5"
VIProductVersion "0.1.0.7"
VIAddVersionKey /LANG=1031 "ProductName" "${PRODUCT_NAME}"
VIAddVersionKey /LANG=1031 "CompanyName" "${PRODUCT_PUBLISHER}"
VIAddVersionKey /LANG=1031 "FileDescription" "${PRODUCT_NAME} Setup"
+1 -2
View File
@@ -15,7 +15,7 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
[application]
config/name="Void Bastion"
config/version="0.1.0-alpha.5"
config/version="0.1.0-alpha.7"
run/main_scene="res://main.tscn"
config/features=PackedStringArray("4.7")
config/icon="res://assets/app_icon.png"
@@ -43,5 +43,4 @@ enabled=PackedStringArray("res://addons/agent_tools/plugin.cfg", "res://addons/A
[rendering]
renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="mobile"
textures/vram_compression/import_etc2_astc=true
+88 -22
View File
@@ -17,14 +17,24 @@ var RIGHT_PATH = [];
for (var pathIndex = 0; pathIndex < LEFT_PATH.length; pathIndex++) RIGHT_PATH.push([WORLD_WIDTH - LEFT_PATH[pathIndex][0], LEFT_PATH[pathIndex][1]]);
var PATHS = [LEFT_PATH, RIGHT_PATH];
var TOWERS = [
{cost:120,damage:22,range:190,cooldown:0.55},
{cost:180,damage:12,range:165,cooldown:0.18},
{cost:250,damage:65,range:260,cooldown:1.40},
{cost:230,damage:48,range:240,cooldown:1.05},
{cost:210,damage:34,range:215,cooldown:0.92},
{cost:300,damage:110,range:330,cooldown:1.85}
{cost:120,damage:22,range:190,cooldown:0.55,targetClass:"ground"},
{cost:180,damage:12,range:165,cooldown:0.18,targetClass:"both",bothScale:0.82},
{cost:250,damage:65,range:260,cooldown:1.40,targetClass:"ground"},
{cost:230,damage:34,range:240,cooldown:1.05,targetClass:"both",bothScale:0.72},
{cost:210,damage:20,range:215,cooldown:0.92,targetClass:"both",bothScale:1},
{cost:300,damage:77,range:330,cooldown:1.85,targetClass:"both",bothScale:0.70}
];
var ENEMIES = [
{category:"air",hp:70,speed:105,reward:18,breachDamage:1},
{category:"air",hp:185,speed:72,reward:34,breachDamage:2},
{category:"air",hp:520,speed:43,reward:78,breachDamage:3},
{category:"air",hp:52,speed:178,reward:22,breachDamage:2},
{category:"air",hp:260,speed:58,reward:52,breachDamage:2},
{category:"air",hp:740,speed:48,reward:108,breachDamage:4},
{category:"ground",hp:88,speed:142,reward:22,breachDamage:1},
{category:"ground",hp:390,speed:61,reward:61,breachDamage:3},
{category:"ground",hp:980,speed:36,reward:132,breachDamage:5}
];
var ENEMIES = [{hp:70,speed:105,reward:18},{hp:185,speed:72,reward:34},{hp:520,speed:43,reward:78}];
var DIFFICULTIES = [
{hp:1,speed:1,count:1,income:1,spawnDelay:1,startCredits:450},
{hp:1.32,speed:1.07,count:1.24,income:0.94,spawnDelay:0.91,startCredits:430},
@@ -231,7 +241,7 @@ function matchmakerMatched(ctx, logger, nk, matches) {
function initialState(params) {
var difficulty = Math.max(0,Math.min(2,Math.floor(Number(params.difficulty||1))));
var credits = DIFFICULTIES[difficulty].startCredits;
return {presences:{},slots:{},players:{},ready:{},started:false,pausedForReconnect:false,reconnectDeadlineTick:0,gameOver:false,difficulty:difficulty,mode:String(params.mode||"quick"),roomCode:normalizeRoomCode(params.roomCode),hostUserId:String(params.hostUserId||""),emptyTicks:0,credits:[credits,credits],reactorLives:30,wave:0,waveActive:false,spawnRemaining:0,spawnTimer:0,nextSpawnRoute:0,nextEnemyId:1,nextTowerId:1,towers:[],enemies:[],lastSnapshotTick:0};
return {presences:{},slots:{},players:{},ready:{},started:false,pausedForReconnect:false,reconnectDeadlineTick:0,gameOver:false,difficulty:difficulty,mode:String(params.mode||"quick"),roomCode:normalizeRoomCode(params.roomCode),hostUserId:String(params.hostUserId||""),emptyTicks:0,credits:[credits,credits],reactorLives:30,wave:0,waveActive:false,spawnRemaining:0,waveSchedule:[],spawnTimer:0,nextSpawnRoute:0,nextEnemyId:1,nextTowerId:1,towers:[],enemies:[],lastSnapshotTick:0};
}
function matchLabel(state) { return state.mode === "private" ? state.roomCode : JSON.stringify({game:"void_bastion",mode:state.mode,open:!state.started&&!state.gameOver}); }
function matchInit(ctx,logger,nk,params) { var state=initialState(params||{}); return {state:state,tickRate:TICK_RATE,label:matchLabel(state)}; }
@@ -247,7 +257,7 @@ function snapshot(state,tick) {
var id=ids[i],slot=state.slots[id],player=state.players[id]||{};
lobby[slot]={user_id:id,slot:slot,name:String(player.name||"PILOT"),ready:!!state.ready[id],connected:state.presences[id]!==undefined};
}
return {version:GAME_VERSION,tick:tick,started:state.started,connection_interrupted:state.pausedForReconnect,reconnect_seconds:state.pausedForReconnect?Math.max(0,Math.ceil((state.reconnectDeadlineTick-tick)/TICK_RATE)):0,game_over:state.gameOver,difficulty:state.difficulty,slots:state.slots,players:lobby,credits:state.credits,reactor_lives:state.reactorLives,wave:state.wave,wave_active:state.waveActive,enemies_to_spawn:state.spawnRemaining,towers:state.towers,enemies:state.enemies};
return {version:GAME_VERSION, tick:tick,started:state.started,connection_interrupted:state.pausedForReconnect,reconnect_seconds:state.pausedForReconnect?Math.max(0,Math.ceil((state.reconnectDeadlineTick-tick)/TICK_RATE)):0,game_over:state.gameOver,difficulty:state.difficulty,slots:state.slots,players:lobby,credits:state.credits,reactor_lives:state.reactorLives,wave:state.wave,wave_active:state.waveActive,enemies_to_spawn:state.spawnRemaining,wave_types_remaining:state.waveSchedule.map(function(entry){return entry.type;}),towers:state.towers,enemies:state.enemies};
}
function broadcastSnapshot(dispatcher,state,tick) { dispatcher.broadcastMessage(OP_SNAPSHOT,JSON.stringify(snapshot(state,tick)),null,null,true); state.lastSnapshotTick=tick; }
function broadcastEvent(dispatcher,type,data) { var payload=data||{}; payload.type=type; dispatcher.broadcastMessage(OP_EVENT,JSON.stringify(payload),null,null,true); }
@@ -310,16 +320,58 @@ function handleSell(state,slot,payload) {
var i=ownedTowerIndex(state,slot,Math.floor(Number(payload.tower_id))); if (i<0) return false;
var tower=state.towers[i]; state.credits[slot]+=Math.floor(TOWERS[tower.type].cost*(0.55+tower.level*0.12)); state.towers.splice(i,1); return true;
}
function enemyTypeForWave(wave,roll,difficulty,boss) {
if (boss) return wave>=10?8:5;
var offset=difficulty===2?2:(difficulty===1?1:0);
if (wave>=10&&roll>=0.96) return 8;
if (wave>=8&&roll>=0.84&&roll<0.94) return 7;
if (wave>=3&&roll>=0.52&&roll<0.64) return 6;
if (wave>=10-offset&&roll>=0.94) return 5;
if (wave>=7-offset&&roll>=0.80) return 4;
if (wave>=4-offset&&roll>=0.65) return 3;
if (wave>=7&&roll>=0.45) return 2;
if (wave>=3&&roll>=0.28) return 1;
return 0;
}
function wavePopulation(state,wave) {
var scale=1+Math.max(0,wave-3)*0.08;
if (state.difficulty===2) scale+=Math.max(0,wave-4)*0.025;
return Math.ceil((7+wave*2)*DIFFICULTIES[state.difficulty].count*scale)*2;
}
function buildWaveSchedule(state,wave,count) {
var schedule=[],bossCount=wave>0&&wave%5===0?2:0;
for (var i=0;i<count;i++) {
var boss=i>=count-bossCount;
var roll=((i+1)*0.61803398875+wave*0.38196601125)%1;
schedule.push({type:enemyTypeForWave(wave,roll,state.difficulty,boss),boss:boss});
}
return schedule;
}
function startWave(state) {
if (state.waveActive||state.gameOver||!state.started) return false;
state.wave+=1; state.spawnRemaining=Math.ceil((7+state.wave*2)*DIFFICULTIES[state.difficulty].count)*2; state.spawnTimer=0.1; state.waveActive=true; return true;
state.wave+=1; state.waveSchedule=buildWaveSchedule(state,state.wave,wavePopulation(state,state.wave)); state.spawnRemaining=state.waveSchedule.length; state.spawnTimer=0.1; state.waveActive=true; return true;
}
function spawnEnemy(state) {
var boss=state.wave>0&&state.wave%5===0&&state.spawnRemaining<=2,roll=Math.random();
var type=boss?2:(state.wave>=7&&roll>0.82?2:(state.wave>=3&&roll>0.53?1:0));
var base=ENEMIES[type],d=DIFFICULTIES[state.difficulty],scale=(1+Math.max(0,state.wave-1)*0.115)*(boss?3.25:1)*d.hp;
var entry=state.waveSchedule.length?state.waveSchedule.shift():{type:0,boss:false};
var boss=entry.boss,type=entry.type;
var base=ENEMIES[type],d=DIFFICULTIES[state.difficulty];
var scale=(1+Math.max(0,state.wave-1)*0.20+Math.max(0,state.wave-3)*0.09+((state.difficulty===2)?Math.max(0,state.wave-5)*0.055:0))*(boss?3.25:1)*d.hp;
var routeId=state.nextSpawnRoute; state.nextSpawnRoute=(state.nextSpawnRoute+1)%2; var route=PATHS[routeId];
state.enemies.push({id:state.nextEnemyId++,type:type,x:route[0][0],y:route[0][1],path_index:1,route_id:routeId,hp:base.hp*scale,max_hp:base.hp*scale,speed:base.speed*(1+state.wave*0.012)*(boss?0.82:1)*d.speed,reward:Math.floor((base.reward+state.wave)*(boss?4:1)*d.income),angle:0,boss:boss});
var hasShield=type===4||(state.wave>=4&&Math.random()<Math.min(0.42,0.10+state.wave*0.018+state.difficulty*0.06));
var maxShieldHp=hasShield?base.hp*scale*0.55:0;
state.enemies.push({id:state.nextEnemyId++,type:type,x:route[0][0],y:route[0][1],path_index:1,route_id:routeId,hp:base.hp*scale,max_hp:base.hp*scale,speed:base.speed*(1+state.wave*0.012)*(boss?0.82:1)*d.speed,reward:Math.floor((base.reward+state.wave)*(boss?4:1)*d.income),angle:0,boss:boss,shield:hasShield,shield_hp:maxShieldHp,max_shield_hp:maxShieldHp,shield_resistance:type===4?0.18:0.28});
}
function applyDamageToEnemy(enemy,damage,towerType) {
var applied=damage;
if (enemy.shield) {
if (towerType===1) {
var shieldDamage=Math.min(enemy.shield_hp,damage);
enemy.shield_hp-=shieldDamage;
if (enemy.shield_hp<=0) { enemy.shield=false; applied=damage-shieldDamage; }
else applied=0;
} else if (towerType!==5) applied*=enemy.shield_resistance||0.28;
}
enemy.hp-=applied;
}
function removeEnemy(state,index,owner) { state.credits[owner]+=state.enemies[index].reward; state.enemies.splice(index,1); }
function updateEnemies(state,delta) {
@@ -328,24 +380,38 @@ function updateEnemies(state,delta) {
e.angle=Math.atan2(dy,dx);
if (dist<=Math.max(9,e.speed*delta)) {
e.x=target[0]; e.y=target[1]; e.path_index+=1;
if (e.path_index>=route.length) { state.reactorLives=Math.max(0,state.reactorLives-(1+e.type+(e.boss?4:0))); state.enemies.splice(i,1); if (state.reactorLives<=0) { state.gameOver=true; state.waveActive=false; state.spawnRemaining=0; } }
if (e.path_index>=route.length) { state.reactorLives=Math.max(0,state.reactorLives-(ENEMIES[e.type].breachDamage+(e.boss?4:0))); state.enemies.splice(i,1); if (state.reactorLives<=0) { state.gameOver=true; state.waveActive=false; state.spawnRemaining=0; } }
} else { e.x+=dx/dist*e.speed*delta; e.y+=dy/dist*e.speed*delta; }
}
}
function enemyProgress(e) { return e.path_index*100000-distanceSquared(e.x,e.y,REACTOR_X,REACTOR_Y); }
function updateTowers(state,delta) {
for (var i=0;i<state.towers.length;i++) {
var t=state.towers[i]; t.cooldown-=delta; var range=towerRange(t),targetIndex=-1,best=-1;
for (var j=0;j<state.enemies.length;j++) if (distanceSquared(t.x,t.y,state.enemies[j].x,state.enemies[j].y)<=range*range) { var p=enemyProgress(state.enemies[j]); if (p>best) { best=p; targetIndex=j; } }
var t=state.towers[i]; t.cooldown-=delta; var range=towerRange(t),targetIndex=-1,best=-1,bestShield=false;
for (var j=0;j<state.enemies.length;j++) {
var candidate=state.enemies[j];
if ((TOWERS[t.type].targetClass==="both"||TOWERS[t.type].targetClass===ENEMIES[candidate.type].category)&&distanceSquared(t.x,t.y,candidate.x,candidate.y)<=range*range) {
var p=enemyProgress(candidate),shieldPriority=t.type===1&&candidate.shield===true;
if (targetIndex<0||(shieldPriority&&!bestShield)||(shieldPriority===bestShield&&p>best)) { best=p; bestShield=shieldPriority; targetIndex=j; }
}
}
if (targetIndex<0) continue;
var target=state.enemies[targetIndex]; t.angle=Math.atan2(target.y-t.y,target.x-t.x)+([0,3,5].indexOf(t.type)>=0?Math.PI:0);
if (t.cooldown>0) continue;
var damage=towerDamage(t);
if (t.type===2||t.type===3||t.type===4) {
var radius=t.type===2?76:(t.type===3?92:118),splash=t.type===2?0.45:(t.type===3?0.62:0.38);
var damage=towerDamage(t)*(TOWERS[t.type].targetClass==="both"?TOWERS[t.type].bothScale:1);
if (t.type===5) {
var aimLength=Math.sqrt(distanceSquared(t.x,t.y,target.x,target.y)),dx=(target.x-t.x)/aimLength,dy=(target.y-t.y)/aimLength,rangeSquared=range*range;
for (var railIndex=state.enemies.length-1;railIndex>=0;railIndex--) {
var railTarget=state.enemies[railIndex];
if (distanceSquared(t.x,t.y,railTarget.x,railTarget.y)>rangeSquared) continue;
var offsetX=railTarget.x-t.x,offsetY=railTarget.y-t.y,along=offsetX*dx+offsetY*dy,across=Math.abs(offsetX*dy-offsetY*dx);
if (along>=0&&along<=range&&across<=18) { applyDamageToEnemy(railTarget,damage,t.type); if (railTarget.hp<=0) removeEnemy(state,railIndex,t.owner); }
}
} else if (t.type===2||t.type===3||t.type===4) {
var radius=t.type===2?76:(t.type===3?92:118),splash=t.type===2?0.45:(t.type===3?0.62:1);
var tx=target.x,ty=target.y,targetId=target.id;
for (var k=state.enemies.length-1;k>=0;k--) if (distanceSquared(state.enemies[k].x,state.enemies[k].y,tx,ty)<=radius*radius) { state.enemies[k].hp-=state.enemies[k].id===targetId?damage:damage*splash; if (state.enemies[k].hp<=0) removeEnemy(state,k,t.owner); }
} else { target.hp-=damage; if (target.hp<=0) removeEnemy(state,targetIndex,t.owner); }
for (var k=state.enemies.length-1;k>=0;k--) if ((TOWERS[t.type].targetClass==="both"||TOWERS[t.type].targetClass===ENEMIES[state.enemies[k].type].category)&&distanceSquared(state.enemies[k].x,state.enemies[k].y,tx,ty)<=radius*radius) { applyDamageToEnemy(state.enemies[k],state.enemies[k].id===targetId?damage:damage*splash,t.type); if (state.enemies[k].hp<=0) removeEnemy(state,k,t.owner); }
} else { applyDamageToEnemy(target,damage,t.type); if (target.hp<=0) removeEnemy(state,targetIndex,t.owner); }
t.cooldown=towerCooldown(t);
}
}
+59
View File
@@ -8,6 +8,62 @@ const context = { console, Math, Date, JSON, isFinite };
vm.createContext(context);
vm.runInContext(source, context);
assert.strictEqual(context.ENEMIES.length, 9);
assert.strictEqual(context.ENEMIES[0].category, "air");
assert.strictEqual(context.ENEMIES[6].category, "ground");
assert.strictEqual(context.TOWERS[0].targetClass, "ground");
assert.strictEqual(context.TOWERS[1].targetClass, "both");
assert.ok(context.TOWERS[1].bothScale < 1, "The all-purpose laser should trade damage for shield-breaking ground coverage.");
assert.strictEqual(context.TOWERS[3].targetClass, "both");
assert.ok(context.TOWERS[3].bothScale < 1, "Universal towers should trade damage for flexibility.");
assert.strictEqual(context.TOWERS[4].damage, 20, "EMP damage should be tuned down to about 20.");
assert.strictEqual(context.TOWERS[4].targetClass, "both", "EMP should cover both air and ground units.");
const shieldTest = { hp: 100, shield: true, shield_hp: 30, max_shield_hp: 30, shield_resistance: 0.28 };
context.applyDamageToEnemy(shieldTest, 20, 1);
assert.strictEqual(shieldTest.hp, 100, "Laser damage should drain shield before hull.");
assert.strictEqual(shieldTest.shield_hp, 10);
context.applyDamageToEnemy(shieldTest, 20, 1);
assert.strictEqual(shieldTest.shield, false);
assert.strictEqual(shieldTest.hp, 90, "Laser damage beyond shield capacity should spill into hull.");
const priorityState = {
towers: [{ id: 1, type: 1, x: 800, y: 120, owner: 0, level: 1, cooldown: 0 }],
enemies: [
{ id: 201, type: 6, x: 850, y: 120, path_index: 1, hp: 100, shield: true, shield_hp: 50, max_shield_hp: 50, shield_resistance: 0.28 },
{ id: 202, type: 0, x: 950, y: 120, path_index: 1, hp: 100, shield: false }
],
credits: [0, 0], wave: 1
};
context.updateTowers(priorityState, 1);
assert.ok(priorityState.enemies[0].shield_hp < 50, "Online lasers should drain a shield before firing at unshielded units.");
assert.strictEqual(priorityState.enemies[1].hp, 100);
const railgunState = {
towers: [{ id: 10, type: 5, x: 300, y: 120, owner: 0, level: 1, cooldown: 0 }],
enemies: [
{ id: 301, type: 0, x: 400, y: 120, path_index: 1, hp: 1000, shield: false },
{ id: 302, type: 6, x: 500, y: 120, path_index: 1, hp: 1000, shield: false },
{ id: 303, type: 0, x: 450, y: 150, path_index: 1, hp: 1000, shield: false }
],
credits: [0, 0], wave: 1
};
context.updateTowers(railgunState, 0.01);
assert.ok(railgunState.enemies[0].hp < 1000 && railgunState.enemies[1].hp < 1000, "Online railgun should pierce enemies behind its first hit on the same line.");
assert.strictEqual(railgunState.enemies[2].hp, 1000, "Online railgun should not hit units away from its beam line.");
const empState = {
towers: [{ id: 11, type: 4, x: 300, y: 120, owner: 0, level: 1, cooldown: 0 }],
enemies: [
{ id: 401, type: 0, x: 400, y: 120, path_index: 1, hp: 1000, shield: false },
{ id: 402, type: 6, x: 450, y: 120, path_index: 1, hp: 1000, shield: false },
{ id: 403, type: 6, x: 570, y: 120, path_index: 1, hp: 1000, shield: false }
],
credits: [0, 0], wave: 1
};
context.updateTowers(empState, 0.01);
assert.strictEqual(empState.enemies[0].hp, 980, "EMP should deal 20 damage to its primary target.");
assert.strictEqual(empState.enemies[1].hp, 980, "EMP should deal its reduced 20 damage to nearby ground units too.");
assert.strictEqual(empState.enemies[2].hp, 1000, "EMP should not damage units outside its area radius.");
const sent = [];
const dispatcher = {
broadcastMessage(opCode, data) {
@@ -132,6 +188,9 @@ assert.strictEqual(state.credits[1], 250);
state = context.matchLoop({}, logger, nk, dispatcher, 6, state, [message(presenceA, 13, {})]).state;
assert.strictEqual(state.wave, 1);
assert.strictEqual(state.waveActive, true);
assert.strictEqual(context.snapshot(state, 6).wave_types_remaining.length, state.spawnRemaining);
const groundSchedule = context.buildWaveSchedule(state, 3, context.wavePopulation(state, 3));
assert.ok(groundSchedule.some(entry => context.ENEMIES[entry.type].category === "ground"), "Ground units should enter the wave schedule from wave 3.");
for (let tick = 7; tick < 30; tick++) state = context.matchLoop({}, logger, nk, dispatcher, tick, state, []).state;
assert.ok(state.enemies.length > 0 || state.spawnRemaining > 0);
assert.ok(sent.some(entry => entry.opCode === 1 && entry.data.started === true));
+76 -10
View File
@@ -29,6 +29,12 @@ func _run() -> void:
var ads_manager = game.get_node("AdsManager")
var account_manager = game.get_node("AccountManager")
var multiplayer_manager = game.get_node("MultiplayerManager")
game.set("core_crystals", 0)
game.call("_adjust_core_crystals", 25)
assert(game.get("core_crystals") == 25, "Der Entwicklermodus muss Testkristalle hinzufügen können.")
game.call("_adjust_core_crystals", -25)
game.call("_adjust_core_crystals", -25)
assert(game.get("core_crystals") == 0, "Der Entwicklermodus muss Kristalle abziehen können, ohne unter null zu fallen.")
assert(game.get("MAP_NAMES").size() == 7, "Es müssen sieben Karten vorhanden sein.")
assert(game.get("MAP_TEXTURES").size() == 7, "Jede Karte benötigt einen eigenen Hintergrund.")
assert(game.get("map_buttons").size() == 7, "Alle sieben Kartenvorschauen müssen gleichzeitig auswählbar sein.")
@@ -135,10 +141,10 @@ func _run() -> void:
assert(game.get("game_mode") == "menu", "Die Bestenliste benötigt einen funktionierenden Zurück-Button.")
assert(not account_manager.player_id.is_empty(), "Beim ersten Start muss automatisch ein stabiles Gastprofil angelegt werden.")
assert(not account_manager.display_name.is_empty(), "Das Gastprofil benötigt einen sichtbaren Pilotennamen.")
assert(game.call("_app_version") == "0.1.0-alpha.5", "Profil, Installer und Online-Übertragung müssen dieselbe Alpha-Version verwenden.")
assert(game.call("_app_version") == "0.1.0-alpha.7", "Profil, Installer und Online-Übertragung müssen dieselbe Alpha-Version verwenden.")
var support_url: String = game.call("_support_email_url")
assert(support_url.begins_with("mailto:support@gergames.de?"), "Die Feedback-Schaltfläche muss die öffentliche Supportadresse verwenden.")
assert(support_url.contains("0.1.0-alpha.5") and support_url.contains(account_manager.short_id()), "Die Support-Mail muss Version und gekürzte Spieler-ID zur Fehlerzuordnung enthalten.")
assert(support_url.contains("0.1.0-alpha.7") and support_url.contains(account_manager.short_id()), "Die Support-Mail muss Version und gekürzte Spieler-ID zur Fehlerzuordnung enthalten.")
account_manager.storage_enabled = false
var original_display_name: String = account_manager.display_name
assert(account_manager.validate_display_name("Kommandant Ägir").is_empty(), "Ein normaler internationaler Spielername muss gültig sein.")
@@ -152,14 +158,14 @@ func _run() -> void:
assert(account_manager.get_map_best_wave(0, false) >= 3, "Die Profilansicht muss den gespeicherten Missionsbestwert lesen können.")
assert(not ads_manager.mobile_ads_supported, "Auf Windows müssen Anzeigen vollständig deaktiviert bleiben.")
var update_manager = game.get_node("UpdateManager")
assert(update_manager.call("_is_newer_version", "0.1.0-alpha.5", "0.1.0-alpha.4"), "Eine neuere Alpha-Version muss als Update erkannt werden.")
assert(update_manager.call("_is_newer_version", "0.1.0-alpha.6", "0.1.0-alpha.5"), "Eine neuere Alpha-Version muss als Update erkannt werden.")
assert(update_manager.call("_is_newer_version", "0.1.0", "0.1.0-alpha.2"), "Eine stabile Version muss neuer als ihre Alpha-Version sein.")
assert(not update_manager.call("_is_newer_version", "0.1.0-alpha.5", "0.1.0-alpha.5"), "Die installierte Version darf nicht als Update angeboten werden.")
assert(not update_manager.call("_is_newer_version", "0.1.0-alpha.6", "0.1.0-alpha.6"), "Die installierte Version darf nicht als Update angeboten werden.")
var announced_update := {}
update_manager.update_available.connect(func(info: Dictionary): announced_update.merge(info, true))
var fake_release_body := JSON.stringify([{"tag_name": "v0.1.0-alpha.6", "name": "Alpha.6", "draft": false, "assets": [{"name": "Void-Bastion-Setup-x64.exe", "browser_download_url": "https://git.dominicspringer.de/public-test.exe"}]}]).to_utf8_buffer()
var fake_release_body := JSON.stringify([{"tag_name": "v0.1.0-alpha.8", "name": "Alpha.8", "draft": false, "assets": [{"name": "Void-Bastion-Setup-x64.exe", "browser_download_url": "https://git.dominicspringer.de/public-test.exe"}]}]).to_utf8_buffer()
update_manager.call("_on_request_completed", HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), fake_release_body)
assert(announced_update.get("version") == "0.1.0-alpha.6", "Die Update-Prüfung muss eine neuere öffentliche Version ankündigen.")
assert(announced_update.get("version") == "0.1.0-alpha.8", "Die Update-Prüfung muss eine neuere öffentliche Version ankündigen.")
assert(str(announced_update.get("url", "")).begins_with("https://git.dominicspringer.de/"), "Der Update-Hinweis darf nur einen öffentlichen Download-Link öffnen.")
assert(game.get("path_points").size() == 9, "Bastion Prime sollte mit einer eigenen Einfahrt im Reaktor enden.")
_assert_grid_aligned_path(game.get("path_points"), "Bastion Prime")
@@ -266,7 +272,53 @@ func _run() -> void:
game.call("_start_game")
assert(game.get("credits") == 340 and game.get("reactor_lives") == 12, "Schwer muss mit knapperen Startressourcen und weniger Reaktorleben beginnen.")
assert(int(game.call("_difficulty").crystals) == 12, "Ein Sieg auf Schwer muss den höheren Kristallbonus gewähren.")
assert(game.get("ENEMY_DATA").size() == 6, "Drei zusätzliche Gegnerrollen müssen im Spiel verfügbar sein.")
assert(game.get("ENEMY_DATA").size() == 9, "Drei zusätzliche Gegnerrollen müssen im Spiel verfügbar sein.")
assert(game.call("_enemy_category", 0) == "air" and game.call("_enemy_category", 6) == "ground", "Luft- und Bodeneinheiten müssen eindeutig klassifiziert sein.")
assert(not game.call("_tower_can_target", 0, 0) and game.call("_tower_can_target", 1, 0) and game.call("_tower_can_target", 1, 6) and game.call("_tower_can_target", 5, 6), "Kinetik muss auf Boden beschränkt sein; Laser und Railgun dürfen beide Kategorien angreifen.")
assert(game.call("_enemy_texture", 6).get_width() > 0 and game.call("_enemy_texture", 8).get_height() > 0, "Bodeneinheiten benötigen eigene lesbare Grafiken.")
assert(game.get("TOWER_DATA")[0].target_class == "ground" and game.get("TOWER_DATA")[1].target_class == "both", "Der Laser muss neben Luft- auch Bodenziele angreifen können.")
assert(game.get("TOWER_DATA")[1].both_damage_scale < 1.0 and game.get("TOWER_DATA")[3].both_damage_scale < 1.0 and game.get("TOWER_DATA")[5].both_damage_scale < 1.0, "Laser, Plasma und Railgun müssen für ihre Mehrzweckfähigkeit einen Schadensmultiplikator erhalten.")
assert(game.get("TOWER_DATA")[4].damage == 20.0 and game.get("TOWER_DATA")[4].target_class == "both", "EMP muss Luft- und Bodenziele mit rund 20 Schaden erfassen.")
assert(game.call("_tower_display_order") == [0, 2, 1, 3, 4, 5], "Die Turmauswahl muss zuerst Luft-, dann Boden- und anschließend Mehrzwecktürme gruppieren.")
assert(game.call("_tower_class_color", 0).g > game.call("_tower_class_color", 0).r and game.call("_tower_class_color", 1).b > game.call("_tower_class_color", 1).r, "Bodenfelder müssen grün und Mehrzweckfelder blau-cyan eingefärbt sein.")
game.get("enemies").clear()
var rail_front := {"id": 96001, "type": 0, "pos": Vector2(400, 300), "hp": 1000.0, "max_hp": 1000.0, "flash": 0.0, "shield": false, "reward": 1}
var rail_behind := {"id": 96002, "type": 6, "pos": Vector2(500, 300), "hp": 1000.0, "max_hp": 1000.0, "flash": 0.0, "shield": false, "reward": 1}
var rail_offline := {"id": 96003, "type": 0, "pos": Vector2(450, 326), "hp": 1000.0, "max_hp": 1000.0, "flash": 0.0, "shield": false, "reward": 1}
game.get("enemies").append_array([rail_front, rail_behind, rail_offline])
game.call("_fire_tower", {"type": 5, "level": 1, "pos": Vector2(300, 300), "owner": 0}, rail_front)
assert(rail_front.hp < 1000.0 and rail_behind.hp < 1000.0 and is_equal_approx(rail_offline.hp, 1000.0), "Der Railgun-Strahl muss Gegner hinter dem Ziel auf der Linie durchschlagen, aber keine Ziele neben der Linie treffen.")
assert(game.get("beams")[-1].piercing and game.get("beams")[-1].color == Color("c86bff"), "Der durchschlagende Railgun-Strahl muss sichtbar lila sein.")
game.get("enemies").clear()
game.get("projectiles").clear()
var emp_air := {"id": 96101, "type": 0, "pos": Vector2(500, 500), "hp": 1000.0, "max_hp": 1000.0, "flash": 0.0, "shield": false, "reward": 1}
var emp_ground := {"id": 96102, "type": 6, "pos": Vector2(550, 500), "hp": 1000.0, "max_hp": 1000.0, "flash": 0.0, "shield": false, "reward": 1}
var emp_outside := {"id": 96103, "type": 6, "pos": Vector2(700, 500), "hp": 1000.0, "max_hp": 1000.0, "flash": 0.0, "shield": false, "reward": 1}
game.get("enemies").append_array([emp_air, emp_ground, emp_outside])
game.call("_fire_tower", {"type": 4, "level": 1, "pos": Vector2(400, 500), "owner": 0}, emp_air)
game.get("projectiles")[-1].speed = 0.0
game.get("projectiles")[-1].pos = emp_air.pos
game.call("_update_projectiles", 0.01)
assert(is_equal_approx(emp_air.hp, 980.0) and is_equal_approx(emp_ground.hp, 980.0) and is_equal_approx(emp_outside.hp, 1000.0), "Der EMP muss Luft und Boden im 118er-Radius jeweils um 20 treffen und Gegner außerhalb verschonen.")
game.get("enemies").clear()
game.set("path_points", PackedVector2Array([Vector2(0, 0), Vector2(1000, 0), Vector2(1600, 0)]))
game.get("enemies").clear()
var shield_target := {"id": 201, "type": 6, "pos": Vector2(850, 0), "path_index": 1, "hp": 100.0, "shield": true}
var unshielded_target := {"id": 202, "type": 6, "pos": Vector2(950, 0), "path_index": 1, "hp": 500.0, "shield": false}
game.get("enemies").append(shield_target)
game.get("enemies").append(unshielded_target)
assert(game.call("_find_target", {"type": 1, "level": 1, "pos": Vector2(800, 0)}).id == 201, "Lasertürme müssen unabhängig vom Fortschritt immer zuerst ein Schildziel in Reichweite wählen.")
assert(game.call("_find_target", {"type": 0, "level": 1, "pos": Vector2(800, 0), "target_priority": "strongest"}).id == 202, "Die Priorität MEISTE HP muss das Ziel mit den meisten Lebenspunkten wählen.")
assert(game.call("_find_target", {"type": 0, "level": 1, "pos": Vector2(800, 0), "target_priority": "last"}).id == 201, "Die Priorität LETZTE muss die Einheit mit dem geringsten Wegfortschritt wählen.")
game.get("towers").clear()
game.get("towers").append({"type": 0, "target_priority": "first"})
game.set("selected_tower_index", 0)
game.call("_cycle_selected_tower_priority")
assert(game.get("towers")[0].target_priority == "last", "Die Zielpriorität eines ausgewählten Turms muss umstellbar sein.")
game.set("selected_tower_index", -1)
game.get("towers").clear()
game.get("enemies").clear()
game.call("_select_map", 0)
assert(game.call("_enemy_type_for_wave", 3, 0.9, false) == 3, "Auf Schwer muss der Abfangjäger ab Welle 3 auftreten.")
assert(game.call("_enemy_type_for_wave", 5, 0.85, false) == 4, "Auf Schwer muss der Schildkreuzer ab Welle 5 auftreten.")
assert(game.call("_enemy_type_for_wave", 8, 0.95, false) == 5, "Auf Schwer muss der Belagerer ab Welle 8 auftreten.")
@@ -282,13 +334,25 @@ func _run() -> void:
game.set("wave", 3)
game.call("_start_wave")
assert(game.get("enemies_to_spawn") > 15, "Ab Welle 4 muss die Gegnerzahl merklich steigen.")
var preview_types: Array[int] = game.call("_wave_preview_types", 3)
assert(preview_types.has(6) and preview_types.has(0), "Die Vorschau muss Luft- und Bodentypen der gleichen Welle zeigen.")
var preview_summary: Dictionary = game.call("_wave_preview_summary", 3)
assert(preview_summary.total == preview_types.size() and preview_summary.air + preview_summary.ground == preview_summary.total, "Die Vorschau muss die Gesamtzahl der Schiffe sowie die korrekten Luft-/Bodenanteile zählen.")
assert(game.get("wave_type_schedule").size() == game.get("enemies_to_spawn"), "Die Vorschau und der tatsächliche Spawnplan müssen übereinstimmen.")
var test_enemy := {"hp": 100.0, "max_hp": 100.0, "base_max_hp": 100.0, "type": 0, "flash": 0.0, "pos": Vector2.ZERO}
game.get("enemies").append(test_enemy)
game.call("_set_enemy_strength_multiplier", 1.6)
assert(is_equal_approx(test_enemy.max_hp, 160.0) and is_equal_approx(test_enemy.hp, 160.0), "Der Testmultiplikator muss die Hülle laufender Gegner anpassen.")
game.call("_set_enemy_strength_multiplier", 1.0)
game.get("enemies").clear()
var shielded_enemy := {"hp": 1000.0, "max_hp": 1000.0, "flash": 0.0, "id": 98001, "type": 0, "pos": Vector2.ZERO, "speed": 1.0, "reward": 1, "boss": false, "size_scale": 1.0, "shield": true}
var shielded_enemy := {"hp": 1000.0, "max_hp": 1000.0, "flash": 0.0, "id": 98001, "type": 0, "pos": Vector2.ZERO, "speed": 1.0, "reward": 1, "boss": false, "size_scale": 1.0, "shield": true, "shield_hp": 50.0, "max_shield_hp": 50.0, "shield_resistance": 0.28}
game.get("enemies").append(shielded_enemy)
game.call("_apply_damage", shielded_enemy, 100.0, 0, 0)
assert(is_equal_approx(shielded_enemy.hp, 972.0), "Schilde müssen kinetischen Schaden deutlich reduzieren, aber nicht vollständig blockieren.")
game.call("_apply_damage", shielded_enemy, 100.0, 0, 1)
assert(is_equal_approx(shielded_enemy.hp, 872.0), "Laser müssen Schildgegner effektiv beschädigen.")
game.call("_apply_damage", shielded_enemy, 30.0, 0, 1)
assert(is_equal_approx(shielded_enemy.hp, 972.0) and is_equal_approx(shielded_enemy.shield_hp, 20.0), "Laser müssen das Schildleben statt der Hülle abbauen.")
game.call("_apply_damage", shielded_enemy, 30.0, 0, 1)
assert(not shielded_enemy.shield and is_equal_approx(shielded_enemy.hp, 962.0), "Der Laser muss beim Schildbruch überschüssigen Schaden auf die Hülle übertragen.")
game.get("enemies").clear()
game.set("wave", 5)
game.set("reactor_lives", 12)
@@ -305,6 +369,8 @@ func _run() -> void:
game.set("wave", 4)
game.call("_start_wave")
game.set("enemies_to_spawn", 1)
game.set("wave_spawn_index", 0)
game.set("wave_type_schedule", game.call("_build_wave_schedule", 5, 1))
game.call("_spawn_enemy")
var boss: Dictionary = game.get("enemies")[-1]
assert(boss.boss, "Jede fünfte Welle sollte ein Kommandoschiff erzeugen.")