diff --git a/ART_ASSETS.md b/ART_ASSETS.md index 9687b64..8773863 100644 --- a/ART_ASSETS.md +++ b/ART_ASSETS.md @@ -2,6 +2,12 @@ Erzeugt mit dem integrierten OpenAI-Bildgenerator. Die Sprite-Sheets besitzen echte Transparenz. +## Zusätzliche Shop-Karten (Alpha 0.1.0-alpha.4 Entwicklung) + +Die vier neuen Hintergründe `orbital_biodome_bg.png`, `cryo_refinery_bg.png`, `solar_forge_bg.png` und `vestige_megastructure_bg.png` wurden als eigenständige, straßenfreie 16:9-Karten erzeugt. Der Reaktor liegt jeweils rechts ungefähr auf Höhe der Spielfeldmitte. Die Fahrbahn, Gegnerroute und Bausperre werden zur Laufzeit ausschließlich aus den gemeinsamen 80-Pixel-Raster-Wegpunkten aufgebaut. + +Gemeinsame Prompt-Vorgabe: realistische, hochauflösende, direkt von oben gesehene Science-Fiction-Plattform mit freier Deckfläche, gut lesbarer Umgebung und sichtbarem Reaktor am rechten Rand; kein eingebauter Weg, keine Fahrbahnmarkierung, kein Raster, keine Bauplätze, keine Einheiten, Türme, UI, Schrift oder Logos. Die Varianten unterscheiden sich thematisch durch Biodom und blauen Nebel, eisige Raffinerie, orangefarbene Sonnen-Schmiede sowie eine verlassene Megastruktur im violetten All. + ## orbital_platform.png ```text diff --git a/README.md b/README.md index 60472b4..0347bbd 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ Ein plattformübergreifender 2D-Tower-Defense-Prototyp für Godot 4. Der aktuell ## Enthalten -- drei auswählbare realistische Weltraumkarten im Querformat +- sieben auswählbare realistische Weltraumkarten im Querformat; vier zusätzliche Sektoren werden im internen Shop mit Kernkristallen freigeschaltet +- Kartenkatalog mit vier Sektoren pro Seite, persistent gespeicherten Freischaltungen und direktem Shop-Einstieg bei gesperrten Karten - vollständig sichtbare Einsatzkarten ohne Scrollen, Minikarte oder Richtungstasten - hellere, kontrastreichere Stationskarten und eigens gestaltete modulare Metallfahrbahnen mit realistischen Materialtexturen auf Geraden und Kurven, Führungsschienen, gebürsteten Paneelen, cyanfarbenen Energieleitungen, farbigen Reflexionen, Schraubpunkten, warmen Positionslichtern und dezenten Lichtkegeln - feste Benutzeroberfläche mit 1600 × 900 Referenzauflösung @@ -14,7 +15,7 @@ Ein plattformübergreifender 2D-Tower-Defense-Prototyp für Godot 4. Der aktuell - sichtbares 80-Pixel-Bauraster mit automatischem Einrasten der Türme - 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 -- drei Türme: Kinetik, Laser und Rakete +- sechs Türme: Kinetik, Laser, Rakete, Plasma, EMP-Puls und Railgun - drei Gegnerklassen mit unterschiedlichen Werten - 15 automatisch skalierende Missionswellen - drei wählbare Schwierigkeitsgrade: Leicht entspricht dem bisherigen Balancing, Mittel ist der neue Standard und Schwer erhöht Gegnerhülle, Tempo und Anzahl deutlich @@ -36,6 +37,7 @@ Ein plattformübergreifender 2D-Tower-Defense-Prototyp für Godot 4. Der aktuell - feste Reaktor-Sperrzonen auf allen Karten; auf Reaktoren und offenen Reaktorkammern wird kein Bauraster angezeigt und es können dort keine Türme platziert werden - auf eine Rasterzelle verkleinerte Turmdarstellung, damit angrenzende Türme nicht in die Fahrbahn hineinragen - dauerhaft gespeicherte Kernkristalle als spätere Freischaltwährung +- Nova-Arkologie (24), Cryo-Schleuse (32), Solarforge (40) und Vestige-9 (48) als kaufbare Einsatzsektoren - dauerhaftes lokales Gastprofil mit stabiler Spieler-ID als Grundlage für Apple-, Google- und Windows-Anmeldung - eigene Profilansicht mit lokalen Missions- und Endlosbestwerten pro Karte - frei wählbarer Spielername mit identischer Prüfung im Spiel und auf dem Server @@ -58,7 +60,7 @@ Ein plattformübergreifender 2D-Tower-Defense-Prototyp für Godot 4. Der aktuell - Abschussbelohnungen und Baukosten werden dem jeweiligen Spieler beziehungsweise KI-Partner getrennt zugerechnet - farblich getrennte Bauflächen und markierte KI-Türme zur schnellen Zuordnung im Gefecht - fünf Kernkristalle als Belohnung für jede erfolgreich abgeschlossene Karte -- normale und doppelte Spielgeschwindigkeit +- auswählbare Spielgeschwindigkeit bis 5× sowie automatischer Start der nächsten Welle - Pause, Missionssieg, Niederlage und lokaler Wellenrekord - jederzeit sichtbarer Zurück-Button zum Abbrechen der laufenden Runde und Wechseln ins Kartenmenü - lokaler Speicherstand für Rekord und Tutorialstatus diff --git a/RELEASE_NOTES_0.1.0-alpha.4.md b/RELEASE_NOTES_0.1.0-alpha.4.md new file mode 100644 index 0000000..0a3b2bb --- /dev/null +++ b/RELEASE_NOTES_0.1.0-alpha.4.md @@ -0,0 +1,22 @@ +# Void Bastion 0.1.0-alpha.4 + +Diese Windows-Testversion erweitert die Kartenauswahl und verbessert die Wegdarstellung. + +## Neu + +- vier neue Einsatzkarten: Nova-Arkologie, Cryo-Schleuse, Solarforge und Vestige-9 +- alle sieben Karten werden gleichzeitig im Hauptmenü angezeigt +- neue Karten lassen sich im internen Shop dauerhaft mit Kernkristallen freischalten +- die Freischaltungen bleiben lokal im Profil gespeichert + +## Verbessert + +- alle Kartenwege folgen dem gemeinsamen 80-Pixel-Raster und enden im sichtbaren Reaktor +- sich kreuzende Wegsegmente auf Nova-Arkologie sowie zusätzliche problematische Kreuzungen auf Cryo-Schleuse und Solarforge wurden entfernt +- die letzten zwei unnötigen Bögen bei der Reaktoreinfahrt auf Pyrax wurden entfernt +- Währungsanzeigen verwenden durchgängig die Bezeichnung „Kernkristalle“ +- die Profilübersicht und Kartenfilter unterstützen alle sieben Sektoren + +## Hinweis + +Void Bastion befindet sich weiterhin in einer frühen Alpha-Phase. Unter Windows erscheint keine Werbung. Updates werden im Spiel angekündigt, aber niemals ungefragt installiert. Rückmeldungen und Fehlerberichte bitte an support@gergames.de senden. diff --git a/ads_manager.gd b/ads_manager.gd index 5516a8b..b440d97 100644 --- a/ads_manager.gd +++ b/ads_manager.gd @@ -3,11 +3,9 @@ extends Node signal energy_reward_granted(amount: int) signal ad_unavailable(message: String) +signal victory_interstitial_finished(was_shown: bool) -const SAVE_PATH := "user://void_bastion_ads.json" const ENERGY_REWARD := 150 -const MISSIONS_PER_INTERSTITIAL := 3 -const INTERSTITIAL_COOLDOWN_SECONDS := 15 * 60 const ANDROID_TEST_APP_ID := "ca-app-pub-3940256099942544~3347511713" const IOS_TEST_APP_ID := "ca-app-pub-3940256099942544~1458002511" @@ -16,15 +14,13 @@ var initialized := false var rewarded_ready := false var interstitial_ready := false var reward_used_this_mission := false -var missions_since_interstitial := 0 -var last_interstitial_time := 0 +var _victory_interstitial_pending := false var _admob: Node var _initialization_requested := false func _ready() -> void: - _load_state() mobile_ads_supported = OS.has_feature("android") or OS.has_feature("ios") if not mobile_ads_supported: return @@ -55,6 +51,7 @@ func _create_admob_node() -> void: _admob.interstitial_ad_loaded.connect(_on_interstitial_ad_loaded) _admob.interstitial_ad_failed_to_load.connect(_on_interstitial_ad_failed_to_load) _admob.interstitial_ad_dismissed_full_screen_content.connect(_on_interstitial_ad_dismissed) + _admob.interstitial_ad_failed_to_show_full_screen_content.connect(_on_interstitial_ad_failed_to_show) add_child(_admob) @@ -76,31 +73,23 @@ func request_rewarded_energy() -> void: _admob.show_rewarded_ad() -func record_mission_finished() -> void: - missions_since_interstitial += 1 - _save_state() - if is_interstitial_due() and interstitial_ready: - _show_interstitial() - elif is_interstitial_due() and initialized: +func show_victory_interstitial() -> void: + if _victory_interstitial_pending: + return + if not mobile_ads_supported or not initialized: + victory_interstitial_finished.emit(false) + return + _victory_interstitial_pending = true + if not interstitial_ready: _admob.load_interstitial_ad() + return + _display_pending_victory_interstitial() -func is_interstitial_due(now_unix: int = -1) -> bool: - if missions_since_interstitial < MISSIONS_PER_INTERSTITIAL: - return false - if last_interstitial_time <= 0: - return true - var now := int(Time.get_unix_time_from_system()) if now_unix < 0 else now_unix - return now - last_interstitial_time >= INTERSTITIAL_COOLDOWN_SECONDS - - -func _show_interstitial() -> void: - if not mobile_ads_supported or not initialized or not interstitial_ready: +func _display_pending_victory_interstitial() -> void: + if not _victory_interstitial_pending or not interstitial_ready: return interstitial_ready = false - missions_since_interstitial = 0 - last_interstitial_time = int(Time.get_unix_time_from_system()) - _save_state() _admob.show_interstitial_ad() @@ -171,33 +160,29 @@ func _on_rewarded_ad_dismissed(_ad_info) -> void: func _on_interstitial_ad_loaded(_ad_info, _response_info) -> void: interstitial_ready = true + _display_pending_victory_interstitial() func _on_interstitial_ad_failed_to_load(_ad_info, _error) -> void: interstitial_ready = false + _finish_victory_interstitial(false) func _on_interstitial_ad_dismissed(_ad_info) -> void: interstitial_ready = false + _finish_victory_interstitial(true) _admob.load_interstitial_ad() -func _save_state() -> void: - var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE) - if file: - file.store_string(JSON.stringify({ - "missions_since_interstitial": missions_since_interstitial, - "last_interstitial_time": last_interstitial_time - })) +func _on_interstitial_ad_failed_to_show(_ad_info, _error) -> void: + interstitial_ready = false + _finish_victory_interstitial(false) + if initialized: + _admob.load_interstitial_ad() -func _load_state() -> void: - if not FileAccess.file_exists(SAVE_PATH): +func _finish_victory_interstitial(was_shown: bool) -> void: + if not _victory_interstitial_pending: return - var file := FileAccess.open(SAVE_PATH, FileAccess.READ) - if not file: - return - var data = JSON.parse_string(file.get_as_text()) - if data is Dictionary: - missions_since_interstitial = maxi(0, int(data.get("missions_since_interstitial", 0))) - last_interstitial_time = maxi(0, int(data.get("last_interstitial_time", 0))) + _victory_interstitial_pending = false + victory_interstitial_finished.emit(was_shown) diff --git a/assets/cryo_refinery_bg.png b/assets/cryo_refinery_bg.png new file mode 100644 index 0000000..9399a37 Binary files /dev/null and b/assets/cryo_refinery_bg.png differ diff --git a/assets/cryo_refinery_bg.png.import b/assets/cryo_refinery_bg.png.import new file mode 100644 index 0000000..fb8bf4d --- /dev/null +++ b/assets/cryo_refinery_bg.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c6qskqc282rpg" +path="res://.godot/imported/cryo_refinery_bg.png-b0439eb485200ef8da0459147d987794.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/cryo_refinery_bg.png" +dest_files=["res://.godot/imported/cryo_refinery_bg.png-b0439eb485200ef8da0459147d987794.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/orbital_biodome_bg.png b/assets/orbital_biodome_bg.png new file mode 100644 index 0000000..40f92ab Binary files /dev/null and b/assets/orbital_biodome_bg.png differ diff --git a/assets/orbital_biodome_bg.png.import b/assets/orbital_biodome_bg.png.import new file mode 100644 index 0000000..f1c847a --- /dev/null +++ b/assets/orbital_biodome_bg.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bkc4l5ax4o761" +path="res://.godot/imported/orbital_biodome_bg.png-9073720249bcf29936435b3b814e9686.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/orbital_biodome_bg.png" +dest_files=["res://.godot/imported/orbital_biodome_bg.png-9073720249bcf29936435b3b814e9686.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/solar_forge_bg.png b/assets/solar_forge_bg.png new file mode 100644 index 0000000..abc4460 Binary files /dev/null and b/assets/solar_forge_bg.png differ diff --git a/assets/solar_forge_bg.png.import b/assets/solar_forge_bg.png.import new file mode 100644 index 0000000..8a8f945 --- /dev/null +++ b/assets/solar_forge_bg.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dxy2tscacupix" +path="res://.godot/imported/solar_forge_bg.png-d2e9c29bfbb0f929e05570a5a5d0a90b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/solar_forge_bg.png" +dest_files=["res://.godot/imported/solar_forge_bg.png-d2e9c29bfbb0f929e05570a5a5d0a90b.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/tower_emp.png b/assets/tower_emp.png new file mode 100644 index 0000000..a12f818 Binary files /dev/null and b/assets/tower_emp.png differ diff --git a/assets/tower_emp.png.import b/assets/tower_emp.png.import new file mode 100644 index 0000000..314a9c2 --- /dev/null +++ b/assets/tower_emp.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://da66x5legbfd3" +path="res://.godot/imported/tower_emp.png-0c5406d0107cf3a00de9de8b95603513.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/tower_emp.png" +dest_files=["res://.godot/imported/tower_emp.png-0c5406d0107cf3a00de9de8b95603513.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/tower_plasma.png b/assets/tower_plasma.png new file mode 100644 index 0000000..a4cca1e Binary files /dev/null and b/assets/tower_plasma.png differ diff --git a/assets/tower_plasma.png.import b/assets/tower_plasma.png.import new file mode 100644 index 0000000..f721c7c --- /dev/null +++ b/assets/tower_plasma.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dcfoc8ioji2jg" +path="res://.godot/imported/tower_plasma.png-6c4277ea9427fade2f842c305b0946df.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/tower_plasma.png" +dest_files=["res://.godot/imported/tower_plasma.png-6c4277ea9427fade2f842c305b0946df.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/tower_railgun.png b/assets/tower_railgun.png new file mode 100644 index 0000000..3e051d6 Binary files /dev/null and b/assets/tower_railgun.png differ diff --git a/assets/tower_railgun.png.import b/assets/tower_railgun.png.import new file mode 100644 index 0000000..926f2d4 --- /dev/null +++ b/assets/tower_railgun.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bbba7b3ds0q1s" +path="res://.godot/imported/tower_railgun.png-05e293f5b98edc1a6a0a91aff3e87037.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/tower_railgun.png" +dest_files=["res://.godot/imported/tower_railgun.png-05e293f5b98edc1a6a0a91aff3e87037.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/vestige_megastructure_bg.png b/assets/vestige_megastructure_bg.png new file mode 100644 index 0000000..be790e5 Binary files /dev/null and b/assets/vestige_megastructure_bg.png differ diff --git a/assets/vestige_megastructure_bg.png.import b/assets/vestige_megastructure_bg.png.import new file mode 100644 index 0000000..62b99c9 --- /dev/null +++ b/assets/vestige_megastructure_bg.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c18bfpv8ad25r" +path="res://.godot/imported/vestige_megastructure_bg.png-cdef57a31190ae339ecfafd3efc0bafe.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/vestige_megastructure_bg.png" +dest_files=["res://.godot/imported/vestige_megastructure_bg.png-cdef57a31190ae339ecfafd3efc0bafe.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 c262c5a..59c55a3 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.3" -application/product_version="0.1.0.3" +application/file_version="0.1.0.4" +application/product_version="0.1.0.4" application/company_name="GerGames" application/product_name="Void Bastion" application/file_description="Void Bastion – Alpha-Testversion" @@ -34,3 +34,90 @@ application/trademarks="" ssh_remote_deploy/enabled=false texture_format/s3tc_bptc=true texture_format/etc2_astc=false + +[preset.1] + +name="Android Test APK" +platform="Android" +runnable=true +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter=".mcp.json,*.md,tests/*,server/*,tools/*,packaging/*,addons/agent_tools/*,build/*,dist/*" +export_path="build/android/Void-Bastion-Alpha.apk" +patches=PackedStringArray() + +[preset.1.options] + +custom_template/debug="" +custom_template/release="" +gradle_build/use_gradle_build=true +gradle_build/export_format=0 +gradle_build/min_sdk="24" +architectures/arm64-v8a=true +architectures/armeabi-v7a=true +architectures/x86=false +architectures/x86_64=false +package/name="Void Bastion" +package/unique_name="de.gergames.voidbastion" +package/signed=true +screen/immersive_mode=true +permissions/internet=true + +[preset.3] + +name="iOS (Xcode Projekt)" +platform="iOS" +runnable=false +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter=".mcp.json,*.md,tests/*,server/*,tools/*,packaging/*,addons/agent_tools/*,build/*,dist/*" +export_path="build/ios/Void-Bastion" +patches=PackedStringArray() + +[preset.3.options] + +custom_template/debug="" +custom_template/release="" +application/app_store_team_id="" +application/bundle_identifier="de.gergames.voidbastion" +application/export_project_only=true +application/min_ios_version="14.3" +application/short_version="0.1.0" +application/version="1.0" + +[preset.2] + +name="Android Studio Emulator x86_64" +platform="Android" +runnable=true +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter=".mcp.json,*.md,tests/*,server/*,tools/*,packaging/*,addons/agent_tools/*,build/*,dist/*" +export_path="build/android/Void-Bastion-Emulator-x64.apk" +patches=PackedStringArray() + +[preset.2.options] + +custom_template/debug="" +custom_template/release="" +gradle_build/use_gradle_build=true +gradle_build/export_format=0 +gradle_build/min_sdk="24" +architectures/arm64-v8a=false +architectures/armeabi-v7a=false +architectures/x86=false +architectures/x86_64=true +package/name="Void Bastion" +package/unique_name="de.gergames.voidbastion" +package/signed=true +screen/immersive_mode=true +permissions/internet=true diff --git a/main.gd b/main.gd index e5369c7..771b6bb 100644 --- a/main.gd +++ b/main.gd @@ -10,6 +10,9 @@ const PLAYFIELD_TOP := 92.0 const PLAYFIELD_BOTTOM := 758.0 const GRID_SIZE := 80.0 const TOWER_TEXTURE := preload("res://assets/defense_towers.png") +const PLASMA_TEXTURE := preload("res://assets/tower_plasma.png") +const EMP_TEXTURE := preload("res://assets/tower_emp.png") +const RAILGUN_TEXTURE := preload("res://assets/tower_railgun.png") const ENEMY_TEXTURE := preload("res://assets/enemy_ships.png") const ROAD_STRAIGHT_TEXTURE := preload("res://assets/road_straight.svg") const ROAD_CORNER_TEXTURE := preload("res://assets/road_corner.svg") @@ -18,19 +21,32 @@ const ROAD_CORNER_SURFACE_TEXTURE := preload("res://assets/road_corner_surface_v const MAP_TEXTURES := [ preload("res://assets/orbital_platform_grid_base.png"), preload("res://assets/orbital_shipyard_grid_base.png"), - preload("res://assets/asteroid_refinery_grid_base.png") + preload("res://assets/asteroid_refinery_grid_base.png"), + preload("res://assets/orbital_biodome_bg.png"), + preload("res://assets/cryo_refinery_bg.png"), + preload("res://assets/solar_forge_bg.png"), + preload("res://assets/vestige_megastructure_bg.png") ] -const MAP_NAMES := ["BASTION PRIME", "HELIOS-WERFT", "PYRAX-RAFFINERIE"] -const MAP_DESCRIPTIONS := ["Dreifache Feuerlinie", "Langer Werft-Korridor", "Gefährlicher Asteroidenring"] +const MAP_NAMES := ["BASTION PRIME", "HELIOS-WERFT", "PYRAX-RAFFINERIE", "NOVA-ARKOLOGIE", "CRYO-SCHLEUSE", "SOLARFORGE", "VESTIGE-9"] +const MAP_DESCRIPTIONS := ["Dreifache Feuerlinie", "Langer Werft-Korridor", "Gefährlicher Asteroidenring", "Biodom über dem Nebel", "Eisige Raffinerie", "Schmiede am Gasriesen", "Verlassene Megastruktur"] +const MAP_UNLOCK_COSTS := [0, 0, 0, 24, 32, 40, 48] const MAP_PATHS := [ [Vector2(-40, 120), Vector2(1160, 120), Vector2(1160, 360), Vector2(360, 360), Vector2(360, 680), Vector2(1560, 680), Vector2(1560, 520), Vector2(1480, 520), Vector2(1480, 440)], [Vector2(-40, 280), Vector2(1320, 280), Vector2(1320, 440), Vector2(280, 440), Vector2(280, 680), Vector2(1560, 680), Vector2(1560, 440), Vector2(1480, 440)], - [Vector2(-40, 120), Vector2(360, 120), Vector2(360, 600), Vector2(1320, 600), Vector2(1320, 280), Vector2(1560, 280), Vector2(1560, 360), Vector2(1480, 360)] + [Vector2(-40, 120), Vector2(360, 120), Vector2(360, 600), Vector2(1320, 600), Vector2(1320, 280), Vector2(1480, 280), Vector2(1480, 360)], + [Vector2(-40, 120), Vector2(1240, 120), Vector2(1240, 280), Vector2(280, 280), Vector2(280, 520), Vector2(1480, 520), Vector2(1480, 440)], + [Vector2(-40, 680), Vector2(1000, 680), Vector2(1000, 520), Vector2(200, 520), Vector2(200, 360), Vector2(1400, 360), Vector2(1480, 360), Vector2(1480, 440)], + [Vector2(-40, 120), Vector2(1400, 120), Vector2(1400, 280), Vector2(200, 280), Vector2(200, 600), Vector2(1480, 600), Vector2(1480, 440)], + [Vector2(-40, 360), Vector2(200, 360), Vector2(200, 120), Vector2(1400, 120), Vector2(1400, 280), Vector2(440, 280), Vector2(440, 600), Vector2(1240, 600), Vector2(1240, 520), Vector2(1480, 520), Vector2(1480, 440)] ] const MAP_BUILD_ZONES := [ [Rect2(155, 95, 1380, 660)], [Rect2(120, 95, 1380, 660)], - [Rect2(180, 110, 1270, 645)] + [Rect2(180, 110, 1270, 645)], + [Rect2(40, 96, 1520, 664)], + [Rect2(40, 96, 1520, 664)], + [Rect2(40, 96, 1520, 664)], + [Rect2(40, 96, 1520, 664)] ] const MAP_REACTOR_ZONES := [ [{"center": Vector2(1480, 440), "radius": 190.0}], @@ -38,7 +54,11 @@ const MAP_REACTOR_ZONES := [ [ {"center": Vector2(1480, 360), "radius": 165.0}, {"center": Vector2(690, 385), "radius": 155.0} - ] + ], + [{"center": Vector2(1480, 440), "radius": 185.0}], + [{"center": Vector2(1480, 440), "radius": 185.0}], + [{"center": Vector2(1480, 440), "radius": 185.0}], + [{"center": Vector2(1480, 440), "radius": 185.0}] ] const COOP_MIRROR_X := 1480.0 const COOP_WORLD_WIDTH := COOP_MIRROR_X * 2.0 @@ -58,9 +78,12 @@ 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")}, - {"name": "LASER", "cost": 180, "damage": 12.0, "range": 165.0, "rate": 0.18, "shot_speed": 1100.0, "damage_type": "EINZELZIEL", "color": Color("62fff1")}, - {"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")} + {"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."} ] const ENEMY_DATA := [ @@ -77,15 +100,25 @@ var selected_difficulty := 1 var camera_offset := Vector2.ZERO var game_mode := "menu" +var menu_step := "map" +var store_return_mode := "menu" var credits := 450 var reactor_lives := 20 var wave := 0 var best_wave := 0 var best_endless_wave := 0 var core_crystals := 0 +var unlocked_towers := [0, 1, 2] +var unlocked_maps := [0, 1, 2] +var store_tab := "towers" var tutorial_completed := false var tutorial_step := 0 var game_speed := 1.0 +const SPEED_OPTIONS := [1.0, 2.0, 3.0, 4.0, 5.0] +var speed_dropdown_open := false +var auto_start_waves := false +var auto_wave_countdown := 0.0 +var towers_built_this_run := 0 var cursor_position := Vector2(-1000, -1000) var cursor_screen_position := Vector2(-1000, -1000) var selected_tower_type := -1 @@ -106,6 +139,7 @@ var elapsed := 0.0 var endless_mode := false var mission_reward_granted := false var last_mission_reward := 0 +var victory_reward_pending := false var coop_mode := false var coop_ai_mode := false var coop_online_mode := false @@ -113,23 +147,37 @@ var local_player_slot := 0 var coop_paths: Array[PackedVector2Array] = [] var next_spawn_route := 0 -var start_button := Rect2(625, 705, 350, 60) -var map_buttons := [Rect2(185, 430, 390, 145), Rect2(605, 430, 390, 145), Rect2(1025, 430, 390, 145)] -var difficulty_buttons := [Rect2(535, 610, 165, 50), Rect2(718, 610, 165, 50), Rect2(901, 610, 165, 50)] +var start_button := Rect2(625, 674, 350, 58) +var map_buttons := [ + Rect2(30, 390, 340, 130), Rect2(430, 390, 340, 130), Rect2(830, 390, 340, 130), Rect2(1230, 390, 340, 130), + Rect2(255, 540, 340, 130), Rect2(630, 540, 340, 130), Rect2(1005, 540, 340, 130) +] +var difficulty_buttons := [Rect2(535, 520, 165, 50), Rect2(718, 520, 165, 50), Rect2(901, 520, 165, 50)] 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 back_button := Rect2(1070, 18, 90, 54) -var tower_buttons := [Rect2(22, 770, 305, 120), Rect2(347, 770, 305, 120), Rect2(672, 770, 305, 120)] -var upgrade_button := Rect2(1000, 814, 270, 64) -var sell_button := Rect2(1290, 814, 285, 64) +var tower_buttons := [ + Rect2(15, 768, 255, 122), Rect2(278, 768, 255, 122), Rect2(541, 768, 255, 122), + Rect2(804, 768, 255, 122), Rect2(1067, 768, 255, 122), Rect2(1330, 768, 255, 122) +] +var upgrade_button := Rect2(1275, 187, 145, 44) +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 endless_button := Rect2(545, 515, 510, 70) var finish_mission_button := Rect2(625, 610, 350, 62) var profile_button := Rect2(1280, 25, 270, 52) -var leaderboard_button := Rect2(250, 705, 350, 60) +var leaderboard_button := Rect2(100, 790, 250, 54) +var shop_button := Rect2(640, 790, 250, 54) +var map_step_back_button := Rect2(55, 320, 170, 50) +var shop_back_button := Rect2(55, 35, 185, 54) +var shop_purchase_buttons := [Rect2(190, 545, 360, 62), Rect2(620, 545, 360, 62), Rect2(1050, 545, 360, 62)] +var map_shop_purchase_buttons := [Rect2(70, 565, 350, 62), Rect2(460, 565, 350, 62), Rect2(850, 565, 350, 62), Rect2(1240, 565, 350, 62)] +var store_tower_tab_button := Rect2(590, 174, 200, 42) +var store_map_tab_button := Rect2(810, 174, 200, 42) var leaderboard_back_button := Rect2(55, 35, 185, 54) var leaderboard_mission_button := Rect2(510, 212, 270, 54) var leaderboard_endless_button := Rect2(820, 212, 270, 54) @@ -147,7 +195,7 @@ var leaderboard_difficulty := 1 var leaderboard_loading := false var leaderboard_message := "" var profile_back_button := Rect2(55, 35, 185, 54) -var multiplayer_button := Rect2(1000, 705, 350, 60) +var multiplayer_button := Rect2(370, 790, 250, 54) var multiplayer_back_button := Rect2(55, 35, 185, 54) var quick_match_button := Rect2(125, 665, 300, 64) var friend_match_button := Rect2(475, 665, 300, 64) @@ -172,19 +220,33 @@ var update_button := Rect2(30, 25, 390, 54) var update_dismiss_button := Rect2(430, 25, 54, 54) var available_update: Dictionary = {} var update_notice_visible := false +var audio_settings_open := false +var master_volume := 0.65 +var audio_settings_button := Rect2(910, 790, 250, 54) +var exit_button := Rect2(1180, 790, 250, 54) +var audio_panel_rect := Rect2(450, 325, 700, 270) +var audio_close_button := Rect2(1010, 345, 110, 44) +var audio_slider_rect := Rect2(565, 460, 520, 32) @onready var ads_manager = $AdsManager @onready var account_manager = $AccountManager @onready var multiplayer_manager = $MultiplayerManager @onready var online_manager = $OnlineManager @onready var update_manager = $UpdateManager +@onready var music_player: AudioStreamPlayer = $MusicPlayer +@onready var ui_sound_player: AudioStreamPlayer = $UiSoundPlayer func _ready() -> void: _select_map(selected_map) _load_progress() + _load_audio_settings() + music_player.stream = _build_ambient_stream() + music_player.play() + ui_sound_player.stream = _build_ui_click_stream() ads_manager.energy_reward_granted.connect(_on_energy_reward_granted) ads_manager.ad_unavailable.connect(_show_status) + ads_manager.victory_interstitial_finished.connect(_on_victory_interstitial_finished) multiplayer_manager.online_game_ready.connect(_start_coop_online_game) multiplayer_manager.online_snapshot_updated.connect(_apply_online_snapshot) multiplayer_manager.online_event_received.connect(_handle_online_event) @@ -197,6 +259,10 @@ func _process(delta: float) -> void: if status_timer > 0.0: status_timer -= delta if game_mode == "playing": + if auto_wave_countdown > 0.0: + auto_wave_countdown -= delta + if auto_wave_countdown <= 0.0 and auto_start_waves and not wave_active and (endless_mode or wave < MAX_WAVES): + _start_wave() if coop_online_mode: _update_online_interpolation(delta) _update_effects(delta) @@ -232,6 +298,9 @@ func _update_wave(delta: float) -> void: if wave > best_wave: best_wave = wave _save_progress() + if auto_start_waves and (wave < MAX_WAVES or endless_mode) and not coop_online_mode: + auto_wave_countdown = 1.4 + _show_status("WELLE ABGEWEHRT • NÄCHSTE WELLE IN 1,4 S") func _spawn_enemy() -> void: @@ -245,7 +314,7 @@ func _spawn_enemy() -> void: elif wave >= 3 and roll > 0.53: type = 1 var data: Dictionary = ENEMY_DATA[type] - var health_scale := (1.0 + maxf(0.0, wave - 1.0) * 0.115) * (3.25 if is_boss else 1.0) * float(_difficulty().hp) + var health_scale := _enemy_health_scale(wave, is_boss) var route_id := 0 var route := path_points if coop_mode: @@ -265,13 +334,21 @@ func _spawn_enemy() -> void: "angle": 0.0, "flash": 0.0, "boss": is_boss, - "size_scale": 1.34 if is_boss else 1.0 + "size_scale": 1.34 if is_boss else 1.0, + "shield": wave >= 4 and randf() < minf(0.42, 0.10 + wave * 0.018 + selected_difficulty * 0.06) }) if is_boss: _show_status("WARNUNG: KOMMANDOSCHIFF IM ANFLUG") next_enemy_id += 1 +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 + var endless_ramp := maxf(0.0, for_wave - MAX_WAVES) * 0.035 if endless_mode else 0.0 + return (1.0 + early_ramp + post_four_ramp + endless_ramp) * (3.25 if is_boss else 1.0) * float(_difficulty().hp) + + func _update_enemies(delta: float) -> void: for enemy in enemies.duplicate(): if enemy.flash > 0.0: @@ -323,7 +400,7 @@ 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.type == 1: - _apply_damage(target, damage, int(tower.get("owner", 0))) + _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}) else: projectiles.append({ @@ -346,21 +423,27 @@ func _update_projectiles(delta: float) -> void: var direction: Vector2 = shot.pos.direction_to(target.pos) shot.pos += direction * shot.speed * delta if shot.pos.distance_to(target.pos) < 14.0: - if shot.type == 2: + 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) for enemy in enemies.duplicate(): - if enemy.pos.distance_to(target.pos) < 76.0: - _apply_damage(enemy, shot.damage if enemy.id == target.id else shot.damage * 0.45, int(shot.get("owner", 0))) - _spawn_burst(target.pos, Color("ff9b4a"), 14) + if 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) else: - _apply_damage(target, shot.damage, int(shot.get("owner", 0))) + _apply_damage(target, shot.damage, int(shot.get("owner", 0)), int(shot.type)) _spawn_burst(target.pos, shot.color, 4) projectiles.erase(shot) -func _apply_damage(enemy: Dictionary, amount: float, owner := 0) -> void: +func _apply_damage(enemy: Dictionary, amount: float, owner := 0, weapon_type := -1) -> void: if not enemies.has(enemy): return - enemy.hp -= amount + var applied_damage := amount + if bool(enemy.get("shield", false)) and weapon_type not in [1, 5]: + applied_damage *= 0.28 + enemy.hp -= applied_damage enemy.flash = 0.06 if enemy.hp <= 0.0: _award_credits(owner, int(enemy.reward)) @@ -402,6 +485,24 @@ 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 in [KEY_ESCAPE, KEY_BACK, KEY_Q]: + if audio_settings_open: + audio_settings_open = false + _save_audio_settings() + queue_redraw() + return + if game_mode == "menu": + get_tree().quit() + return + if audio_settings_open and event is InputEventMouseMotion and (event.button_mask & MOUSE_BUTTON_MASK_LEFT) != 0: + _set_master_volume((event.position.x - audio_slider_rect.position.x) / audio_slider_rect.size.x) + return + if audio_settings_open and event is InputEventScreenDrag: + _set_master_volume((event.position.x - audio_slider_rect.position.x) / audio_slider_rect.size.x) + return + if audio_settings_open and ((event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and not event.pressed) or (event is InputEventScreenTouch and not event.pressed)): + _save_audio_settings() + return if game_mode == "profile_name_entry" and event is InputEventKey and event.pressed and not event.echo: if event.keycode == KEY_ESCAPE: _hide_virtual_keyboard() @@ -448,6 +549,16 @@ func _unhandled_input(event: InputEvent) -> void: func _handle_pointer(position: Vector2, is_touch := false) -> void: + _play_ui_sound() + if audio_settings_open: + if audio_close_button.has_point(position) or not audio_panel_rect.has_point(position): + audio_settings_open = false + _save_audio_settings() + elif audio_slider_rect.grow(12.0).has_point(position): + _set_master_volume((position.x - audio_slider_rect.position.x) / audio_slider_rect.size.x) + _save_audio_settings() + queue_redraw() + return if game_mode == "menu": if update_notice_visible and update_button.has_point(position): OS.shell_open(str(available_update.get("url", "https://void-bastion.gergames.de/"))) @@ -460,16 +571,45 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void: game_mode = "profile" queue_redraw() return - for index in map_buttons.size(): - if map_buttons[index].has_point(position): - _select_map(index) - return - for index in difficulty_buttons.size(): - if difficulty_buttons[index].has_point(position): - selected_difficulty = index - _save_progress() - return - if start_button.has_point(position): + if audio_settings_button.has_point(position): + audio_settings_open = true + queue_redraw() + return + if exit_button.has_point(position): + get_tree().quit() + return + if shop_button.has_point(position): + store_return_mode = "menu" + store_tab = "towers" + game_mode = "store" + queue_redraw() + return + if menu_step == "difficulty" and map_step_back_button.has_point(position): + menu_step = "map" + queue_redraw() + return + if menu_step == "map": + for index in map_buttons.size(): + if map_buttons[index].has_point(position): + var map_index := index + if not unlocked_maps.has(map_index): + store_return_mode = "menu" + store_tab = "maps" + game_mode = "store" + queue_redraw() + return + _select_map(map_index) + menu_step = "difficulty" + queue_redraw() + return + else: + for index in difficulty_buttons.size(): + if difficulty_buttons[index].has_point(position): + selected_difficulty = index + _save_progress() + queue_redraw() + return + if menu_step == "difficulty" and start_button.has_point(position): _start_game() elif leaderboard_button.has_point(position): _open_leaderboard() @@ -477,6 +617,32 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void: game_mode = "multiplayer_menu" queue_redraw() return + if game_mode == "store": + if shop_back_button.has_point(position): + game_mode = store_return_mode + queue_redraw() + return + if store_tower_tab_button.has_point(position): + store_tab = "towers" + queue_redraw() + return + if store_map_tab_button.has_point(position): + store_tab = "maps" + queue_redraw() + return + if store_tab == "maps": + for index in map_shop_purchase_buttons.size(): + if map_shop_purchase_buttons[index].has_point(position): + _buy_map_with_crystals(index + 3) + queue_redraw() + return + else: + for index in shop_purchase_buttons.size(): + if shop_purchase_buttons[index].has_point(position): + _buy_tower_with_crystals(index + 3) + queue_redraw() + return + return if game_mode == "leaderboard": if leaderboard_back_button.has_point(position): game_mode = "menu" @@ -571,12 +737,16 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void: return if game_mode in ["game_over", "victory", "endless_result"]: if start_button.has_point(position): - if coop_ai_mode: + if game_mode == "victory": + _return_to_menu() + elif coop_ai_mode: _start_coop_ai_game() else: _start_game() return if game_mode == "endless_offer": + if victory_reward_pending: + return if endless_button.has_point(position): _start_endless_mode() elif finish_mission_button.has_point(position): @@ -591,12 +761,28 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void: if pause_button.has_point(position): _toggle_pause() return + if speed_dropdown_open: + for speed_index in SPEED_OPTIONS.size(): + if _speed_option_rect(speed_index).has_point(position): + game_speed = SPEED_OPTIONS[speed_index] + speed_dropdown_open = false + _show_status("SIMULATION %.0f×" % game_speed) + queue_redraw() + return + speed_dropdown_open = false if speed_button.has_point(position): if coop_online_mode: _show_status("ONLINE-SIMULATION LÄUFT AUF SERVERTEMPO") return - game_speed = 2.0 if game_speed < 1.5 else 1.0 - _show_status("SIMULATION %.0f×" % game_speed) + speed_dropdown_open = true + queue_redraw() + return + if auto_wave_button.has_point(position): + auto_start_waves = not auto_start_waves + if not auto_start_waves: + auto_wave_countdown = 0.0 + _show_status("AUTO-WELLENSTART %s" % ("EIN" if auto_start_waves else "AUS")) + queue_redraw() return if game_mode == "paused": return @@ -605,7 +791,13 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void: return for index in tower_buttons.size(): if tower_buttons[index].has_point(position): - selected_tower_type = index + var tower_type := index + if not unlocked_towers.has(tower_type): + store_return_mode = game_mode + game_mode = "store" + queue_redraw() + return + selected_tower_type = tower_type selected_tower_index = -1 pending_touch_build_position = Vector2(-1, -1) if not tutorial_completed and tutorial_step == 0: @@ -701,6 +893,8 @@ func _start_coop_ai_game() -> void: credits = multiplayer_manager.player_credits[0] reactor_lives = multiplayer_manager.shared_reactor_lives wave = 0 + towers_built_this_run = 0 + auto_wave_countdown = 0.0 towers.clear() enemies.clear() projectiles.clear() @@ -713,6 +907,7 @@ func _start_coop_ai_game() -> void: endless_mode = false mission_reward_granted = false last_mission_reward = 0 + victory_reward_pending = false next_spawn_route = 0 game_mode = "playing" game_speed = 1.0 @@ -741,6 +936,7 @@ func _start_coop_online_game(player_slot: int) -> void: endless_mode = false mission_reward_granted = false last_mission_reward = 0 + victory_reward_pending = false game_mode = "playing" game_speed = 1.0 tutorial_step = 3 @@ -851,6 +1047,8 @@ func _delete_profile_account() -> void: best_wave = 0 best_endless_wave = 0 core_crystals = 0 + unlocked_towers = [0, 1, 2] + unlocked_maps = [0, 1, 2] _save_progress() profile_message = "Online-Konto und lokale Profildaten gelöscht. Ein neues Gastprofil wurde erstellt." queue_redraw() @@ -887,6 +1085,8 @@ func _start_game() -> void: credits = int(_difficulty().start_credits) reactor_lives = int(_difficulty().lives) wave = 0 + towers_built_this_run = 0 + auto_wave_countdown = 0.0 towers.clear() enemies.clear() projectiles.clear() @@ -899,6 +1099,7 @@ func _start_game() -> void: endless_mode = false mission_reward_granted = false last_mission_reward = 0 + victory_reward_pending = false game_mode = "playing" game_speed = 1.0 camera_offset = Vector2.ZERO @@ -911,6 +1112,7 @@ func _return_to_menu() -> void: if coop_online_mode: online_manager.leave_current_match() game_mode = "menu" + menu_step = "map" wave_active = false game_speed = 1.0 enemies_to_spawn = 0 @@ -939,7 +1141,10 @@ func _start_wave() -> void: _show_status("WELLENSTART AN SERVER GESENDET") return wave += 1 - enemies_to_spawn = ceili((7 + wave * 2) * float(_difficulty().count)) * (2 if coop_mode else 1) + var wave_population_scale := 1.0 + maxf(0.0, wave - 3.0) * 0.08 + 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) spawn_timer = 0.15 wave_active = true if coop_ai_mode: @@ -971,25 +1176,21 @@ func _game_over() -> void: best_wave = wave _record_account_score(false) _save_progress() - ads_manager.record_mission_finished() func _mission_complete() -> void: game_mode = "victory" wave_active = false - best_wave = maxi(best_wave, wave) - _record_account_score(false) - _grant_map_reward() - _save_progress() - ads_manager.record_mission_finished() func _offer_endless_mode() -> void: game_mode = "endless_offer" wave_active = false best_wave = maxi(best_wave, wave) - _grant_map_reward() + _record_account_score(false) _save_progress() + victory_reward_pending = true + ads_manager.show_victory_interstitial() func _start_endless_mode() -> void: @@ -1004,22 +1205,70 @@ func _endless_run_complete() -> void: best_wave = maxi(best_wave, wave) best_endless_wave = maxi(best_endless_wave, wave) _record_account_score(true) + _save_progress() + + +func _on_victory_interstitial_finished(_was_shown: bool) -> void: + if not victory_reward_pending: + return + victory_reward_pending = false _grant_map_reward() _save_progress() - ads_manager.record_mission_finished() + queue_redraw() func _record_account_score(was_endless: bool) -> void: - var score := wave * 1000 + maxi(reactor_lives, 0) * 100 + _player_credits(_local_credit_slot()) + var score := _calculate_run_score() account_manager.record_run(selected_map, selected_difficulty, was_endless, wave, score) +func _calculate_run_score() -> int: + var base_score := wave * 1000 + maxi(reactor_lives, 0) * 150 + _player_credits(_local_credit_slot()) * 2 + var tower_efficiency := clampf(1.0 + (10.0 - towers_built_this_run) * 0.05, 0.50, 1.50) + return roundi(base_score * tower_efficiency) + + func _grant_map_reward() -> void: if mission_reward_granted: return mission_reward_granted = true last_mission_reward = int(_difficulty().crystals) core_crystals += last_mission_reward + _save_progress() + + +func _buy_tower_with_crystals(tower_type: int) -> void: + if tower_type < 3 or tower_type >= TOWER_DATA.size(): + return + if unlocked_towers.has(tower_type): + _show_status("%s IST BEREITS FREIGESCHALTET" % TOWER_DATA[tower_type].name) + return + var price := int(TOWER_DATA[tower_type].unlock_cost) + if core_crystals < price: + _show_status("ZU WENIG KERNKRISTALLE • BENÖTIGT: %d" % price) + return + core_crystals -= price + unlocked_towers.append(tower_type) + unlocked_towers.sort() + _save_progress() + _show_status("%s FREIGESCHALTET • IM ARSENAL VERFÜGBAR" % TOWER_DATA[tower_type].name) + + +func _buy_map_with_crystals(map_index: int) -> void: + if map_index < 3 or map_index >= MAP_NAMES.size(): + return + if unlocked_maps.has(map_index): + _show_status("%s IST BEREITS FREIGESCHALTET" % MAP_NAMES[map_index]) + return + var price := int(MAP_UNLOCK_COSTS[map_index]) + if core_crystals < price: + _show_status("ZU WENIG KERNKRISTALLE • BENÖTIGT: %d" % price) + return + core_crystals -= price + unlocked_maps.append(map_index) + unlocked_maps.sort() + _save_progress() + _show_status("%s FREIGESCHALTET • NEUER EINSATZSEKTOR" % MAP_NAMES[map_index]) func _difficulty() -> Dictionary: @@ -1046,6 +1295,9 @@ func _complete_tutorial() -> void: func _place_tower(position: Vector2) -> void: position = _snap_to_grid(position) + if selected_tower_type < 0 or not unlocked_towers.has(selected_tower_type): + _show_status("TURM ZUERST IM ARSENAL FREISCHALTEN") + return var data: Dictionary = TOWER_DATA[selected_tower_type] var credit_slot := _local_credit_slot() if _player_credits(credit_slot) < data.cost: @@ -1061,6 +1313,7 @@ func _place_tower(position: Vector2) -> void: _show_status("BAUAUFTRAG AN SERVER GESENDET") 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}) selected_tower_index = towers.size() - 1 selected_tower_type = -1 @@ -1132,7 +1385,7 @@ func _tower_shots_per_second(type: int, level: int) -> float: func _tower_aim_angle(type: int, origin: Vector2, target: Vector2) -> float: - return origin.angle_to_point(target) + (PI if type == 0 else 0.0) + return origin.angle_to_point(target) + (PI if type in [0, 3, 5] else 0.0) func _tower_at(position: Vector2, radius := 52.0) -> int: @@ -1247,17 +1500,57 @@ func _run_ai_builder(initial_setup: bool) -> void: func _find_ai_build_position(offset: int) -> Vector2: - var candidates: Array[Vector2] = [] + if coop_paths.size() < 2: + return Vector2.ZERO + var route: PackedVector2Array = coop_paths[1] + var desired_towers := mini(9, 2 + int(wave / 2)) + var target_progress := _route_length(route) * float(offset + 1) / float(desired_towers + 1) + var tower_range: float = TOWER_DATA[offset % TOWER_DATA.size()].range + var best_position := Vector2.ZERO + var best_score := INF for x in range(1560, 2841, 80): for y in range(120, 721, 80): var candidate := Vector2(x, y) if _is_valid_build_position(candidate, 1): - var distance := _distance_to_route(candidate, coop_paths[1]) - if distance >= 90.0 and distance <= 205.0: - candidates.append(candidate) - if candidates.is_empty(): - return Vector2.ZERO - return candidates[offset % candidates.size()] + var distance := _distance_to_route(candidate, route) + if distance < 82.0 or distance > tower_range - 12.0: + continue + var progress := _route_progress_at(candidate, route) + var score := absf(progress - target_progress) + absf(distance - tower_range * 0.70) * 1.25 + for tower in towers: + if int(tower.get("owner", 0)) == 1: + var separation: float = candidate.distance_to(tower.pos) + if separation < 150.0: + score += (150.0 - separation) * 4.0 + if score < best_score: + best_score = score + best_position = candidate + return best_position + + +func _route_length(route: PackedVector2Array) -> float: + var result := 0.0 + for index in route.size() - 1: + result += route[index].distance_to(route[index + 1]) + return result + + +func _route_progress_at(position: Vector2, route: PackedVector2Array) -> float: + var travelled := 0.0 + var best_distance := INF + var best_progress := 0.0 + for index in route.size() - 1: + var start: Vector2 = route[index] + var segment: Vector2 = route[index + 1] - start + var segment_length := segment.length() + var ratio := clampf((position - start).dot(segment) / maxf(0.001, segment.length_squared()), 0.0, 1.0) + var point := start + segment * ratio + var distance := position.distance_to(point) + if distance < best_distance: + best_distance = distance + best_progress = travelled + segment_length * ratio + travelled += segment_length + return best_progress func _distance_to_route(position: Vector2, route: PackedVector2Array) -> float: @@ -1366,13 +1659,92 @@ func _show_status(text: String) -> void: status_timer = 2.1 +func _load_audio_settings() -> void: + var config := ConfigFile.new() + if config.load("user://void_bastion_settings.cfg") == OK: + master_volume = clampf(float(config.get_value("audio", "master_volume", master_volume)), 0.0, 1.0) + _apply_master_volume() + + +func _set_master_volume(value: float) -> void: + master_volume = clampf(value, 0.0, 1.0) + _apply_master_volume() + queue_redraw() + + +func _apply_master_volume() -> void: + var master_bus := AudioServer.get_bus_index("Master") + if master_bus >= 0: + AudioServer.set_bus_volume_db(master_bus, linear_to_db(maxf(master_volume, 0.0001))) + + +func _save_audio_settings() -> void: + var config := ConfigFile.new() + config.set_value("audio", "master_volume", master_volume) + config.save("user://void_bastion_settings.cfg") + + +func _build_ambient_stream() -> AudioStreamWAV: + const sample_rate := 22050 + const duration := 12 + var frame_count := sample_rate * duration + var samples := PackedByteArray() + samples.resize(frame_count * 4) + for frame in frame_count: + var time := float(frame) / sample_rate + var slow_breathe := 0.72 + 0.28 * sin(TAU * time / duration) + var chord := sin(TAU * 55.0 * time) * 0.34 + sin(TAU * 82.5 * time) * 0.22 + sin(TAU * 110.0 * time) * 0.16 + sin(TAU * 165.0 * time) * 0.08 + var shimmer := sin(TAU * 220.0 * time + sin(TAU * time / duration) * 0.45) * 0.035 + var mono := clampf((chord + shimmer) * slow_breathe * 0.12, -1.0, 1.0) + var left := int(mono * 32767.0) + var right := int(clampf(mono * (0.97 + 0.03 * sin(TAU * time / duration)), -1.0, 1.0) * 32767.0) + samples.encode_s16(frame * 4, left) + samples.encode_s16(frame * 4 + 2, right) + var stream := AudioStreamWAV.new() + stream.format = AudioStreamWAV.FORMAT_16_BITS + stream.mix_rate = sample_rate + stream.stereo = true + stream.loop_mode = AudioStreamWAV.LOOP_FORWARD + stream.loop_end = frame_count + stream.data = samples + return stream + + +func _build_ui_click_stream() -> AudioStreamWAV: + const sample_rate := 22050 + const duration := 0.075 + var frame_count := int(sample_rate * duration) + var samples := PackedByteArray() + samples.resize(frame_count * 2) + var phase := 0.0 + for frame in frame_count: + var time := float(frame) / sample_rate + var frequency := lerpf(1350.0, 720.0, time / duration) + phase += TAU * frequency / sample_rate + var envelope := 1.0 - time / duration + samples.encode_s16(frame * 2, int(sin(phase) * envelope * 0.14 * 32767.0)) + var stream := AudioStreamWAV.new() + stream.format = AudioStreamWAV.FORMAT_16_BITS + stream.mix_rate = sample_rate + stream.stereo = false + stream.data = samples + return stream + + +func _play_ui_sound() -> void: + if ui_sound_player.stream != null: + ui_sound_player.play() + + func _save_progress() -> void: var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE) if file: file.store_string(JSON.stringify({ "best_wave": best_wave, "best_endless_wave": best_endless_wave, - "core_crystals": core_crystals, + "core_crystals": core_crystals, + "unlocked_towers": unlocked_towers, + "unlocked_maps": unlocked_maps, "selected_difficulty": selected_difficulty, "tutorial_completed": tutorial_completed })) @@ -1389,16 +1761,34 @@ func _load_progress() -> void: best_wave = int(data.get("best_wave", 0)) best_endless_wave = int(data.get("best_endless_wave", 0)) core_crystals = maxi(0, int(data.get("core_crystals", 0))) + unlocked_towers = [0, 1, 2] + var saved_unlocks = data.get("unlocked_towers", [0, 1, 2]) + if saved_unlocks is Array: + for tower_type in saved_unlocks: + if int(tower_type) >= 0 and int(tower_type) < 3 and not unlocked_towers.has(int(tower_type)): + unlocked_towers.append(int(tower_type)) + elif int(tower_type) >= 3 and int(tower_type) < TOWER_DATA.size() and not unlocked_towers.has(int(tower_type)): + unlocked_towers.append(int(tower_type)) + unlocked_maps = [0, 1, 2] + var saved_maps = data.get("unlocked_maps", [0, 1, 2]) + if saved_maps is Array: + for map_index in saved_maps: + if int(map_index) >= 3 and int(map_index) < MAP_NAMES.size() and not unlocked_maps.has(int(map_index)): + unlocked_maps.append(int(map_index)) selected_difficulty = clampi(int(data.get("selected_difficulty", 1)), 0, DIFFICULTY_DATA.size() - 1) tutorial_completed = bool(data.get("tutorial_completed", false)) func _draw() -> void: - if game_mode in ["menu", "leaderboard", "profile", "profile_name_entry", "multiplayer_menu", "room_code_entry", "matchmaking"]: + if game_mode in ["menu", "store", "leaderboard", "profile", "profile_name_entry", "multiplayer_menu", "room_code_entry", "matchmaking"]: draw_texture_rect(MAP_TEXTURES[selected_map], Rect2(Vector2.ZERO, SCREEN), false, Color("f3f6fb")) draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.01, 0.025, 0.055, 0.06)) if game_mode == "menu": _draw_menu() + if audio_settings_open: + _draw_audio_settings() + elif game_mode == "store": + _draw_store() elif game_mode == "leaderboard": _draw_leaderboard() elif game_mode == "profile": @@ -1456,6 +1846,7 @@ func _draw_world() -> void: for radius in [94.0, 70.0, 44.0]: draw_arc(COOP_REACTOR_CENTER, radius, 0.0, TAU, 64, Color(0.28, 0.92, 1.0, 0.72), 3.0) draw_circle(COOP_REACTOR_CENTER, 25.0, Color("8ffaff")) + _draw_reactor_approach() _draw_build_grid() if selected_tower_type >= 0 and cursor_screen_position.y > PLAYFIELD_TOP and cursor_screen_position.y < PLAYFIELD_BOTTOM: var snapped_position := _snap_to_grid(cursor_position) @@ -1513,19 +1904,82 @@ func _draw_route() -> void: draw_set_transform(center - camera_offset, angle, Vector2.ONE) var variant := absi(cell.x * 3 + cell.y * 5) % 4 var ray_x := -18.0 if variant % 2 == 0 else 18.0 + var tile_opacity := _path_cell_opacity(cell) if not is_corner: - draw_colored_polygon(PackedVector2Array([Vector2(ray_x - 5, -36), Vector2(ray_x + 5, -36), Vector2(ray_x + 14, -62), Vector2(ray_x - 14, -62)]), Color(1.0, 0.48, 0.12, 0.055)) - draw_colored_polygon(PackedVector2Array([Vector2(ray_x - 5, 36), Vector2(ray_x + 5, 36), Vector2(ray_x + 14, 62), Vector2(ray_x - 14, 62)]), Color(1.0, 0.48, 0.12, 0.055)) + draw_colored_polygon(PackedVector2Array([Vector2(ray_x - 5, -36), Vector2(ray_x + 5, -36), Vector2(ray_x + 14, -62), Vector2(ray_x - 14, -62)]), Color(1.0, 0.48, 0.12, 0.055 * tile_opacity)) + draw_colored_polygon(PackedVector2Array([Vector2(ray_x - 5, 36), Vector2(ray_x + 5, 36), Vector2(ray_x + 14, 62), Vector2(ray_x - 14, 62)]), Color(1.0, 0.48, 0.12, 0.055 * tile_opacity)) var source_positions := [Vector2(80, 80), Vector2(650, 80), Vector2(80, 700), Vector2(650, 700)] var source_position: Vector2 = source_positions[variant] - draw_texture_rect_region(ROAD_SURFACE_TEXTURE, Rect2(-40, -31, 80, 62), Rect2(source_position, Vector2(520, 403))) - draw_texture_rect(texture, Rect2(-GRID_SIZE * 0.5, -GRID_SIZE * 0.5, GRID_SIZE, GRID_SIZE), false) + draw_texture_rect_region(ROAD_SURFACE_TEXTURE, Rect2(-40, -31, 80, 62), Rect2(source_position, Vector2(520, 403)), Color(1.0, 1.0, 1.0, tile_opacity)) + draw_texture_rect(texture, Rect2(-GRID_SIZE * 0.5, -GRID_SIZE * 0.5, GRID_SIZE, GRID_SIZE), false, Color(1.0, 1.0, 1.0, tile_opacity)) else: - draw_texture_rect(texture, Rect2(-GRID_SIZE * 0.5, -GRID_SIZE * 0.5, GRID_SIZE, GRID_SIZE), false) - draw_texture_rect(ROAD_CORNER_SURFACE_TEXTURE, Rect2(-GRID_SIZE * 0.5, -GRID_SIZE * 0.5, GRID_SIZE, GRID_SIZE), false) + draw_texture_rect(texture, Rect2(-GRID_SIZE * 0.5, -GRID_SIZE * 0.5, GRID_SIZE, GRID_SIZE), false, Color(1.0, 1.0, 1.0, tile_opacity)) + draw_texture_rect(ROAD_CORNER_SURFACE_TEXTURE, Rect2(-GRID_SIZE * 0.5, -GRID_SIZE * 0.5, GRID_SIZE, GRID_SIZE), false, Color(1.0, 1.0, 1.0, tile_opacity)) draw_set_transform(-camera_offset, 0.0, Vector2.ONE) +func _path_cell_opacity(cell: Vector2i) -> float: + var routes: Array = [path_points] + if coop_mode: + routes = coop_paths + var center := _cell_center(cell) + var nearest_remaining := INF + for route in routes: + if route.size() < 2: + continue + var length_after_segment := 0.0 + for segment_index in range(route.size() - 2, -1, -1): + var segment_start: Vector2 = route[segment_index] + var segment_end: Vector2 = route[segment_index + 1] + var segment := segment_end - segment_start + var segment_length := segment.length() + if segment_length > 0.0: + var direction := segment / segment_length + var distance_along := clampf((center - segment_start).dot(direction), 0.0, segment_length) + var projection := segment_start + direction * distance_along + if center.distance_to(projection) <= GRID_SIZE * 0.55: + var remaining := center.distance_to(projection) + segment_length - distance_along + length_after_segment + nearest_remaining = minf(nearest_remaining, remaining) + length_after_segment += segment_length + if nearest_remaining >= 320.0: + return 1.0 + var fade_progress := clampf(nearest_remaining / 320.0, 0.0, 1.0) + return fade_progress * fade_progress * (3.0 - 2.0 * fade_progress) + + +func _draw_reactor_approach() -> void: + var routes: Array = [path_points] + if coop_mode: + routes = coop_paths + for route in routes: + if route.size() < 2: + continue + var approach_start: Vector2 = route[route.size() - 2] + var reactor_end: Vector2 = route[route.size() - 1] + var direction := approach_start.direction_to(reactor_end) + var approach_color := Color("65eaff") + # Fade the road's cyan guide in short overlapping steps as it reaches the core. + for segment_index in 12: + var progress_start := float(segment_index) / 12.0 + var progress_end := float(segment_index + 1) / 12.0 + var fade := 1.0 - (progress_start + progress_end) * 0.5 + var fade_start := approach_start.lerp(reactor_end, progress_start) + var fade_end := approach_start.lerp(reactor_end, progress_end) + draw_line(fade_start, fade_end, Color(approach_color, 0.10 * fade), 13.0, true) + draw_line(fade_start, fade_end, Color(approach_color, 0.18 * fade), 2.0, true) + # Multiple dim pulses travel inward, like energy being carried to the core. + for pulse_index in 3: + var pulse_progress := fposmod(elapsed * 0.34 + float(pulse_index) / 3.0, 1.0) + var pulse_position := approach_start.lerp(reactor_end, pulse_progress) + var pulse_alpha := (0.22 + 0.12 * sin(pulse_progress * PI)) * (1.0 - pulse_progress * 0.72) + draw_line(pulse_position - direction * 12.0, pulse_position + direction * 12.0, Color(approach_color, pulse_alpha), 3.0, true) + draw_circle(pulse_position, 7.0, Color(approach_color, pulse_alpha * 0.20)) + draw_circle(pulse_position, 2.2, Color(0.78, 0.98, 1.0, pulse_alpha + 0.18)) + var reactor_breathe := 0.5 + 0.5 * sin(elapsed * TAU * 0.42) + draw_circle(reactor_end, 13.0 + reactor_breathe * 3.0, Color(approach_color, 0.035 + reactor_breathe * 0.025)) + draw_arc(reactor_end, 22.0 + reactor_breathe * 2.0, 0.0, TAU, 40, Color(approach_color, 0.10 + reactor_breathe * 0.08), 1.5) + + func _draw_build_grid() -> void: var line_color := Color(0.24, 0.82, 1.0, 0.14 if selected_tower_type >= 0 else 0.10) var fill_color := Color(0.08, 0.55, 0.72, 0.035 if selected_tower_type >= 0 else 0.025) @@ -1547,14 +2001,18 @@ func _draw_build_grid() -> void: func _draw_tower(tower: Dictionary) -> void: - var source_width: float = TOWER_TEXTURE.get_width() / 3.0 - var source := Rect2(source_width * tower.type, 0, source_width, TOWER_TEXTURE.get_height()) var size: float = 74.0 + (tower.level - 1) * 4.0 if coop_mode and int(tower.get("owner", 0)) == 1: draw_circle(tower.pos, size * 0.54, Color(1.0, 0.56, 0.20, 0.18)) draw_arc(tower.pos, size * 0.54, 0.0, TAU, 28, Color(1.0, 0.70, 0.34, 0.72), 2.0) draw_set_transform(tower.pos - camera_offset, tower.angle, Vector2.ONE) - draw_texture_rect_region(TOWER_TEXTURE, Rect2(-size * 0.5, -size * 0.5, size, size), source) + var destination := Rect2(-size * 0.5, -size * 0.5, size, size) + if tower.type < 3: + var source_width: float = TOWER_TEXTURE.get_width() / 3.0 + draw_texture_rect_region(TOWER_TEXTURE, destination, Rect2(source_width * tower.type, 0, source_width, TOWER_TEXTURE.get_height())) + else: + var tower_texture: Texture2D = PLASMA_TEXTURE if tower.type == 3 else (EMP_TEXTURE if tower.type == 4 else RAILGUN_TEXTURE) + draw_texture_rect(tower_texture, destination, false) draw_set_transform(-camera_offset, 0.0, Vector2.ONE) for level_mark in tower.level: draw_circle(tower.pos + Vector2(-10 + level_mark * 10, 43), 3.0, Color("6cecff")) @@ -1565,6 +2023,9 @@ func _draw_enemy(enemy: Dictionary) -> void: var source := Rect2(source_width * enemy.type, 0, source_width, ENEMY_TEXTURE.get_height()) var size: float = ENEMY_DATA[enemy.type].size * enemy.size_scale 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) draw_set_transform(enemy.pos - camera_offset, enemy.angle, Vector2.ONE) draw_texture_rect_region(ENEMY_TEXTURE, Rect2(-size * 0.5, -size * 0.5, size, size), source, tint) draw_set_transform(-camera_offset, 0.0, Vector2.ONE) @@ -1572,6 +2033,8 @@ func _draw_enemy(enemy: Dictionary) -> void: var ratio: float = clampf(enemy.hp / enemy.max_hp, 0.0, 1.0) draw_rect(Rect2(enemy.pos + Vector2(-bar_width * 0.5, -size * 0.48), Vector2(bar_width, 5)), Color("261923")) draw_rect(Rect2(enemy.pos + Vector2(-bar_width * 0.5, -size * 0.48), Vector2(bar_width * ratio, 5)), Color("ff6049")) + if bool(enemy.get("shield", false)): + draw_string(ThemeDB.fallback_font, enemy.pos + Vector2(-30, -size * 0.62), "SCHILD", HORIZONTAL_ALIGNMENT_CENTER, 60, 9, Color("75eaff")) if enemy.boss: draw_string(ThemeDB.fallback_font, enemy.pos + Vector2(-45, -size * 0.58), "KOMMANDOSCHIFF", HORIZONTAL_ALIGNMENT_CENTER, 90, 11, Color("ffab78")) @@ -1589,7 +2052,7 @@ func _draw_hud() -> void: var map_label := "KOOP-ARENA • %s %04d EN" % [partner_name, _player_credits(partner_slot)] if coop_mode else "KARTE %s" % MAP_NAMES[selected_map] draw_string(font, Vector2(715, 52), map_label, HORIZONTAL_ALIGNMENT_LEFT, -1, 15, Color("ffcf7a") if coop_mode else Color("91a8bc")) draw_string(font, Vector2(920, 34), _difficulty().name, HORIZONTAL_ALIGNMENT_LEFT, -1, 13, Color("ffcf7a")) - draw_string(font, Vector2(920, 62), "KERN %04d" % core_crystals, HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color("89f4ff")) + draw_string(font, Vector2(920, 62), "KERNKRISTALLE %04d" % core_crystals, HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color("89f4ff")) _draw_button(back_button, "ZURÜCK", false, Color("26394a")) if coop_mode: var viewing_right := camera_offset.x > 0.0 @@ -1604,7 +2067,7 @@ func _draw_hud() -> void: 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) for index in tower_buttons.size(): - _draw_tower_card(index, selected_tower_type == index) + _draw_tower_card(index, index, selected_tower_type == index) 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) @@ -1612,22 +2075,83 @@ func _draw_hud() -> void: _draw_button(sell_button, "VERKAUFEN", false, Color("6b3542")) if selected_tower_type >= 0 or selected_tower_index >= 0: _draw_tower_info_panel() + 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) + if auto_start_waves: + draw_rect(Rect2(auto_wave_button.position + Vector2(10, 7), Vector2(8, 8)), Color("65eaff"), true) + draw_string(font, auto_wave_button.position + Vector2(29, 16), "NÄCHSTE WELLE AUTO STARTEN", HORIZONTAL_ALIGNMENT_LEFT, 220, 11, Color("dceaf0")) + if speed_dropdown_open: + for speed_index in SPEED_OPTIONS.size(): + var option_rect := _speed_option_rect(speed_index) + 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 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_tower_card(type: int, active: bool) -> void: - var rect: Rect2 = tower_buttons[type] +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) + + +func _draw_tower_card(slot: int, type: int, active: bool) -> void: + var rect: Rect2 = tower_buttons[slot] var data: Dictionary = TOWER_DATA[type] var font := ThemeDB.fallback_font - draw_rect(rect, Color("226c88") if active else Color("173c55"), true) - draw_rect(rect, Color("65dfff") if active else Color("37677b"), false, 2.0) - draw_string(font, rect.position + Vector2(14, 27), data.name, HORIZONTAL_ALIGNMENT_LEFT, 170, 19, Color.WHITE) - draw_string(font, rect.position + Vector2(185, 27), "%d EN" % data.cost, HORIZONTAL_ALIGNMENT_RIGHT, 105, 16, Color("7ce7ff")) - draw_string(font, rect.position + Vector2(14, 59), "SCHADEN %.0f • %.2f SCHUSS/S" % [data.damage, _tower_shots_per_second(type, 1)], HORIZONTAL_ALIGNMENT_LEFT, 278, 14, Color("dceaf0")) - draw_string(font, rect.position + Vector2(14, 86), "REICHWEITE %.0f • %s" % [data.range, data.damage_type], HORIZONTAL_ALIGNMENT_LEFT, 278, 14, Color("9fdce9")) - draw_string(font, rect.position + Vector2(14, 109), "Antippen, dann Bauplatz wählen", HORIZONTAL_ALIGNMENT_LEFT, 278, 12, Color("719aaa")) + 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) + 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")) + 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 _draw_store() -> void: + var font := ThemeDB.fallback_font + draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.70), true) + _draw_button(shop_back_button, "< ZURÜCK", false, Color("26394a")) + draw_string(font, Vector2(0, 115), "GERGAMES-ARSENAL", HORIZONTAL_ALIGNMENT_CENTER, 1600, 42, Color("c7f5ff")) + draw_string(font, Vector2(0, 160), "Türme und Einsatzsektoren mit Kernkristallen freischalten", HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("8feeff")) + draw_string(font, Vector2(1270, 55), "KERNKRISTALLE %04d" % core_crystals, HORIZONTAL_ALIGNMENT_RIGHT, 270, 20, Color("8ff4ff")) + _draw_button(store_tower_tab_button, "TÜRME", store_tab == "towers", Color("24475b")) + _draw_button(store_map_tab_button, "KARTEN", store_tab == "maps", Color("24475b")) + if store_tab == "maps": + for slot in map_shop_purchase_buttons.size(): + var map_index := slot + 3 + var button: Rect2 = map_shop_purchase_buttons[slot] + var card := Rect2(button.position.x, 225, 350, 405) + var is_unlocked := unlocked_maps.has(map_index) + draw_rect(card, Color(0.012, 0.04, 0.07, 0.94), true) + draw_rect(card, Color("65dfff", 0.72), false, 2.0) + draw_texture_rect(MAP_TEXTURES[map_index], Rect2(card.position + Vector2(12, 12), Vector2(326, 176)), false) + draw_string(font, card.position + Vector2(10, 222), MAP_NAMES[map_index], HORIZONTAL_ALIGNMENT_CENTER, 330, 17, Color("9feeff")) + draw_string(font, card.position + Vector2(10, 250), MAP_DESCRIPTIONS[map_index], HORIZONTAL_ALIGNMENT_CENTER, 330, 13, Color("dceaf0")) + draw_string(font, card.position + Vector2(10, 282), "80-PX-RASTER • DIREKTZIEL REAKTOR", HORIZONTAL_ALIGNMENT_CENTER, 330, 10, Color("8fddef")) + var button_text := "FREIGESCHALTET" if is_unlocked else "FREISCHALTEN • %d KERNKRISTALLE" % int(MAP_UNLOCK_COSTS[map_index]) + _draw_button(button, button_text, is_unlocked, Color("24516a") if not is_unlocked else Color("245843")) + draw_string(font, Vector2(0, 685), "Jeder Sektor hat einen eigenen, rastergenauen Laufweg bis in den Reaktor.", HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("ffcf7a")) + return + var textures: Array[Texture2D] = [PLASMA_TEXTURE, EMP_TEXTURE, RAILGUN_TEXTURE] + for slot in shop_purchase_buttons.size(): + var tower_type := slot + 3 + var card := Rect2(shop_purchase_buttons[slot].position.x, 215, 360, 400) + var data: Dictionary = TOWER_DATA[tower_type] + draw_rect(card, Color(0.012, 0.04, 0.07, 0.94), true) + draw_rect(card, Color(data.color, 0.72), false, 2.0) + 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, 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) + _draw_button(shop_purchase_buttons[slot], button_text, is_unlocked, Color("24516a") if not is_unlocked else Color("245843")) + draw_string(font, Vector2(0, 685), "Weitere Verteidigungen lassen sich dauerhaft mit Kernkristallen freischalten.", HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("ffcf7a")) func _draw_tower_info_panel() -> void: @@ -1674,31 +2198,66 @@ func _draw_menu() -> void: draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.52)) draw_string(font, Vector2(0, 145), "VOID BASTION", HORIZONTAL_ALIGNMENT_CENTER, 1600, 64, Color("c7f5ff")) draw_string(font, Vector2(0, 202), "ORBITAL DEFENSE PROTOCOL", HORIZONTAL_ALIGNMENT_CENTER, 1600, 23, Color("56cce9")) - draw_string(font, Vector2(250, 270), "Wähle einen Einsatzsektor und verteidige den Reaktorkern.", HORIZONTAL_ALIGNMENT_CENTER, 1100, 21, Color("d7e2ea")) _draw_button(profile_button, "%s • PROFIL" % account_manager.display_name, false, Color("233f57")) - draw_string(font, Vector2(0, 375), "EINSATZKARTE WÄHLEN", HORIZONTAL_ALIGNMENT_CENTER, 1600, 20, Color("8feeff")) - for index in map_buttons.size(): - var card: Rect2 = map_buttons[index] - draw_texture_rect(MAP_TEXTURES[index], card, false, Color(0.76, 0.82, 0.88, 1.0)) - draw_rect(card, Color(0.005, 0.015, 0.035, 0.30), true) - draw_rect(card, Color("70eaff") if selected_map == index else Color("436574"), false, 4.0 if selected_map == index else 2.0) - draw_rect(Rect2(card.position + Vector2(0, 92), Vector2(card.size.x, 53)), Color(0.01, 0.025, 0.05, 0.90), true) - draw_string(font, card.position + Vector2(0, 116), MAP_NAMES[index], HORIZONTAL_ALIGNMENT_CENTER, card.size.x, 18, Color.WHITE) - draw_string(font, card.position + Vector2(0, 138), MAP_DESCRIPTIONS[index], HORIZONTAL_ALIGNMENT_CENTER, card.size.x, 13, Color("8fddef")) - draw_string(font, Vector2(0, 600), "SCHWIERIGKEIT • LEICHT ENTSPRICHT DEM BISHERIGEN BALANCING", HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, Color("9fdce9")) - for index in difficulty_buttons.size(): - _draw_button(difficulty_buttons[index], DIFFICULTY_DATA[index].name, selected_difficulty == index, Color("27394d")) - draw_string(font, Vector2(0, 686), DIFFICULTY_DESCRIPTIONS[selected_difficulty], HORIZONTAL_ALIGNMENT_CENTER, 1600, 14, Color("ffcf7a")) - _draw_button(start_button, "MISSION STARTEN", false, Color("18556f")) + if menu_step == "map": + draw_string(font, Vector2(0, 300), "EINSATZKARTE WÄHLEN • GESPERRTE SEKTOREN IM SHOP FREISCHALTEN", HORIZONTAL_ALIGNMENT_CENTER, 1600, 20, Color("8feeff")) + for index in map_buttons.size(): + var card: Rect2 = map_buttons[index] + var map_index := index + var is_unlocked := unlocked_maps.has(map_index) + draw_texture_rect(MAP_TEXTURES[map_index], card, false, Color(0.82, 0.88, 0.94, 1.0) if is_unlocked else Color(0.38, 0.46, 0.54, 1.0)) + draw_rect(card, Color(0.005, 0.015, 0.035, 0.22 if is_unlocked else 0.48), true) + draw_rect(card, Color("70eaff") if selected_map == map_index and is_unlocked else Color("436574"), false, 4.0 if selected_map == map_index and is_unlocked else 2.0) + var footer_y := card.size.y - 52.0 + draw_rect(Rect2(card.position + Vector2(0, footer_y), Vector2(card.size.x, 52)), Color(0.01, 0.025, 0.05, 0.88), true) + draw_string(font, card.position + Vector2(0, footer_y + 21), MAP_NAMES[map_index], HORIZONTAL_ALIGNMENT_CENTER, card.size.x, 14, Color.WHITE) + var description: String = MAP_DESCRIPTIONS[map_index] if is_unlocked else "GESPERRT • %d KERNKRISTALLE" % int(MAP_UNLOCK_COSTS[map_index]) + draw_string(font, card.position + Vector2(0, footer_y + 42), description, HORIZONTAL_ALIGNMENT_CENTER, card.size.x, 11, Color("8fddef") if is_unlocked else Color("ffcf7a")) + draw_string(font, Vector2(0, 720), "Wähle einen Sektor, um Schwierigkeit und Missionsstart festzulegen.", HORIZONTAL_ALIGNMENT_CENTER, 1600, 16, Color("d7e2ea")) + else: + _draw_button(map_step_back_button, "< KARTE", false, Color("26394a")) + var selected_card := Rect2(500, 315, 600, 145) + draw_texture_rect(MAP_TEXTURES[selected_map], selected_card, false, Color(0.86, 0.90, 0.95, 1.0)) + draw_rect(selected_card, Color(0.005, 0.015, 0.035, 0.34), true) + draw_rect(selected_card, Color("70eaff"), false, 3.0) + draw_rect(Rect2(selected_card.position + Vector2(0, 92), Vector2(selected_card.size.x, 53)), Color(0.01, 0.025, 0.05, 0.88), true) + draw_string(font, selected_card.position + Vector2(0, 116), MAP_NAMES[selected_map], HORIZONTAL_ALIGNMENT_CENTER, selected_card.size.x, 18, Color.WHITE) + draw_string(font, selected_card.position + Vector2(0, 138), MAP_DESCRIPTIONS[selected_map], HORIZONTAL_ALIGNMENT_CENTER, selected_card.size.x, 13, Color("8fddef")) + draw_string(font, Vector2(0, 490), "SCHWIERIGKEIT WÄHLEN", HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("9fdce9")) + for index in difficulty_buttons.size(): + _draw_button(difficulty_buttons[index], DIFFICULTY_DATA[index].name, selected_difficulty == index, Color("27394d")) + draw_string(font, Vector2(0, 603), DIFFICULTY_DESCRIPTIONS[selected_difficulty], HORIZONTAL_ALIGNMENT_CENTER, 1600, 14, Color("ffcf7a")) + _draw_button(start_button, "MISSION STARTEN", false, Color("18556f")) + draw_string(font, Vector2(0, 755), "KARTENBONUS %d • BESTE WELLE %02d • KERNKRISTALLE %04d" % [int(_difficulty().crystals), best_wave, core_crystals], HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, Color("86c8d8")) _draw_button(leaderboard_button, "BESTENLISTE", false, Color("24475b")) _draw_button(multiplayer_button, "MULTIPLAYER", false, Color("254f75")) - draw_string(font, Vector2(0, 792), "BESTE WELLE %02d • KERNKRISTALLE %04d • KARTENBONUS %d" % [best_wave, core_crystals, int(_difficulty().crystals)], HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("86c8d8")) + _draw_button(shop_button, "ARSENAL / SHOP", false, Color("24516a")) + _draw_button(audio_settings_button, "EINSTELLUNGEN", false, Color("24516a")) + _draw_button(exit_button, "SPIEL BEENDEN", false, Color("5c3342")) draw_string(font, Vector2(0, 860), "VERSION %s • GERGAMES-ONLINEKONTO • GLOBALE BESTENLISTEN" % _app_version(), HORIZONTAL_ALIGNMENT_CENTER, 1600, 14, Color("688293")) if update_notice_visible: _draw_button(update_button, "UPDATE %s VERFÜGBAR" % str(available_update.get("version", "")), true, Color("1f5d4d")) _draw_button(update_dismiss_button, "×", false, Color("3d4650")) +func _draw_audio_settings() -> void: + var font := ThemeDB.fallback_font + draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.002, 0.008, 0.02, 0.62), true) + draw_rect(audio_panel_rect, Color(0.01, 0.035, 0.065, 0.98), true) + draw_rect(audio_panel_rect, Color("4ab8d1"), false, 2.0) + draw_string(font, audio_panel_rect.position + Vector2(30, 54), "AUDIOEINSTELLUNGEN", HORIZONTAL_ALIGNMENT_LEFT, 450, 25, Color("bcefff")) + _draw_button(audio_close_button, "SCHLIESSEN", false, Color("26394a")) + draw_string(font, Vector2(audio_slider_rect.position.x, audio_slider_rect.position.y - 18), "MASTER-LAUTSTÄRKE", HORIZONTAL_ALIGNMENT_LEFT, 260, 16, Color("dceaf0")) + draw_string(font, Vector2(audio_slider_rect.end.x - 80, audio_slider_rect.position.y - 18), "%d%%" % roundi(master_volume * 100.0), HORIZONTAL_ALIGNMENT_RIGHT, 80, 16, Color("8ff4ff")) + var track := Rect2(audio_slider_rect.position.x, audio_slider_rect.position.y + 13, audio_slider_rect.size.x, 6) + draw_rect(track, Color("24475b"), true) + draw_rect(Rect2(track.position, Vector2(track.size.x * master_volume, track.size.y)), Color("45d4ee"), true) + var knob_position := Vector2(track.position.x + track.size.x * master_volume, track.position.y + track.size.y * 0.5) + draw_circle(knob_position, 13, Color("0b1d2c")) + draw_circle(knob_position, 9, Color("8ff4ff")) + draw_string(font, Vector2(audio_panel_rect.position.x + 30, audio_panel_rect.end.y - 28), "Die Einstellung wird automatisch gespeichert.", HORIZONTAL_ALIGNMENT_LEFT, audio_panel_rect.size.x - 60, 13, Color("91a8bc")) + + func _draw_leaderboard() -> void: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.84)) @@ -1739,6 +2298,7 @@ func _draw_leaderboard() -> void: if not leaderboard_message.is_empty(): draw_string(font, Vector2(300, 520), leaderboard_message, HORIZONTAL_ALIGNMENT_CENTER, 1000, 17, Color("ffcf7a")) draw_string(font, Vector2(0, 810), "Spielernamen werden aus dem aktuellen öffentlichen Profil geladen.", HORIZONTAL_ALIGNMENT_CENTER, 1600, 14, Color("91a8bc")) + draw_string(font, Vector2(0, 838), "WERTUNG: WELLEN + REAKTORREST + RESTENERGIE • WENIGER GEBAUTE TÜRME ERHÖHEN DEN EFFIZIENZ-MULTIPLIKATOR", HORIZONTAL_ALIGNMENT_CENTER, 1600, 12, Color("75aaba")) func _draw_multiplayer_menu() -> void: @@ -1900,13 +2460,13 @@ func _draw_profile() -> void: draw_string(font, Vector2(930, 430), "ENDLOS", HORIZONTAL_ALIGNMENT_CENTER, 180, 14, Color("91a8bc")) draw_string(font, Vector2(1160, 430), "BESTE PUNKTE", HORIZONTAL_ALIGNMENT_CENTER, 180, 14, Color("91a8bc")) for index in MAP_NAMES.size(): - var y := 482.0 + index * 62.0 - draw_rect(Rect2(210, y - 30, 1180, 48), Color(0.03, 0.09, 0.14, 0.70), true) - draw_string(font, Vector2(235, y), MAP_NAMES[index], HORIZONTAL_ALIGNMENT_LEFT, 420, 17, Color.WHITE) - draw_string(font, Vector2(700, y), "WELLE %02d" % account_manager.get_map_best_wave(index, false), HORIZONTAL_ALIGNMENT_CENTER, 180, 16, Color("8ff4ff")) - draw_string(font, Vector2(930, y), "WELLE %02d" % account_manager.get_map_best_wave(index, true), HORIZONTAL_ALIGNMENT_CENTER, 180, 16, Color("ffcf7a")) + var y := 466.0 + index * 28.0 + draw_rect(Rect2(210, y - 19, 1180, 25), Color(0.03, 0.09, 0.14, 0.70), true) + draw_string(font, Vector2(235, y), MAP_NAMES[index], HORIZONTAL_ALIGNMENT_LEFT, 420, 12, Color.WHITE) + draw_string(font, Vector2(700, y), "WELLE %02d" % account_manager.get_map_best_wave(index, false), HORIZONTAL_ALIGNMENT_CENTER, 180, 12, Color("8ff4ff")) + draw_string(font, Vector2(930, y), "WELLE %02d" % account_manager.get_map_best_wave(index, true), HORIZONTAL_ALIGNMENT_CENTER, 180, 12, Color("ffcf7a")) var score := maxi(account_manager.get_map_best_score(index, false), account_manager.get_map_best_score(index, true)) - draw_string(font, Vector2(1160, y), "%07d" % score, HORIZONTAL_ALIGNMENT_CENTER, 180, 16, Color("8fffea")) + draw_string(font, Vector2(1160, y), "%07d" % score, HORIZONTAL_ALIGNMENT_CENTER, 180, 12, Color("8fffea")) if not profile_message.is_empty(): draw_string(font, Vector2(220, 658), profile_message, HORIZONTAL_ALIGNMENT_CENTER, 1160, 14, Color("ffcf7a")) _draw_button(profile_support_button, "SUPPORT / FEEDBACK", false, Color("24516a")) @@ -1941,6 +2501,7 @@ func _draw_game_over() -> void: draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.05, 0.005, 0.015, 0.76)) draw_string(font, Vector2(0, 330), "REAKTOR VERLOREN", HORIZONTAL_ALIGNMENT_CENTER, 1600, 48, Color("ff8b78")) draw_string(font, Vector2(0, 395), "Erreichte Welle: %d" % wave, HORIZONTAL_ALIGNMENT_CENTER, 1600, 22, Color.WHITE) + draw_string(font, Vector2(0, 430), "Highscore: %d • Effizienzbonus: ×%.2f" % [_calculate_run_score(), clampf(1.0 + (10.0 - towers_built_this_run) * 0.05, 0.50, 1.50)], HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, Color("d6eaf0")) _draw_button(start_button, "NEUER VERSUCH", false, Color("6a2835")) @@ -1951,7 +2512,8 @@ func _draw_victory() -> void: draw_string(font, Vector2(0, 375), "Alle %d Angriffswellen wurden abgewehrt." % MAX_WAVES, HORIZONTAL_ALIGNMENT_CENTER, 1600, 22, Color.WHITE) draw_string(font, Vector2(0, 415), "Der Orbitalkern bleibt unter unserer Kontrolle.", HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("8adff2")) draw_string(font, Vector2(0, 458), "+%d KERNKRISTALLE • GUTHABEN %d" % [last_mission_reward, core_crystals], HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("8fffea")) - _draw_button(start_button, "MISSION WIEDERHOLEN", false, Color("185f68")) + draw_string(font, Vector2(0, 492), "Highscore: %d • Effizienzbonus: ×%.2f" % [_calculate_run_score(), clampf(1.0 + (10.0 - towers_built_this_run) * 0.05, 0.50, 1.50)], HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, Color("d6eaf0")) + _draw_button(start_button, "ZUM HAUPTMENÜ", false, Color("185f68")) func _draw_endless_offer() -> void: @@ -1960,9 +2522,12 @@ func _draw_endless_offer() -> void: draw_string(font, Vector2(0, 280), "SEKTOR GESICHERT", HORIZONTAL_ALIGNMENT_CENTER, 1600, 50, Color("8fffea")) draw_string(font, Vector2(0, 345), "Alle %d regulären Wellen wurden abgewehrt." % MAX_WAVES, HORIZONTAL_ALIGNMENT_CENTER, 1600, 22, Color.WHITE) draw_string(font, Vector2(0, 405), "Möchtest du den Einsatz im immer schwereren Endlosmodus fortsetzen?", HORIZONTAL_ALIGNMENT_CENTER, 1600, 19, Color("8adff2")) - draw_string(font, Vector2(0, 450), "+%d Kernkristalle erhalten. Ein späteres Ende gilt nicht als gescheitert." % last_mission_reward, HORIZONTAL_ALIGNMENT_CENTER, 1600, 16, Color("8fffea")) - _draw_button(endless_button, "ENDLOSMODUS STARTEN", false, Color("215b68")) - _draw_button(finish_mission_button, "MISSION BEENDEN", false, Color("24394c")) + if victory_reward_pending: + draw_string(font, Vector2(0, 450), "WERBUNG WIRD GELADEN • KERNKRISTALLE DANACH: %d" % int(_difficulty().crystals), HORIZONTAL_ALIGNMENT_CENTER, 1600, 16, Color("ffcf7a")) + else: + draw_string(font, Vector2(0, 450), "+%d Kernkristalle gutgeschrieben. Ein späteres Ende gilt nicht als gescheitert." % last_mission_reward, HORIZONTAL_ALIGNMENT_CENTER, 1600, 16, Color("8fffea")) + _draw_button(endless_button, "ENDLOSMODUS STARTEN", false, Color("215b68")) + _draw_button(finish_mission_button, "MISSION BEENDEN", false, Color("24394c")) func _draw_endless_result() -> void: @@ -1972,6 +2537,7 @@ func _draw_endless_result() -> void: draw_string(font, Vector2(0, 382), "Erreichte Welle: %d" % wave, HORIZONTAL_ALIGNMENT_CENTER, 1600, 23, Color.WHITE) draw_string(font, Vector2(0, 425), "Der reguläre Sektorauftrag bleibt erfolgreich abgeschlossen.", HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("8adff2")) draw_string(font, Vector2(0, 462), "KERNKRISTALLE %d" % core_crystals, HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("8fffea")) + draw_string(font, Vector2(0, 492), "Highscore: %d • Effizienzbonus: ×%.2f" % [_calculate_run_score(), clampf(1.0 + (10.0 - towers_built_this_run) * 0.05, 0.50, 1.50)], HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, Color("d6eaf0")) _draw_button(start_button, "NEUER EINSATZ", false, Color("18556f")) diff --git a/main.tscn b/main.tscn index 0172153..8650e11 100644 --- a/main.tscn +++ b/main.tscn @@ -24,3 +24,9 @@ script = ExtResource("5") [node name="UpdateManager" type="Node" parent="."] script = ExtResource("6") + +[node name="MusicPlayer" type="AudioStreamPlayer" parent="."] +bus = &"Master" + +[node name="UiSoundPlayer" type="AudioStreamPlayer" parent="."] +bus = &"Master" diff --git a/online_manager.gd b/online_manager.gd index 3006f2d..508da9b 100644 --- a/online_manager.gd +++ b/online_manager.gd @@ -422,7 +422,7 @@ func is_online() -> bool: func make_leaderboard_id(map_index: int, difficulty_index: int, mode: String) -> String: var safe_mode := mode if mode in ["mission", "endless", "coop_ai", "coop_online"] else "mission" - return "%s_map%d_difficulty%d" % [safe_mode, clampi(map_index, 0, 2), clampi(difficulty_index, 0, 2)] + return "%s_map%d_difficulty%d" % [safe_mode, clampi(map_index, 0, 6), clampi(difficulty_index, 0, 2)] func _parse_rpc_result(result) -> Dictionary: diff --git a/packaging/windows/installer.nsi b/packaging/windows/installer.nsi index 60ee3c8..68280b6 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.3" +!define PRODUCT_VERSION "0.1.0-alpha.4" !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.3" +VIProductVersion "0.1.0.4" 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 d4450b1..3bc0be6 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.3" +config/version="0.1.0-alpha.4" run/main_scene="res://main.tscn" config/features=PackedStringArray("4.7") config/icon="res://assets/app_icon.png" @@ -29,10 +29,12 @@ Nakama="*res://addons/com.heroiclabs.nakama/Nakama.gd" window/size/viewport_width=1600 window/size/viewport_height=900 +window/size/mode=3 window/size/window_width_override=1440 window/size/window_height_override=810 window/stretch/mode="canvas_items" window/stretch/aspect="expand" +window/handheld/orientation=4 [editor_plugins] @@ -42,3 +44,4 @@ enabled=PackedStringArray("res://addons/agent_tools/plugin.cfg", "res://addons/A renderer/rendering_method="gl_compatibility" renderer/rendering_method.mobile="gl_compatibility" +textures/vram_compression/import_etc2_astc=true diff --git a/server/nakama/runtime/build/index.js b/server/nakama/runtime/build/index.js index 3fca5fd..4654d0b 100644 --- a/server/nakama/runtime/build/index.js +++ b/server/nakama/runtime/build/index.js @@ -1,5 +1,5 @@ var GAME_VERSION = "0.23"; -var MAP_COUNT = 3; +var MAP_COUNT = 7; var DIFFICULTY_COUNT = 3; var VALID_MODES = ["mission", "endless", "coop_ai", "coop_online"]; var MATCH_MODULE = "void_bastion_coop"; @@ -19,7 +19,10 @@ 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: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} ]; var ENEMIES = [{hp:70,speed:105,reward:18},{hp:185,speed:72,reward:34},{hp:520,speed:43,reward:78}]; var DIFFICULTIES = [ @@ -335,12 +338,13 @@ function updateTowers(state,delta) { var t=state.towers[i]; t.cooldown-=delta; var range=towerRange(t),targetIndex=-1,best=-1; for (var j=0;jbest) { best=p; targetIndex=j; } } if (targetIndex<0) continue; - var target=state.enemies[targetIndex]; t.angle=Math.atan2(target.y-t.y,target.x-t.x)+(t.type===0?Math.PI:0); + 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) { + 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 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)<=76*76) { state.enemies[k].hp-=state.enemies[k].id===targetId?damage:damage*0.45; if (state.enemies[k].hp<=0) removeEnemy(state,k,t.owner); } + 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); } t.cooldown=towerCooldown(t); } diff --git a/tests/smoke_test.gd b/tests/smoke_test.gd index 4ac58c4..7b857b9 100644 --- a/tests/smoke_test.gd +++ b/tests/smoke_test.gd @@ -29,6 +29,51 @@ func _run() -> void: var ads_manager = game.get_node("AdsManager") var account_manager = game.get_node("AccountManager") var multiplayer_manager = game.get_node("MultiplayerManager") + 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.") + game.set("unlocked_maps", [0, 1, 2]) + assert(game.get("TOWER_DATA").size() == 6, "Das Arsenal muss sechs unterschiedliche Turmtypen enthalten.") + assert(game.get("tower_buttons").size() == 6, "Alle sechs Türme müssen gleichzeitig in der unteren Baumenüleiste sichtbar sein.") + game.call("_handle_pointer", Vector2(760, 815), false) + assert(game.get("game_mode") == "store", "Der interne Shop muss direkt aus dem Hauptmenü erreichbar sein.") + await process_frame + game.set("core_crystals", 24) + game.call("_handle_pointer", game.get("store_map_tab_button").get_center(), false) + assert(game.get("store_tab") == "maps", "Der Shop muss eine eigene Kartenseite haben.") + game.call("_handle_pointer", game.get("map_shop_purchase_buttons")[0].get_center(), false) + assert(game.get("unlocked_maps").has(3) and game.get("core_crystals") == 0, "Neue Karten müssen dauerhaft mit Kernkristallen freischaltbar sein.") + game.set("unlocked_maps", [0, 1, 2]) + game.set("core_crystals", 0) + game.call("_handle_pointer", Vector2(140, 60), false) + assert(game.get("game_mode") == "menu", "Der Shop benötigt einen funktionierenden Zurück-Button.") + game.call("_handle_pointer", game.get("map_buttons")[3].get_center(), false) + assert(game.get("game_mode") == "store" and game.get("store_tab") == "maps", "Eine gesperrte Karte muss direkt den Karten-Shop öffnen.") + game.set("core_crystals", 24) + game.call("_handle_pointer", game.get("map_shop_purchase_buttons")[0].get_center(), false) + assert(game.get("unlocked_maps").has(3), "Die erste zusätzliche Karte muss kaufbar sein.") + game.call("_handle_pointer", game.get("shop_back_button").get_center(), false) + assert(game.get("game_mode") == "menu", "Nach dem Kartenkauf muss die Kartenauswahl erreichbar bleiben.") + game.call("_handle_pointer", game.get("map_buttons")[3].get_center(), false) + assert(game.get("menu_step") == "difficulty" and game.get("selected_map") == 3, "Eine freigeschaltete Karte muss danach spielbar sein.") + game.call("_handle_pointer", game.get("map_step_back_button").get_center(), false) + game.set("unlocked_maps", [0, 1, 2]) + game.set("core_crystals", 0) + game.call("_select_map", 0) + game.call("_handle_pointer", game.get("start_button").get_center(), false) + assert(game.get("game_mode") == "menu", "Die Mission darf erst nach Auswahl einer Karte gestartet werden.") + var menu_map_index: int = game.get("selected_map") + game.call("_handle_pointer", game.get("map_buttons")[menu_map_index].get_center(), false) + assert(game.get("menu_step") == "difficulty", "Nach Auswahl einer Karte muss die Schwierigkeitsauswahl erscheinen.") + game.call("_handle_pointer", game.get("map_step_back_button").get_center(), false) + assert(game.get("menu_step") == "map", "Die Missionsvorbereitung benötigt eine Rückkehr zur Kartenauswahl.") + game.set("unlocked_towers", [0, 1, 2]) + game.set("game_mode", "playing") + game.call("_handle_pointer", game.get("tower_buttons")[3].get_center(), false) + assert(game.get("game_mode") == "store", "Ein gesperrter Turm muss den Shop auch aus einer laufenden Mission öffnen.") + game.call("_handle_pointer", game.get("shop_back_button").get_center(), false) + assert(game.get("game_mode") == "playing", "Der Shop-Zurück-Button muss in die laufende Mission zurückführen.") + game.set("game_mode", "menu") multiplayer_manager.reset_coop_session(500, 30) assert(multiplayer_manager.player_credits == [500, 500], "Beide Koop-Spieler benötigen getrennte, gleich hohe Startressourcen.") assert(multiplayer_manager.spend_player_credits(0, 120), "Spieler 1 muss aus seinem eigenen Guthaben bauen können.") @@ -42,7 +87,7 @@ func _run() -> void: assert(multiplayer_manager.matchmaking_state == "waiting_for_service", "Die Spielersuche benötigt einen sichtbaren vorbereiteten Zustand.") multiplayer_manager.cancel_search() assert(multiplayer_manager.matchmaking_state == "idle", "Eine laufende Spielersuche muss abgebrochen werden können.") - game.call("_handle_pointer", Vector2(1100, 730), false) + game.call("_handle_pointer", Vector2(500, 815), false) assert(game.get("game_mode") == "multiplayer_menu", "Der Multiplayerbereich muss direkt aus dem Hauptmenü erreichbar sein.") game.call("_handle_pointer", Vector2(400, 695), false) assert(game.get("game_mode") == "matchmaking", "Schnelles Spiel muss in den vorbereiteten Suchzustand wechseln.") @@ -51,7 +96,7 @@ func _run() -> void: game.call("_handle_pointer", Vector2(140, 60), false) assert(game.get("game_mode") == "menu", "Der Multiplayerbereich benötigt einen funktionierenden Zurück-Button.") game.set("selected_difficulty", 0) - game.call("_handle_pointer", Vector2(1100, 730), false) + game.call("_handle_pointer", Vector2(500, 815), false) game.call("_handle_pointer", Vector2(1300, 695), false) assert(game.get("game_mode") == "playing" and game.get("coop_mode") and game.get("coop_ai_mode"), "Der KI-Koop-Modus muss direkt spielbar gestartet werden können.") assert(game.get("coop_paths").size() == 2, "Die Koop-Arena benötigt zwei gespiegelte Gegnerwege.") @@ -60,6 +105,7 @@ func _run() -> void: assert(game.get("towers").size() >= 2, "Der Computergegner muss selbstständig eine Startverteidigung bauen.") for ai_tower in game.get("towers"): assert(int(ai_tower.get("owner", -1)) == 1 and ai_tower.pos.x > 1480.0, "KI-Türme dürfen nur auf der vollständigen gespiegelten KI-Seite stehen.") + assert(game.call("_distance_to_route", ai_tower.pos, game.get("coop_paths")[1]) <= 200.0, "KI-Türme müssen innerhalb ihrer effektiven Reichweite am Gegnerweg stehen.") assert(multiplayer_manager.player_credits[0] > multiplayer_manager.player_credits[1], "Die KI muss ihre Baukosten ausschließlich aus dem eigenen Guthaben bezahlen.") assert(game.call("_is_valid_build_position", Vector2(440, 200), 0), "Der Spieler benötigt gültige Bauplätze auf der linken Kartenhälfte.") assert(game.call("_is_valid_build_position", Vector2(1240, 200), 0), "Die eigene Koop-Seite muss nahezu die volle Breite einer Einzelspielerkarte bieten.") @@ -77,11 +123,11 @@ func _run() -> void: game.call("_process", 0.1) assert(game.get("next_enemy_id") > 3, "Beide Koop-Routen müssen auch während der laufenden Simulation Gegner erzeugen.") game.call("_return_to_menu") - game.call("_handle_pointer", Vector2(400, 735), false) + game.call("_handle_pointer", Vector2(225, 815), false) assert(game.get("game_mode") == "leaderboard", "Die globale Bestenliste muss direkt aus dem Hauptmenü erreichbar sein.") var leaderboard_map_before: int = game.get("leaderboard_map") game.call("_handle_pointer", Vector2(750, 175), false) - assert(game.get("leaderboard_map") == wrapi(leaderboard_map_before + 1, 0, 3), "Die Rangliste muss direkt nach Karte filterbar sein.") + assert(game.get("leaderboard_map") == wrapi(leaderboard_map_before + 1, 0, game.get("MAP_NAMES").size()), "Die Rangliste muss direkt nach Karte filterbar sein.") var leaderboard_difficulty_before: int = game.get("leaderboard_difficulty") game.call("_handle_pointer", Vector2(1225, 175), false) assert(game.get("leaderboard_difficulty") == wrapi(leaderboard_difficulty_before + 1, 0, 3), "Die Rangliste muss direkt nach Schwierigkeitsgrad filterbar sein.") @@ -89,10 +135,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.3", "Profil, Installer und Online-Übertragung müssen dieselbe Alpha-Version verwenden.") + assert(game.call("_app_version") == "0.1.0-alpha.4", "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.3") 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.4") 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.") @@ -105,23 +151,15 @@ func _run() -> void: assert(account_manager.record_run(0, 0, false, 3, previous_test_score + 1000), "Ein neuer lokaler Highscore muss gespeichert werden.") 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.") - ads_manager.missions_since_interstitial = 0 - ads_manager.last_interstitial_time = 0 - assert(not ads_manager.is_interstitial_due(1000), "Vor drei Missionen darf keine Vollbildwerbung fällig sein.") - ads_manager.missions_since_interstitial = 3 - assert(ads_manager.is_interstitial_due(1000), "Frühestens nach drei Missionen darf eine Vollbildwerbung fällig sein.") - ads_manager.last_interstitial_time = 800 - assert(not ads_manager.is_interstitial_due(1000), "Die 15-Minuten-Sperre zwischen Vollbildanzeigen muss greifen.") - ads_manager.last_interstitial_time = 0 var update_manager = game.get_node("UpdateManager") - assert(update_manager.call("_is_newer_version", "0.1.0-alpha.3", "0.1.0-alpha.2"), "Eine neuere Alpha-Version muss als Update erkannt werden.") + assert(update_manager.call("_is_newer_version", "0.1.0-alpha.4", "0.1.0-alpha.3"), "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.3", "0.1.0-alpha.3"), "Die installierte Version darf nicht als Update angeboten werden.") + assert(not update_manager.call("_is_newer_version", "0.1.0-alpha.4", "0.1.0-alpha.4"), "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.4", "name": "Alpha.4", "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.5", "name": "Alpha.5", "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.4", "Die Update-Prüfung muss eine neuere öffentliche Version ankündigen.") + assert(announced_update.get("version") == "0.1.0-alpha.5", "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") @@ -149,15 +187,29 @@ func _run() -> void: assert(not game.call("_is_valid_build_position", Vector2(600, 680)), "Auf der unteren Helios-Fahrbahn darf nicht gebaut werden.") assert(game.call("_is_in_reactor_zone", Vector2(1480, 440)), "Der Helios-Reaktor muss als Sperrzone erkannt werden.") assert(not game.call("_is_valid_build_position", Vector2(1480, 440)), "Auf dem Helios-Reaktor darf nicht gebaut werden.") + for map_index in game.get("MAP_NAMES").size(): + game.call("_select_map", map_index) + var current_path: PackedVector2Array = game.get("path_points") + _assert_grid_aligned_path(current_path, str(game.get("MAP_NAMES")[map_index])) + assert(game.call("_validate_route_ends_at_reactor", current_path, map_index), "Jede neue Karte muss exakt am sichtbaren Reaktor enden.") + assert(current_path.size() >= 7, "Jede Karte braucht einen ausreichend langen, gestalteten Laufweg.") + assert(game.call("_is_in_reactor_zone", current_path[-1]), "Der Zielpunkt muss in der Reaktorsperrzone liegen.") + for cell in game.get("path_cells"): + var cell_position: Vector2i = cell + var neighbors := 0 + for direction in [Vector2i.LEFT, Vector2i.RIGHT, Vector2i.UP, Vector2i.DOWN]: + if game.get("path_cells").has(cell_position + direction): + neighbors += 1 + assert(neighbors <= 2, "%s hat an Rasterzelle %s eine Wegkreuzung." % [game.get("MAP_NAMES")[map_index], cell_position]) game.call("_select_map", 0) assert(game.call("_is_in_reactor_zone", Vector2(1480, 440)), "Der Bastion-Reaktor muss als Sperrzone erkannt werden.") assert(not game.call("_is_valid_build_position", Vector2(1480, 440)), "Auf dem Bastion-Reaktor darf nicht gebaut werden.") assert(not game.call("_is_valid_build_position", Vector2(40, 680)), "Im freien Weltraum darf kein Bauplatz liegen.") game.call("_select_map", 2) - assert(game.get("path_points").size() == 8, "Pyrax sollte eine klar sichtbare Reaktoreinfahrt besitzen.") + assert(game.get("path_points").size() == 7, "Pyrax sollte ohne die beiden unnötigen letzten Bögen in den Reaktor führen.") _assert_grid_aligned_path(game.get("path_points"), "Pyrax-Raffinerie") assert(game.call("_validate_route_ends_at_reactor", game.get("path_points"), 2), "Die Pyrax-Route muss exakt im Reaktormittelpunkt enden.") - assert(_path_length(game.get("path_points")) >= 2480.0, "Die Pyrax-Route sollte den Bauraum weiträumig erschließen.") + assert(_path_length(game.get("path_points")) >= 2300.0, "Die Pyrax-Route sollte den Bauraum weiträumig erschließen.") assert(game.call("_is_in_reactor_zone", Vector2(1440, 360)), "Der Pyrax-Hauptreaktor muss als Sperrzone erkannt werden.") assert(game.call("_is_in_reactor_zone", Vector2(680, 360)), "Auch die offene Pyrax-Reaktorkammer muss gesperrt sein.") assert(not game.call("_is_valid_build_position", Vector2(1440, 360)), "Auf dem Pyrax-Reaktor darf nicht gebaut werden.") @@ -170,6 +222,15 @@ func _run() -> void: game.set("selected_difficulty", 0) game.call("_start_game") assert(game.get("credits") == 450 and game.get("reactor_lives") == 20, "Leicht muss dem bisherigen Balancing entsprechen.") + game.call("_handle_pointer", Vector2(1180, 45), false) + assert(game.get("speed_dropdown_open"), "Die Spielgeschwindigkeit muss über eine Auswahl bedienbar sein.") + game.call("_handle_pointer", Vector2(1190, 198), false) + assert(is_equal_approx(game.get("game_speed"), 5.0), "Die Geschwindigkeitsauswahl muss bis 5× reichen.") + game.set("game_speed", 1.0) + game.call("_handle_pointer", Vector2(1330, 82), false) + assert(game.get("auto_start_waves"), "Der automatische Wellenstart muss per Checkbox aktivierbar sein.") + game.call("_handle_pointer", Vector2(1330, 82), false) + assert(not game.get("auto_start_waves"), "Der automatische Wellenstart muss per Checkbox abschaltbar sein.") game.call("_handle_pointer", Vector2(1110, 45), false) assert(game.get("game_mode") == "menu", "Der Zurück-Button muss eine laufende Runde abbrechen und zum Kartenmenü führen.") assert(game.get("towers").is_empty() and game.get("enemies").is_empty(), "Beim Abbruch müssen alle Einheiten der laufenden Runde entfernt werden.") @@ -207,6 +268,28 @@ func _run() -> void: game.set("enemies_to_spawn", 2) game.call("_spawn_enemy") assert(game.get("enemies")[-1].max_hp > 100.0, "Schwer muss die Gegnerhülle deutlich verstärken.") + assert(game.call("_enemy_health_scale", 4, false) > game.call("_enemy_health_scale", 3, false), "Die Gegnerhülle muss ab Welle 4 stärker skalieren.") + game.set("wave", 3) + game.call("_start_wave") + assert(game.get("enemies_to_spawn") > 15, "Ab Welle 4 muss die Gegnerzahl merklich steigen.") + 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} + 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.get("enemies").clear() + game.set("wave", 5) + game.set("reactor_lives", 12) + game.set("credits", 300) + game.set("towers_built_this_run", 4) + var lean_score: int = game.call("_calculate_run_score") + game.set("towers_built_this_run", 14) + assert(game.call("_calculate_run_score") < lean_score, "Zusätzliche Bauvorgänge müssen den Effizienz-Multiplikator senken, auch wenn Türme später verkauft werden.") + game.set("towers_built_this_run", 4) + game.set("credits", 500) + assert(game.call("_calculate_run_score") > lean_score, "Verbleibende Energie muss die Highscore-Punkte erhöhen.") game.set("selected_difficulty", 0) game.call("_start_game") game.set("wave", 4) @@ -225,6 +308,10 @@ func _run() -> void: game.call("_update_wave", 0.1) assert(game.get("game_mode") == "endless_offer", "Nach Welle 15 muss die Wahl zum Endlosmodus erscheinen.") assert(game.get("core_crystals") == crystals_before + 5, "Eine abgeschlossene Karte muss Kernkristalle vergeben.") + assert(not game.get("victory_reward_pending"), "Unter Windows ohne Anzeigen muss die Missionsbelohnung nicht blockiert werden.") + game.call("_mission_complete") + game.call("_handle_pointer", game.get("start_button").get_center(), false) + assert(game.get("game_mode") == "menu", "Der Abschluss einer 15-Wellen-Mission muss aus dem Siegbildschirm zurück ins Hauptmenü führen, nicht neu starten.") game.call("_start_endless_mode") assert(game.get("endless_mode") and game.get("game_mode") == "playing", "Der Endlosmodus muss freiwillig startbar sein.") game.call("_game_over")