diff --git a/ALPHA_TEST_CHECKLIST.md b/ALPHA_TEST_CHECKLIST.md index c3ca03b..3c9625c 100644 --- a/ALPHA_TEST_CHECKLIST.md +++ b/ALPHA_TEST_CHECKLIST.md @@ -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 diff --git a/README.md b/README.md index cb322f2..84ef7db 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/RELEASE_NOTES_0.1.0-alpha.6.md b/RELEASE_NOTES_0.1.0-alpha.6.md new file mode 100644 index 0000000..d54c16b --- /dev/null +++ b/RELEASE_NOTES_0.1.0-alpha.6.md @@ -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. diff --git a/RELEASE_NOTES_0.1.0-alpha.7.md b/RELEASE_NOTES_0.1.0-alpha.7.md new file mode 100644 index 0000000..5af74c5 --- /dev/null +++ b/RELEASE_NOTES_0.1.0-alpha.7.md @@ -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. diff --git a/assets/enemy_ground_rover.png b/assets/enemy_ground_rover.png new file mode 100644 index 0000000..9db352b Binary files /dev/null and b/assets/enemy_ground_rover.png differ diff --git a/assets/enemy_ground_rover.png.import b/assets/enemy_ground_rover.png.import new file mode 100644 index 0000000..2d141a7 --- /dev/null +++ b/assets/enemy_ground_rover.png.import @@ -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 diff --git a/assets/enemy_ground_tank.png b/assets/enemy_ground_tank.png new file mode 100644 index 0000000..142edac Binary files /dev/null and b/assets/enemy_ground_tank.png differ diff --git a/assets/enemy_ground_tank.png.import b/assets/enemy_ground_tank.png.import new file mode 100644 index 0000000..be0970a --- /dev/null +++ b/assets/enemy_ground_tank.png.import @@ -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 diff --git a/assets/enemy_ground_walker.png b/assets/enemy_ground_walker.png new file mode 100644 index 0000000..d21250d Binary files /dev/null and b/assets/enemy_ground_walker.png differ diff --git a/assets/enemy_ground_walker.png.import b/assets/enemy_ground_walker.png.import new file mode 100644 index 0000000..9c374d4 --- /dev/null +++ b/assets/enemy_ground_walker.png.import @@ -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 diff --git a/export_presets.cfg b/export_presets.cfg index 22d8985..6fc2779 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -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" diff --git a/main.gd b/main.gd index af6d69b..63e4647 100644 --- a/main.gd +++ b/main.gd @@ -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: diff --git a/packaging/windows/build.ps1 b/packaging/windows/build.ps1 index 813ff48..16e633b 100644 --- a/packaging/windows/build.ps1 +++ b/packaging/windows/build.ps1 @@ -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" diff --git a/packaging/windows/installer.nsi b/packaging/windows/installer.nsi index e6b443f..f8d4e02 100644 --- a/packaging/windows/installer.nsi +++ b/packaging/windows/installer.nsi @@ -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" diff --git a/project.godot b/project.godot index 748a554..191db22 100644 --- a/project.godot +++ b/project.godot @@ -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 diff --git a/server/nakama/runtime/build/index.js b/server/nakama/runtime/build/index.js index 4654d0b..6fa00c0 100644 --- a/server/nakama/runtime/build/index.js +++ b/server/nakama/runtime/build/index.js @@ -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-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()=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;ibest) { 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;jbest)) { 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); } } diff --git a/server/nakama/tests/runtime_sim_test.js b/server/nakama/tests/runtime_sim_test.js index 5d4220c..2bd8497 100644 --- a/server/nakama/tests/runtime_sim_test.js +++ b/server/nakama/tests/runtime_sim_test.js @@ -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)); diff --git a/tests/smoke_test.gd b/tests/smoke_test.gd index 74e84f9..f266d4e 100644 --- a/tests/smoke_test.gd +++ b/tests/smoke_test.gd @@ -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.")