extends Node2D const SCREEN := Vector2(1600.0, 900.0) const ENGLISH_UI := { "PROFIL": "PROFILE", "EINSATZKARTE WÄHLEN • GESPERRTE SEKTOREN IM SHOP FREISCHALTEN": "SELECT A MAP • UNLOCK SECTORS IN THE SHOP", "EINSATZSEKTOREN": "DEPLOYMENT SECTORS", "KARTE WÄHLEN • GESPERRTE SEKTOREN IM SHOP FREISCHALTEN": "CHOOSE A MAP • UNLOCK LOCKED SECTORS IN THE SHOP", "AUSGEWÄHLT": "SELECTED", "KOMMUNITY": "COMMUNITY", "HANGAR & KONTO": "HANGAR & ACCOUNT", "SYSTEM": "SYSTEM", "GESPERRT": "LOCKED", "Wähle einen Sektor. Danach legst du Schwierigkeit und Missionsstart fest.": "Choose a sector, then set the difficulty and start your mission.", "< KARTE": "< MAP", "SCHWIERIGKEIT WÄHLEN": "SELECT DIFFICULTY", "LEICHT": "EASY", "MITTEL": "MEDIUM", "SCHWER": "HARD", "MISSION STARTEN": "START MISSION", "BESTENLISTE": "LEADERBOARD", "ARSENAL / SHOP": "ARSENAL / SHOP", "EINSTELLUNGEN": "SETTINGS", "CODE EINLÖSEN": "REDEEM CODE", "SPIEL BEENDEN": "QUIT GAME", "MULTIPLAYER": "MULTIPLAYER", "SCHLIESSEN": "CLOSE", "MASTER-LAUTSTÄRKE": "MASTER VOLUME", "SPRACHE": "LANGUAGE", "DEUTSCH": "GERMAN", "ENGLISCH": "ENGLISH", "Die Einstellung wird automatisch gespeichert.": "Settings are saved automatically.", "< ZURÜCK": "< BACK", "ZURÜCK": "BACK", "KOOPERATIVE VERTEIDIGUNG": "COOPERATIVE DEFENSE", "ZWEI KOMMANDANTEN • EIN GEMEINSAMER REAKTOR": "TWO COMMANDERS • ONE SHARED REACTOR", "GEMEINSAMER REAKTOR": "SHARED REACTOR", "SCHNELLES SPIEL": "QUICK MATCH", "RAUM BEITRETEN": "JOIN ROOM", "RAUM ERSTELLEN": "CREATE ROOM", "MIT KI-PARTNER": "WITH AI PARTNER", "FREUNDESLISTE": "FRIENDS", "FREUNDE EINLADEN": "INVITE FRIENDS", "AKTUALISIEREN": "REFRESH", "NAMEN KOPIEREN": "COPY NAME", "ANFRAGE SENDEN": "SEND REQUEST", "EINLADEN": "INVITE", "ENTFERNEN": "REMOVE", "ZURÜCKZIEHEN": "WITHDRAW", "ANNEHMEN": "ACCEPT", "ONLINE": "ONLINE", "OFFLINE": "OFFLINE", "IM SPIEL": "IN GAME", "ANFRAGE GESENDET": "REQUEST SENT", "ANFRAGE ERHALTEN": "REQUEST RECEIVED", "BLOCKIERT": "BLOCKED", "Noch keine Freunde oder offenen Anfragen.": "No friends or pending requests yet.", "Spielername eingeben": "Enter player name", "Füge Freunde über ihren Spielernamen hinzu. Beide müssen die Anfrage bestätigen.": "Add friends by player name. Both players must confirm the request.", "Kein Chat: Einladungen enthalten nur einen Raumcode, keine freie Nachricht.": "No chat: invites contain only a room code, never a custom message.", "PRIVATEN RAUM BEITRETEN": "JOIN A PRIVATE ROOM", "GIB DEN SECHSSTELLIGEN CODE DEINES MITPILOTEN EIN": "ENTER YOUR PARTNER'S SIX-CHARACTER CODE", "BUCHSTABEN UND ZAHLEN • GROSS-/KLEINSCHREIBUNG IST EGAL": "LETTERS AND NUMBERS • CASE DOES NOT MATTER", "PRIVATER KOOP-RAUM": "PRIVATE CO-OP ROOM", "MITSPIELERSUCHE": "FINDING A PARTNER", "KOOP-SPIEL": "CO-OP GAME", "NICHT BEREIT": "NOT READY", "BEREIT": "READY", "SUCHE ABBRECHEN": "CANCEL SEARCH", "GLOBALE BESTENLISTE": "GLOBAL LEADERBOARD", "MISSION": "MISSION", "ENDLOSMODUS": "ENDLESS MODE", "RANG": "RANK", "PILOT": "PILOT", "KARTE": "MAP", "STUFE": "LEVEL", "WELLE": "WAVE", "PUNKTE": "SCORE", "PILOTENPROFIL": "PILOT PROFILE", "SPIELERNAME ÄNDERN": "CHANGE PLAYER NAME", "KONTO & CLOUD": "ACCOUNT & CLOUD", "BASTION PRIME": "BASTION PRIME", "HELIOS-WERFT": "HELIOS SHIPYARD", "PYRAX-RAFFINERIE": "PYRAX REFINERY", "NOVA-ARKOLOGIE": "NOVA ARCOLOGY", "CRYO-SCHLEUSE": "CRYO LOCK", "SOLARFORGE": "SOLAR FORGE", "VESTIGE-9": "VESTIGE-9", "Dreifache Feuerlinie": "Triple firing line", "Langer Werft-Korridor": "Long shipyard corridor", "Gefährlicher Asteroidenring": "Dangerous asteroid ring", "Biodom über dem Nebel": "Biodome above the nebula", "Eisige Raffinerie": "Frozen refinery", "Schmiede am Gasriesen": "Forge by the gas giant", "Verlassene Megastruktur": "Abandoned megastructure", "VERKAUFEN": "SELL", "MAX. STUFE": "MAX LEVEL", "FREIGESCHALTET": "UNLOCKED", "GESPERRT • %d KERNKRISTALLE": "LOCKED • %d CORE CRYSTALS", "KARTENBONUS %d • BESTE WELLE %02d • KERNKRISTALLE %04d": "MAP BONUS %d • BEST WAVE %02d • CORE CRYSTALS %04d", "KARTE %02d VON %02d": "MAP %02d OF %02d", "KARTE IM SHOP FREISCHALTEN": "UNLOCK MAP IN SHOP", "Bisheriges Balancing • 20 Reaktor • Kartenbonus 5": "Classic balance • 20 reactor health • map bonus 5", "+32 % Gegnerhülle • +24 % Gegner • Kartenbonus 7": "+32% enemy armor • +24% enemies • map bonus 7", "+72 % Grundhülle • stärkere Spätwellen • Start 340 • Kartenbonus 12": "+72% base armor • stronger late waves • start 340 • map bonus 12", "GERGAMES-SERVER VERBUNDEN • ONLINE-KOOP IST SPIELBEREIT": "GERGAMES SERVER CONNECTED • ONLINE CO-OP READY", "VERBINDUNG ZUM GERGAMES-SERVER WIRD AUTOMATISCH AUFGEBAUT": "CONNECTING TO THE GERGAMES SERVER AUTOMATICALLY", "FREISCHALTEN • %d KERNKRISTALLE": "UNLOCK • %d CORE CRYSTALS", "SCH %.0f • %.2f SCHUSS/S": "DMG %.0f • %.2f SHOTS/S", "RW %.0f • %s": "RANGE %.0f • %s", "LUFT": "AIR", "BODEN": "GROUND", "LUFT + BODEN": "AIR + GROUND", "EINZELZIEL": "SINGLE TARGET", "FLÄCHE 76": "AREA 76", "PLASMA-FLÄCHE 92": "PLASMA AREA 92", "EMP-FLÄCHE 118 · ALLE": "EMP AREA 118 · ALL", "PIERCING-STRAHL": "PIERCING BEAM", "LUFT-EINZELZIEL": "AIR SINGLE TARGET", "DEIN SPIELERNAME: %s": "YOUR PLAYER NAME: %s", "EINLADUNG VON %s ANNEHMEN": "ACCEPT INVITE FROM %s", "Anfrage an %s gesendet.": "Request sent to %s.", "Spielername nicht gefunden oder Anfrage fehlgeschlagen.": "Player name not found or request failed.", "Mehrere Spieler haben diesen Namen. Bitte einen eindeutigen Namen verwenden.": "Multiple players have this name. Please use a unique name.", "Anfrage bestätigt.": "Request accepted.", "Bestätigung fehlgeschlagen.": "Could not accept request.", "Einladung gesendet.": "Invite sent.", "Einladung fehlgeschlagen oder gerade erst gesendet.": "Invite failed or was sent too recently.", "Anfrage zurückgezogen.": "Request withdrawn.", "Freund entfernt.": "Friend removed.", "Aktion fehlgeschlagen.": "Action failed.", "Spielername kopiert.": "Player name copied.", "Keine Serververbindung. Bitte später erneut versuchen.": "No server connection. Please try again later.", "Freundesliste konnte nicht geladen werden.": "Could not load friends.", "Privater Raum %s: Freunde mit EINLADEN direkt benachrichtigen.": "Private room %s: use INVITE to notify friends directly.", "MITSPIELER": "PARTNER", "REAKTORLEBEN %02d • GETRENNTE RESSOURCEN": "REACTOR HEALTH %02d • SEPARATE RESOURCES", "ENERGIE": "ENERGY", "VERSION %s • GERGAMES-ONLINEKONTO • GLOBALE BESTENLISTEN": "VERSION %s • GERGAMES ONLINE ACCOUNT • GLOBAL LEADERBOARDS", "ONLINE • SUCHE NACH EINEM MITPILOTEN": "ONLINE • SEARCHING FOR A PARTNER", "VERBINDUNG ZUM GERGAMES-SERVER WIRD AUFGEBAUT …": "CONNECTING TO THE GERGAMES SERVER …", "SERVER NOCH NICHT ERREICHBAR • BITTE SPÄTER ERNEUT VERSUCHEN": "SERVER UNAVAILABLE • PLEASE TRY AGAIN LATER", "MITPILOT GEFUNDEN • WÄHLE BEREIT": "PARTNER FOUND • SELECT READY", "BEIDE SPIELER MÜSSEN BEREIT SEIN": "BOTH PLAYERS MUST BE READY", "VERBINDUNG WIRD WIEDERHERGESTELLT .": "RECONNECTING …", "VERBINDUNG VERLOREN • BITTE ZURÜCKKEHREN": "CONNECTION LOST • PLEASE GO BACK", "PRIVATER RAUM WIRD ERSTELLT .": "CREATING PRIVATE ROOM …", "TEILE DEN RAUMCODE MIT DEINEM MITPILOTEN • WARTE AUF BEITRITT": "SHARE THE ROOM CODE WITH YOUR PARTNER • WAITING FOR JOIN", "PRIVATER RAUM WIRD GESUCHT .": "FINDING PRIVATE ROOM …", "RAUM NICHT GEFUNDEN ODER BEREITS VOLL": "ROOM NOT FOUND OR FULL", "LINKE VERTEIDIGUNG": "LEFT DEFENSE", "RECHTE VERTEIDIGUNG": "RIGHT DEFENSE", "NICHT VERBUNDEN": "DISCONNECTED", "WARTE AUF MITPILOTEN": "WAITING FOR PARTNER", "RAUMCODE %s": "ROOM CODE %s", "REAKTOR %02d": "REACTOR %02d", "ENERGIE %04d": "ENERGY %04d", "PARTNER": "PARTNER", "KI": "AI", "WELLE %d STARTEN": "START WAVE %d", "%d IM ANFLUG": "%d INCOMING", "JETZT • %02d/%02d": "NOW • %02d/%02d", "DANN • %02d": "NEXT • %02d", "DANN • --": "NEXT • --", "%d SCHIFFE": "%d UNITS", "NÄCHSTE WELLE AUTO STARTEN": "AUTO-START NEXT WAVE", "VIDEO: +150 NOTFALLENERGIE": "VIDEO: +150 EMERGENCY ENERGY", "ANTIPPEN • BAUPLATZ WÄHLEN": "TAP • SELECT BUILD SLOT", "GESPERRT • IM SHOP FREISCHALTEN": "LOCKED • UNLOCK IN SHOP", "UPGRADE %d": "UPGRADE %d", "SCHILD": "SHIELD", "KOMMANDOSCHIFF": "COMMAND SHIP", "TÜRME": "TOWERS", "KARTEN": "MAPS", "Türme und Einsatzsektoren mit Kernkristallen freischalten": "Unlock towers and sectors using core crystals", "KERNKRISTALLE %04d": "CORE CRYSTALS %04d", "Weitere Verteidigungen lassen sich dauerhaft mit Kernkristallen freischalten.": "Unlock additional defenses permanently with core crystals.", "GERGAMES-ARSENAL": "GERGAMES ARSENAL", "80-PX-RASTER • DIREKTZIEL REAKTOR": "80-PX GRID • DIRECT REACTOR TARGET", "Jeder Sektor hat einen eigenen, rastergenauen Laufweg bis in den Reaktor.": "Each sector has its own grid-aligned route into the reactor.", "SCH %.0f • %.2f/S • RW %.0f": "DMG %.0f • %.2f/S • RANGE %.0f", "%s • STUFE %d": "%s • LEVEL %d", "%s • BAUVORSCHAU": "%s • BUILD PREVIEW", "+%.0f SCH +%.2f/S +%.0f RW BEIM UPGRADE": "+%.0f DMG +%.2f/S +%.0f RANGE ON UPGRADE", "MAXIMALE AUSBAUSTUFE": "MAXIMUM LEVEL", "SPIELER-ID %s": "PLAYER ID %s", "LOKALE BESTWERTE": "LOCAL BESTS", "EINSATZSEKTOR": "SECTOR", "ENDLOS": "ENDLESS", "BESTE PUNKTE": "BEST SCORE", "GERGAMES ONLINE VERBUNDEN": "GERGAMES ONLINE CONNECTED", "GAME CENTER / APPLE NÄCHSTE STUFE": "GAME CENTER / APPLE COMING LATER", "GOOGLE PLAY GAMES NÄCHSTE STUFE": "GOOGLE PLAY GAMES COMING LATER", "DATENSCHUTZ": "PRIVACY", "LÖSCHEN BESTÄTIGEN": "CONFIRM DELETION", "KONTO LÖSCHEN": "DELETE ACCOUNT", "SPIELERNAME": "PLAYER NAME", "Dieser Name ist für Mitspieler und in Ranglisten sichtbar.": "This name is visible to partners and on leaderboards.", "3–20 Zeichen · Buchstaben, Zahlen, Leerzeichen, _ und -": "3–20 characters · letters, numbers, spaces, _ and -", "SPIELERNAME SPEICHERN": "SAVE PLAYER NAME", "Die Online-Partie läuft währenddessen weiter.": "The online match continues in the background.", "Die Verteidigung ist angehalten.": "Defense is paused.", "FORTSETZEN": "RESUME", "PARTIE VERLASSEN": "LEAVE MATCH", "MISSION VERLASSEN": "LEAVE MISSION", "REAKTOR VERLOREN": "REACTOR LOST", "Erreichte Welle: %d": "Wave reached: %d", "Highscore: %d • Effizienzbonus: ×%.2f": "Score: %d • Efficiency bonus: ×%.2f", "NEUER VERSUCH": "TRY AGAIN", "SEKTOR GESICHERT": "SECTOR SECURED", "Alle %d Angriffswellen wurden abgewehrt.": "All %d attack waves were repelled.", "Der Orbitalkern bleibt unter unserer Kontrolle.": "The orbital core remains under our control.", "+%d KERNKRISTALLE • GUTHABEN %d": "+%d CORE CRYSTALS • BALANCE %d", "ZUM HAUPTMENÜ": "MAIN MENU", "Alle %d regulären Wellen wurden abgewehrt.": "All %d regular waves were repelled.", "Möchtest du den Einsatz im immer schwereren Endlosmodus fortsetzen?": "Continue in an increasingly difficult endless mode?", "WERBUNG WIRD GELADEN • KERNKRISTALLE DANACH: %d": "LOADING AD • CORE CRYSTALS AFTERWARDS: %d", "+%d Kernkristalle gutgeschrieben. Ein späteres Ende gilt nicht als gescheitert.": "+%d core crystals awarded. A later defeat does not undo this victory.", "ENDLOSMODUS STARTEN": "START ENDLESS MODE", "MISSION BEENDEN": "FINISH MISSION", "ENDLOSAUFTRAG BEENDET": "ENDLESS MISSION ENDED", "Der reguläre Sektorauftrag bleibt erfolgreich abgeschlossen.": "The regular sector mission remains completed.", "KERNKRISTALLE %d": "CORE CRYSTALS %d", "NEUER EINSATZ": "NEW MISSION", "SONDER-CODE": "SPECIAL CODE", "Gib einen persönlichen Freischaltcode ein.": "Enter a personal unlock code.", "FREISCHALTCODE": "UNLOCK CODE", "BITTE WARTEN …": "PLEASE WAIT …", "Spielernamen werden aus dem aktuellen öffentlichen Profil geladen.": "Player names are loaded from their current public profiles.", "WERTUNG: WELLEN + REAKTORREST + RESTENERGIE • WENIGER GEBAUTE TÜRME ERHÖHEN DEN EFFIZIENZ-MULTIPLIKATOR": "SCORE: WAVES + REACTOR HEALTH + REMAINING ENERGY • FEWER TOWERS BUILT INCREASE EFFICIENCY", "Zuverlässig gegen Bodenziele.": "Reliable against ground targets.", "Mehrzwecklaser; durchdringt Schilde von Luft- und Bodenzielen.": "Versatile laser; penetrates shields on air and ground targets.", "Bodenraketen treffen Gegnergruppen.": "Ground rockets hit groups of enemies.", "Mehrzweckwaffe mit reduziertem Schaden.": "Versatile weapon with reduced damage.", "Impuls trifft alle Luft- und Bodenziele im Radius mit 20 Schaden.": "Pulse deals 20 damage to all air and ground targets in range.", "Lila Durchschlagsstrahl trifft alle Gegner auf seiner Schusslinie.": "Purple piercing beam hits all enemies along its path.", "Schnelle Abfangkanone gegen gegnerische Luftschiffe.": "Rapid interceptor cannon against enemy aircraft.", "1/3 VERTEIDIGUNG WÄHLEN": "1/3 SELECT DEFENSE", "Wähle unten das KINETIK-Geschütz.": "Select the KINETIC cannon below.", "2/3 TURM PLATZIEREN": "2/3 PLACE TOWER", "Setze den Turm auf eine freie Metallfläche neben der Flugroute.": "Place the tower on a free metal tile beside the enemy route.", "3/3 ANGRIFF STARTEN": "3/3 START ATTACK", "Starte oben rechts die erste Welle.": "Start the first wave in the top-right corner.", "ÜBERSPRINGEN": "SKIP", "KINETIK": "KINETIC", "RAKETE": "ROCKET", "EMP-PULS": "EMP PULSE" } const WORLD_SCALE := 1.0 const WORLD_SIZE := SCREEN const SAVE_PATH := "user://void_bastion_save.json" const MAX_WAVES := 15 const BOSS_WAVE_INTERVAL := 5 const PLAYFIELD_TOP := 84.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 ENEMY_INTERCEPTOR_TEXTURE := preload("res://assets/enemy_interceptor.png") const ENEMY_SHIELD_CARRIER_TEXTURE := preload("res://assets/enemy_shield_carrier.png") const ENEMY_SIEGE_DESTROYER_TEXTURE := preload("res://assets/enemy_siege_destroyer.png") const ENEMY_GROUND_ROVER_TEXTURE := preload("res://assets/enemy_ground_rover.png") const ENEMY_GROUND_TANK_TEXTURE := preload("res://assets/enemy_ground_tank.png") const ENEMY_GROUND_WALKER_TEXTURE := preload("res://assets/enemy_ground_walker.png") const ROAD_STRAIGHT_TEXTURE := preload("res://assets/road_straight.svg") const ROAD_CORNER_TEXTURE := preload("res://assets/road_corner.svg") const ROAD_SURFACE_TEXTURE := preload("res://assets/road_surface_material_v1.png") const ROAD_CORNER_SURFACE_TEXTURE := preload("res://assets/road_corner_surface_v1.png") 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/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", "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(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(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}], [{"center": Vector2(1480, 440), "radius": 185.0}], [ {"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 const COOP_LEFT_PATH := [Vector2(-40, 120), Vector2(1160, 120), Vector2(1160, 360), Vector2(360, 360), Vector2(360, 680), Vector2(1400, 680), Vector2(1400, 520), Vector2(1480, 520), Vector2(1480, 440)] const COOP_REACTOR_CENTER := Vector2(COOP_MIRROR_X, 440) const COOP_REACTOR_RADIUS := 112.0 const DIFFICULTY_DATA := [ {"name": "LEICHT", "hp": 1.0, "speed": 1.0, "count": 1.0, "income": 1.0, "spawn_delay": 1.0, "start_credits": 450, "lives": 20, "crystals": 5}, {"name": "MITTEL", "hp": 1.32, "speed": 1.07, "count": 1.24, "income": 0.94, "spawn_delay": 0.91, "start_credits": 430, "lives": 17, "crystals": 7}, {"name": "SCHWER", "hp": 1.72, "speed": 1.15, "count": 1.48, "income": 0.78, "spawn_delay": 0.82, "start_credits": 340, "lives": 12, "crystals": 12} ] const DIFFICULTY_DESCRIPTIONS := [ "Bisheriges Balancing • 20 Reaktor • Kartenbonus 5", "+32 % Gegnerhülle • +24 % Gegner • Kartenbonus 7", "+72 % Grundhülle • stärkere Spätwellen • Start 340 • Kartenbonus 12" ] var TOWER_DATA := [ {"name": "KINETIK", "cost": 120, "damage": 22.0, "range": 190.0, "rate": 0.55, "shot_speed": 620.0, "damage_type": "EINZELZIEL", "target_class": "ground", "color": Color("7de7ff"), "unlock_cost": 0, "description": "Zuverlässig gegen Bodenziele."}, {"name": "LASER", "cost": 180, "damage": 12.0, "range": 165.0, "rate": 0.18, "shot_speed": 1100.0, "damage_type": "EINZELZIEL", "target_class": "both", "both_damage_scale": 0.82, "color": Color("62fff1"), "unlock_cost": 0, "description": "Mehrzwecklaser; durchdringt Schilde von Luft- und Bodenzielen."}, {"name": "RAKETE", "cost": 250, "damage": 65.0, "range": 260.0, "rate": 1.40, "shot_speed": 360.0, "damage_type": "FLÄCHE 76", "target_class": "ground", "color": Color("ffb35a"), "unlock_cost": 0, "description": "Bodenraketen treffen Gegnergruppen."}, {"name": "PLASMA", "cost": 230, "damage": 34.0, "range": 240.0, "rate": 1.05, "shot_speed": 480.0, "damage_type": "PLASMA-FLÄCHE 92", "target_class": "both", "both_damage_scale": 0.72, "color": Color("e27bff"), "unlock_cost": 18, "description": "Mehrzweckwaffe mit reduziertem Schaden."}, {"name": "EMP-PULS", "cost": 210, "damage": 20.0, "range": 215.0, "rate": 0.92, "shot_speed": 540.0, "damage_type": "EMP-FLÄCHE 118 · ALLE", "target_class": "both", "both_damage_scale": 1.0, "color": Color("77e8ff"), "unlock_cost": 24, "description": "Impuls trifft alle Luft- und Bodenziele im Radius mit 20 Schaden."}, {"name": "RAILGUN", "cost": 300, "damage": 77.0, "range": 330.0, "rate": 1.85, "shot_speed": 1450.0, "damage_type": "PIERCING-STRAHL", "target_class": "both", "both_damage_scale": 0.70, "color": Color("c86bff"), "unlock_cost": 30, "description": "Lila Durchschlagsstrahl trifft alle Gegner auf seiner Schusslinie."}, {"name": "FLAK", "cost": 210, "damage": 22.0, "range": 230.0, "rate": 0.42, "shot_speed": 920.0, "damage_type": "LUFT-EINZELZIEL", "target_class": "air", "color": Color("ff737c"), "unlock_cost": 0, "description": "Schnelle Abfangkanone gegen gegnerische Luftschiffe."}, {"name": "ADMIN-TURM", "cost": 0, "damage": 1000000.0, "range": 650.0, "rate": 0.08, "shot_speed": 3000.0, "damage_type": "TESTMODUS", "target_class": "both", "color": Color("fff2a1"), "unlock_cost": 0, "description": "Entwicklertest: besiegt jedes Ziel mit einem Schuss."} ] var ENEMY_DATA := [ {"name": "JAEGER", "category": "air", "hp": 70.0, "speed": 105.0, "reward": 18, "size": 58.0, "breach_damage": 1, "shield_resistance": 0.28}, {"name": "FREGATTE", "category": "air", "hp": 185.0, "speed": 72.0, "reward": 34, "size": 78.0, "breach_damage": 2, "shield_resistance": 0.28}, {"name": "KREUZER", "category": "air", "hp": 520.0, "speed": 43.0, "reward": 78, "size": 112.0, "breach_damage": 3, "shield_resistance": 0.28}, {"name": "ABFANGJAEGER", "category": "air", "hp": 52.0, "speed": 178.0, "reward": 22, "size": 48.0, "breach_damage": 2, "shield_resistance": 0.28}, {"name": "SCHILDKREUZER", "category": "air", "hp": 260.0, "speed": 58.0, "reward": 52, "size": 92.0, "breach_damage": 2, "shield_resistance": 0.18, "default_shield": true}, {"name": "BELAGERER", "category": "air", "hp": 620.0, "speed": 48.0, "reward": 108, "size": 128.0, "breach_damage": 4, "shield_resistance": 0.28}, {"name": "SPAEHER-ROVER", "category": "ground", "hp": 88.0, "speed": 142.0, "reward": 22, "size": 54.0, "breach_damage": 1, "shield_resistance": 0.28}, {"name": "STURMPANZER", "category": "ground", "hp": 390.0, "speed": 61.0, "reward": 61, "size": 92.0, "breach_damage": 3, "shield_resistance": 0.28}, {"name": "BELAGERUNGSLAEUFER", "category": "ground", "hp": 980.0, "speed": 36.0, "reward": 132, "size": 124.0, "breach_damage": 5, "shield_resistance": 0.28} ] const BALANCE_CACHE_PATH := "user://balance_cache.json" var path_points := PackedVector2Array() var path_cells: Dictionary = {} var build_zones: Array[Rect2] = [] var road_tile_cache: Array[Dictionary] = [] var build_cell_cache: Array[Dictionary] = [] var draw_geometry_dirty := true var selected_map := 0 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, 6] 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 var selected_tower_index := -1 var pending_touch_build_position := Vector2(-1, -1) var towers: Array[Dictionary] = [] var enemies: Array[Dictionary] = [] var projectiles: Array[Dictionary] = [] var beams: Array[Dictionary] = [] var particles: Array[Dictionary] = [] var next_enemy_id := 1 var wave_active := false var enemies_to_spawn := 0 var spawn_timer := 0.0 var wave_type_schedule: Array[Dictionary] = [] var wave_spawn_index := 0 var online_wave_types_remaining: Array[int] = [] var enemy_strength_multiplier := 1.0 var developer_panel_open := false const DEVELOPER_PASSWORD := "Spring.3" const ADMIN_TOWER_TYPE := 7 const DEVELOPER_AUTH_OP := 15 const DEVELOPER_ENERGY_OP := 16 var developer_unlocked_this_round := false var developer_server_authorized := false var developer_password_prompt_open := false var developer_password_line_edit: LineEdit var developer_password_dialog_rect := Rect2(510, 330, 580, 235) var developer_password_input_rect := Rect2(570, 410, 460, 54) var developer_password_cancel_button := Rect2(670, 488, 260, 48) var status_text := "" var status_timer := 0.0 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 var local_player_slot := 0 var coop_paths: Array[PackedVector2Array] = [] var next_spawn_route := 0 var start_button := Rect2(625, 674, 350, 58) var map_buttons := [ Rect2(55, 312, 350, 150), Rect2(435, 312, 350, 150), Rect2(815, 312, 350, 150), Rect2(1195, 312, 350, 150), Rect2(245, 492, 350, 150), Rect2(625, 492, 350, 150), Rect2(1005, 492, 350, 150) ] var difficulty_buttons := [Rect2(535, 520, 165, 50), Rect2(718, 520, 165, 50), Rect2(901, 520, 165, 50)] var wave_button := Rect2(1216, 10, 359, 46) var pause_button := Rect2(1142, 14, 62, 54) var speed_button := Rect2(1070, 14, 62, 54) var auto_wave_button := Rect2(1216, 58, 359, 19) var wave_current_preview_rect := Rect2(220, 8, 258, 66) var wave_next_preview_rect := Rect2(486, 8, 258, 66) var developer_panel_rect := Rect2(920, 104, 660, 112) var developer_strength_down_button := Rect2(934, 140, 42, 28) var developer_strength_up_button := Rect2(1110, 140, 42, 28) var developer_crystals_down_button := Rect2(1170, 140, 50, 28) var developer_crystals_up_button := Rect2(1412, 140, 50, 28) var developer_energy_down_button := Rect2(934, 176, 78, 28) var developer_energy_up_button := Rect2(1018, 176, 78, 28) var developer_admin_tower_button := Rect2(1370, 174, 195, 32) var tower_buttons := [ Rect2(13, 778, 218, 112), Rect2(238, 778, 218, 112), Rect2(463, 778, 218, 112), Rect2(688, 778, 218, 112), Rect2(913, 778, 218, 112), Rect2(1138, 778, 218, 112), Rect2(1363, 778, 218, 112) ] var upgrade_button := Rect2(350, 698, 160, 44) var sell_button := Rect2(518, 698, 140, 44) var pause_resume_button := Rect2(570, 500, 220, 58) var pause_exit_button := Rect2(810, 500, 220, 58) var tutorial_skip_button := Rect2(365, 205, 145, 30) var reward_ad_button := Rect2(1070, 690, 300, 54) var tower_info_rect := Rect2(16, 690, 320, 60) var target_priority_button := Rect2(666, 698, 300, 44) 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(40, 772, 245, 62) var shop_button := Rect2(550, 772, 245, 62) 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) var leaderboard_refresh_button := Rect2(1265, 35, 280, 54) var leaderboard_map_previous_button := Rect2(235, 150, 58, 50) var leaderboard_map_button := Rect2(303, 150, 410, 50) var leaderboard_map_next_button := Rect2(723, 150, 58, 50) var leaderboard_difficulty_previous_button := Rect2(819, 150, 58, 50) var leaderboard_difficulty_button := Rect2(887, 150, 300, 50) var leaderboard_difficulty_next_button := Rect2(1197, 150, 58, 50) var leaderboard_rows: Array = [] var leaderboard_endless := false var leaderboard_map := 0 var leaderboard_difficulty := 1 var leaderboard_loading := false var leaderboard_message := "" var profile_back_button := Rect2(55, 35, 185, 54) var multiplayer_button := Rect2(295, 772, 245, 62) 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) var private_match_button := Rect2(825, 665, 300, 64) var ai_match_button := Rect2(1175, 665, 300, 64) var friends_button := Rect2(600, 753, 400, 54) var friends_back_button := Rect2(55, 35, 185, 54) var friends_refresh_button := Rect2(1190, 35, 345, 54) var friend_name_rect := Rect2(260, 200, 740, 58) var friend_add_button := Rect2(1030, 200, 300, 58) var friend_copy_button := Rect2(1030, 135, 300, 46) var friend_action_buttons: Array[Rect2] = [] var friend_return_mode := "multiplayer_menu" var friends_message := "" var friend_name_line_edit: LineEdit var pending_match_invite: Dictionary = {} var cancel_search_button := Rect2(625, 650, 350, 64) var lobby_ready_button := Rect2(625, 580, 350, 58) var room_code_input_rect := Rect2(510, 340, 580, 90) var join_room_button := Rect2(625, 525, 350, 64) var coop_side_button := Rect2(916, 14, 140, 54) var online_projectiles: Array[Dictionary] = [] var online_snapshot_elapsed := 0.0 var online_snapshot_interval := 0.1 var room_code_input := "" var profile_name_input := "" var profile_message := "" var profile_delete_armed := false var profile_rename_button := Rect2(220, 305, 250, 48) var profile_support_button := Rect2(570, 685, 250, 48) var profile_privacy_button := Rect2(840, 685, 250, 48) var profile_delete_button := Rect2(1110, 685, 250, 48) var profile_name_input_rect := Rect2(460, 330, 680, 82) var profile_name_save_button := Rect2(625, 470, 350, 62) 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(805, 772, 245, 62) var promo_code_button := Rect2(1060, 772, 245, 62) var exit_button := Rect2(1315, 772, 245, 62) var promo_code_input_rect := Rect2(500, 335, 600, 64) var promo_code_redeem_button := Rect2(650, 460, 300, 58) var promo_code_cancel_button := Rect2(55, 35, 185, 54) var promo_code_line_edit: LineEdit var promo_code_message := "" var promo_code_pending := false var audio_panel_rect := Rect2(450, 280, 700, 355) var audio_close_button := Rect2(1010, 300, 110, 44) var audio_slider_rect := Rect2(565, 460, 520, 32) var language_button_de := Rect2(565, 540, 240, 42) var language_button_en := Rect2(825, 540, 240, 42) var ui_language := "de" @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: _load_balance_cache() _select_map(selected_map) _load_progress() _load_audio_settings() # The Android AudioTrack backend currently crashes on this generated PCM stream # on the supported emulator/device test target. Keep the game playable there # without starting native audio until we replace it with a packaged audio asset. if not OS.has_feature("android"): 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) update_manager.update_available.connect(_on_update_available) if not online_manager.authenticated.is_connected(_on_online_authenticated): online_manager.authenticated.connect(_on_online_authenticated) online_manager.friends_changed.connect(queue_redraw) online_manager.match_invite_received.connect(_on_match_invite_received) _create_promo_code_input() _create_friend_name_input() _create_developer_password_input() queue_redraw() func _create_developer_password_input() -> void: developer_password_line_edit = LineEdit.new() developer_password_line_edit.position = developer_password_input_rect.position + Vector2(12, 4) developer_password_line_edit.size = developer_password_input_rect.size - Vector2(24, 8) developer_password_line_edit.max_length = 40 developer_password_line_edit.secret = true developer_password_line_edit.placeholder_text = "PASSWORT" developer_password_line_edit.alignment = HORIZONTAL_ALIGNMENT_CENTER developer_password_line_edit.add_theme_color_override("font_color", Color("e8fbff")) developer_password_line_edit.add_theme_color_override("font_placeholder_color", Color("7699a8")) var input_style := StyleBoxFlat.new() input_style.bg_color = Color("081a27") input_style.border_color = Color("ffad62") input_style.set_border_width_all(1) input_style.set_content_margin_all(10) developer_password_line_edit.add_theme_stylebox_override("normal", input_style) developer_password_line_edit.add_theme_stylebox_override("focus", input_style) developer_password_line_edit.text_submitted.connect(func(_value: String) -> void: _submit_developer_password()) add_child(developer_password_line_edit) developer_password_line_edit.hide() func _create_friend_name_input() -> void: friend_name_line_edit = LineEdit.new() friend_name_line_edit.position = friend_name_rect.position + Vector2(10, 3) friend_name_line_edit.size = friend_name_rect.size - Vector2(20, 6) friend_name_line_edit.max_length = 20 friend_name_line_edit.placeholder_text = _ui("Spielername eingeben") friend_name_line_edit.text_submitted.connect(func(_value: String) -> void: _add_friend_from_input()) add_child(friend_name_line_edit) friend_name_line_edit.hide() func _on_match_invite_received(invite: Dictionary) -> void: pending_match_invite = invite _show_status("EINLADUNG VON %s" % String(invite.get("from", "MITSPIELER"))) queue_redraw() func _open_friends(return_mode: String) -> void: friend_return_mode = return_mode game_mode = "friends" friend_name_line_edit.show() friends_message = "" _refresh_friends() queue_redraw() func _close_friends() -> void: friend_name_line_edit.hide() game_mode = friend_return_mode queue_redraw() func _refresh_friends() -> void: if not online_manager.is_authenticated(): friends_message = _ui("Keine Serververbindung. Bitte später erneut versuchen.") return var ok: bool = await online_manager.refresh_friends() if not ok: friends_message = _ui("Freundesliste konnte nicht geladen werden.") queue_redraw() func _add_friend_from_input() -> void: var player_name := friend_name_line_edit.text.strip_edges() if player_name.is_empty(): return var result: Dictionary = await online_manager.add_friend_by_display_name(player_name) var ok := bool(result.get("accepted", false)) if ok: friends_message = _ui("Anfrage an %s gesendet.") % String(result.get("display_name", player_name)) friend_name_line_edit.clear() elif String(result.get("error", "")) == "ambiguous": friends_message = _ui("Mehrere Spieler haben diesen Namen. Bitte einen eindeutigen Namen verwenden.") else: friends_message = _ui("Spielername nicht gefunden oder Anfrage fehlgeschlagen.") queue_redraw() func _friend_row_action(friend: Dictionary) -> void: var state := int(friend.get("state", -1)) var user_id := String(friend.get("id", "")) if state == 2: friends_message = _ui("Anfrage bestätigt.") if await online_manager.accept_friend(user_id) else _ui("Bestätigung fehlgeschlagen.") elif state == 0 and _can_invite_friends(): friends_message = _ui("Einladung gesendet.") if await online_manager.invite_friend(user_id, multiplayer_manager.private_room_code) else _ui("Einladung fehlgeschlagen oder gerade erst gesendet.") elif state == 1: friends_message = _ui("Anfrage zurückgezogen.") if await online_manager.remove_friend(user_id) else _ui("Aktion fehlgeschlagen.") elif state == 0: friends_message = _ui("Freund entfernt.") if await online_manager.remove_friend(user_id) else _ui("Aktion fehlgeschlagen.") queue_redraw() func _can_invite_friends() -> bool: return friend_return_mode == "matchmaking" and multiplayer_manager.matchmaking_mode == "private_host" and multiplayer_manager.matchmaking_state in ["hosting", "lobby"] and not multiplayer_manager.private_room_code.is_empty() func _create_promo_code_input() -> void: promo_code_line_edit = LineEdit.new() promo_code_line_edit.position = promo_code_input_rect.position + Vector2(14, 5) promo_code_line_edit.size = promo_code_input_rect.size - Vector2(28, 10) promo_code_line_edit.max_length = 24 promo_code_line_edit.placeholder_text = "CODE EINGEBEN" promo_code_line_edit.alignment = HORIZONTAL_ALIGNMENT_CENTER promo_code_line_edit.add_theme_color_override("font_color", Color("e8fbff")) promo_code_line_edit.add_theme_color_override("font_placeholder_color", Color("7699a8")) var input_style := StyleBoxFlat.new() input_style.bg_color = Color("081a27") input_style.border_color = Color("4ab8d1") input_style.set_border_width_all(1) input_style.set_content_margin_all(10) promo_code_line_edit.add_theme_stylebox_override("normal", input_style) promo_code_line_edit.add_theme_stylebox_override("focus", input_style) promo_code_line_edit.text_changed.connect(func(value: String) -> void: var normalized := value.to_upper().replace(" ", "") if value != normalized: promo_code_line_edit.text = normalized promo_code_line_edit.caret_column = normalized.length() ) promo_code_line_edit.text_submitted.connect(func(_value: String) -> void: _submit_promo_code() ) add_child(promo_code_line_edit) promo_code_line_edit.hide() if online_manager.is_authenticated(): _on_online_authenticated("", "") func _on_online_authenticated(_user_id: String, _username: String) -> void: var balance_response: Dictionary = await online_manager.fetch_game_balance() var balance_config = balance_response.get("config", {}) if balance_config is Dictionary and not balance_config.is_empty(): _save_balance_cache(balance_config) if game_mode in ["menu", "promo_code", "store", "leaderboard", "profile", "multiplayer_menu", "friends", "room_code_entry", "matchmaking"]: _apply_balance_config(balance_config) var entitlements: Dictionary = await online_manager.get_promo_unlocks() _apply_promo_unlocks(entitlements) func _load_balance_cache() -> void: if not FileAccess.file_exists(BALANCE_CACHE_PATH): return var file := FileAccess.open(BALANCE_CACHE_PATH, FileAccess.READ) if file == null: return var parsed = JSON.parse_string(file.get_as_text()) if parsed is Dictionary: _apply_balance_config(parsed) func _save_balance_cache(config: Dictionary) -> void: var file := FileAccess.open(BALANCE_CACHE_PATH, FileAccess.WRITE) if file != null: file.store_string(JSON.stringify(config)) func _apply_balance_config(config: Dictionary) -> bool: var towers_config = config.get("towers", []) var enemies_config = config.get("enemies", []) if not towers_config is Array or towers_config.size() != TOWER_DATA.size() - 1 or not enemies_config is Array or enemies_config.size() != ENEMY_DATA.size(): return false for values in towers_config: if not values is Dictionary or str(values.get("targetClass", "both")) not in ["air", "ground", "both"]: return false for values in enemies_config: if not values is Dictionary: return false for index in TOWER_DATA.size() - 1: var values: Dictionary = towers_config[index] var tower: Dictionary = TOWER_DATA[index] tower["cost"] = clampi(int(values.get("cost", tower.cost)), 10, 50000) tower["damage"] = clampf(float(values.get("damage", tower.damage)), 0.1, 10000.0) tower["range"] = clampf(float(values.get("range", tower.range)), 20.0, 1200.0) tower["rate"] = clampf(float(values.get("cooldown", tower.rate)), 0.04, 20.0) var target_class := str(values.get("targetClass", tower.get("target_class", "both"))) tower["target_class"] = target_class tower["both_damage_scale"] = clampf(float(values.get("bothScale", tower.get("both_damage_scale", 1.0))), 0.05, 2.0) for index in ENEMY_DATA.size(): var values: Dictionary = enemies_config[index] var enemy: Dictionary = ENEMY_DATA[index] enemy["hp"] = clampf(float(values.get("hp", enemy.hp)), 1.0, 1000000.0) enemy["speed"] = clampf(float(values.get("speed", enemy.speed)), 1.0, 1500.0) enemy["reward"] = clampi(int(values.get("reward", enemy.reward)), 0, 50000) enemy["breach_damage"] = clampi(int(values.get("breachDamage", enemy.breach_damage)), 1, 100) enemy["shield_resistance"] = clampf(float(values.get("shieldResistance", enemy.shield_resistance)), 0.01, 1.0) var admin_config = config.get("admin_tower", {}) if admin_config is Dictionary: TOWER_DATA[ADMIN_TOWER_TYPE]["range"] = clampf(float(admin_config.get("range", 650.0)), 80.0, 1200.0) TOWER_DATA[ADMIN_TOWER_TYPE]["rate"] = clampf(float(admin_config.get("cooldown", 0.08)), 0.04, 20.0) return true func _apply_promo_unlocks(entitlements: Dictionary) -> void: if bool(entitlements.get("all_towers", false)): unlocked_towers = [] for tower_type in TOWER_DATA.size() - 1: unlocked_towers.append(tower_type) if bool(entitlements.get("all_maps", false)): unlocked_maps = [] for map_index in MAP_NAMES.size(): unlocked_maps.append(map_index) _save_progress() queue_redraw() func _submit_promo_code() -> void: if promo_code_pending: return if not online_manager.is_authenticated(): promo_code_message = "Für das Einlösen ist eine Internetverbindung erforderlich." queue_redraw() return var code := promo_code_line_edit.text.strip_edges().to_upper().replace("-", "") if code.is_empty(): promo_code_message = "Bitte gib einen Code ein." queue_redraw() return promo_code_pending = true promo_code_message = "CODE WIRD GEPRÜFT …" queue_redraw() var result: Dictionary = await online_manager.redeem_unlock_code(code) promo_code_pending = false if bool(result.get("accepted", false)): _apply_promo_unlocks(result) var reward := str(result.get("reward", "")) promo_code_message = "Freigeschaltet: %s" % _promo_reward_label(reward) promo_code_line_edit.clear() else: match str(result.get("error", "invalid")): "offline": promo_code_message = "Server nicht erreichbar. Bitte später erneut versuchen." "already_redeemed": promo_code_message = "Dieser Code wurde bereits eingelöst." "exhausted": promo_code_message = "Dieser Code wurde bereits vollständig verwendet." "rate_limited": promo_code_message = "Zu viele Versuche. Bitte warte kurz." _: promo_code_message = "Code ungültig oder nicht mehr verfügbar." queue_redraw() func _promo_reward_label(reward: String) -> String: match reward: "all_towers": return "alle Türme" "all_maps": return "alle Karten" "all": return "alle Türme und Karten" _: return "Bonus" func _process(delta: float) -> void: elapsed += delta if coop_online_mode: online_manager.record_online_frame(delta) if status_timer > 0.0: status_timer -= delta if game_mode == "playing" or (game_mode == "paused" and coop_online_mode): 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_online_projectiles(delta) _update_effects(delta) else: var simulation_delta := delta * game_speed _update_wave(simulation_delta) _update_enemies(simulation_delta) _update_towers(simulation_delta) _update_projectiles(simulation_delta) _update_effects(simulation_delta) queue_redraw() func _update_wave(delta: float) -> void: if not wave_active: return if enemies_to_spawn > 0: spawn_timer -= delta if spawn_timer <= 0.0: _spawn_enemy() enemies_to_spawn -= 1 spawn_timer = maxf(0.24, (0.82 - wave * 0.025) * float(_difficulty().spawn_delay)) elif enemies.is_empty(): wave_active = false if wave >= MAX_WAVES and not endless_mode: _offer_endless_mode() return var bonus := int((55 + wave * 8) * float(_difficulty().income)) _award_credits(0, bonus) if coop_mode: _award_credits(1, bonus) _show_status("WELLE %d ABGEWEHRT +%d" % [wave, bonus]) 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: var schedule_entry: Dictionary = wave_type_schedule[wave_spawn_index] if wave_spawn_index < wave_type_schedule.size() else {} var is_boss := bool(schedule_entry.get("boss", wave > 0 and wave % BOSS_WAVE_INTERVAL == 0 and enemies_to_spawn <= (2 if coop_mode else 1))) var type := int(schedule_entry.get("type", _enemy_type_for_wave(wave, randf(), is_boss))) wave_spawn_index += 1 var data: Dictionary = ENEMY_DATA[type] var base_health_scale := _enemy_health_scale(wave, is_boss) var first_boss_hull_scale := 0.5 if is_boss and wave == 5 else 1.0 var health_scale := base_health_scale * enemy_strength_multiplier * first_boss_hull_scale var shield_scale := base_health_scale * enemy_strength_multiplier var has_shield := bool(data.get("default_shield", false)) or (wave >= 4 and randf() < minf(0.42, 0.10 + wave * 0.018 + selected_difficulty * 0.06)) var max_shield_hp: float = data.hp * shield_scale * 0.55 if has_shield else 0.0 if is_boss and wave == 5: max_shield_hp *= 0.5 var route_id := 0 var route := path_points if coop_mode: route_id = next_spawn_route next_spawn_route = (next_spawn_route + 1) % coop_paths.size() route = coop_paths[route_id] enemies.append({ "id": next_enemy_id, "type": type, "pos": route[0], "path_index": 1, "route_id": route_id, "hp": data.hp * health_scale, "max_hp": data.hp * health_scale, "base_max_hp": data.hp * base_health_scale * first_boss_hull_scale, "speed": data.speed * (1.0 + wave * 0.012) * (0.82 if is_boss else 1.0) * float(_difficulty().speed), "reward": int((data.reward + wave) * (4 if is_boss else 1) * float(_difficulty().income)), "angle": 0.0, "flash": 0.0, "boss": is_boss, "size_scale": 1.34 if is_boss else 1.0, "shield": has_shield, "shield_hp": max_shield_hp, "max_shield_hp": max_shield_hp, "shield_resistance": float(data.shield_resistance) }) if is_boss: _show_status("WARNUNG: KOMMANDOSCHIFF IM ANFLUG") next_enemy_id += 1 func _enemy_type_for_wave(for_wave: int, roll: float, is_boss: bool) -> int: if is_boss: return 8 if for_wave >= 10 else 5 var advanced_offset := 2 if selected_difficulty == 2 else (1 if selected_difficulty == 1 else 0) if for_wave >= 10 and roll >= 0.96: return 8 if for_wave >= 8 and roll >= 0.84 and roll < 0.94: return 7 if for_wave >= 3 and roll >= 0.52 and roll < 0.64: return 6 if for_wave >= 10 - advanced_offset and roll >= 0.94: return 5 if for_wave >= 7 - advanced_offset and roll >= 0.80: return 4 if for_wave >= 4 - advanced_offset and roll >= 0.65: return 3 if for_wave >= 7 and roll >= 0.45: return 2 if for_wave >= 3 and roll >= 0.28: return 1 return 0 func _wave_population(for_wave: int) -> int: if for_wave <= 0: return 0 var population_scale := 1.0 + maxf(0.0, for_wave - 3.0) * 0.08 if selected_difficulty == 2: population_scale += maxf(0.0, for_wave - 4.0) * 0.025 if for_wave > MAX_WAVES: population_scale += (for_wave - MAX_WAVES) * 0.035 return ceili((7 + for_wave * 2) * float(_difficulty().count) * population_scale) * (2 if coop_mode else 1) func _build_wave_schedule(for_wave: int, count: int) -> Array[Dictionary]: var schedule: Array[Dictionary] = [] var boss_count := mini(2 if coop_mode else 1, count) if for_wave > 0 and for_wave % BOSS_WAVE_INTERVAL == 0 else 0 for index in count: var is_boss := index >= count - boss_count var roll := fposmod(float(index + 1) * 0.61803398875 + float(for_wave) * 0.38196601125, 1.0) var type := _enemy_type_for_wave(for_wave, roll, is_boss) schedule.append({"type": type, "boss": is_boss}) return schedule func _enemy_category(enemy_type: int) -> String: return str(ENEMY_DATA[clampi(enemy_type, 0, ENEMY_DATA.size() - 1)].category) func _tower_target_class(tower_type: int) -> String: return str(TOWER_DATA[clampi(tower_type, 0, TOWER_DATA.size() - 1)].get("target_class", "both")) func _tower_display_order() -> Array[int]: var ordered_types: Array[int] = [] for target_class in ["air", "ground", "both"]: for tower_type in TOWER_DATA.size() - 1: if _tower_target_class(tower_type) == target_class: ordered_types.append(tower_type) return ordered_types func _tower_can_target(tower_type: int, enemy_type: int) -> bool: var target_class := _tower_target_class(tower_type) return target_class == "both" or target_class == _enemy_category(enemy_type) func _tower_target_label(tower_type: int) -> String: match _tower_target_class(tower_type): "air": return "LUFT" "ground": return "BODEN" _: return "LUFT + BODEN" func _set_enemy_strength_multiplier(value: float) -> void: if coop_online_mode: _show_status("TESTMULTIPLIKATOR IST IM ONLINE-KOOP DEAKTIVIERT") return enemy_strength_multiplier = clampf(roundf(value * 10.0) / 10.0, 0.5, 3.0) for enemy in enemies: var health_ratio := clampf(float(enemy.hp) / maxf(1.0, float(enemy.max_hp)), 0.0, 1.0) var adjusted_max := float(enemy.get("base_max_hp", enemy.max_hp)) * enemy_strength_multiplier enemy.max_hp = adjusted_max enemy.hp = adjusted_max * health_ratio _show_status("TEST: GEGNERSTÄRKE ×%.1f" % enemy_strength_multiplier) queue_redraw() func _adjust_core_crystals(amount: int) -> void: core_crystals = clampi(core_crystals + amount, 0, 999999) _save_progress() _show_status("TEST: KERNKRISTALLE %s25 • STAND %d" % ["+" if amount >= 0 else "−", core_crystals]) queue_redraw() func _enemy_health_scale(for_wave: int, is_boss: bool) -> float: var early_ramp := maxf(0.0, for_wave - 1.0) * 0.20 var post_four_ramp := maxf(0.0, for_wave - 3.0) * 0.09 var hard_late_ramp := maxf(0.0, for_wave - 5.0) * 0.055 if selected_difficulty == 2 else 0.0 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 + hard_late_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: enemy.flash -= delta var route: PackedVector2Array = _enemy_route(enemy) var target: Vector2 = route[enemy.path_index] var direction: Vector2 = enemy.pos.direction_to(target) enemy.angle = direction.angle() enemy.pos += direction * enemy.speed * delta if enemy.pos.distance_to(target) < 9.0: enemy.path_index += 1 if enemy.path_index >= route.size(): enemies.erase(enemy) var enemy_data: Dictionary = ENEMY_DATA[int(enemy.type)] reactor_lives -= int(enemy_data.breach_damage) + (4 if enemy.boss else 0) if coop_mode: multiplayer_manager.shared_reactor_lives = maxi(0, reactor_lives) _spawn_burst(route[-1], Color("54dfff"), 16) if reactor_lives <= 0: _game_over() func _update_towers(delta: float) -> void: for tower in towers: tower.cooldown -= delta var target := _find_target(tower) if target.is_empty(): continue tower.angle = _tower_aim_angle(tower.type, tower.pos, target.pos) if tower.cooldown <= 0.0: _fire_tower(tower, target) tower.cooldown = _tower_cooldown(tower.type, tower.level) func _find_target(tower: Dictionary) -> Dictionary: var best: Dictionary = {} var priority := "shield" if int(tower.type) == 1 else str(tower.get("target_priority", "first")) var best_progress := -INF if priority != "last" else INF var best_hp := -1.0 var best_is_shielded := false var effective_range := _tower_range(tower.type, tower.level) for enemy in enemies: if coop_mode and int(tower.type) == ADMIN_TOWER_TYPE and int(enemy.get("route_id", 0)) != (0 if tower.pos.x < COOP_MIRROR_X else 1): continue if _tower_can_target(tower.type, int(enemy.type)) and tower.pos.distance_to(enemy.pos) <= effective_range: var route: PackedVector2Array = _enemy_route(enemy) var progress: float = enemy.path_index * 1000.0 - enemy.pos.distance_to(route[enemy.path_index]) var enemy_hp := float(enemy.hp) var is_shielded := bool(enemy.get("shield", false)) var is_better := best.is_empty() match priority: "last": is_better = is_better or progress < best_progress "strongest": is_better = is_better or enemy_hp > best_hp "shield": is_better = is_better or (is_shielded and not best_is_shielded) if is_shielded == best_is_shielded and not best.is_empty(): is_better = progress > best_progress _: is_better = is_better or progress > best_progress if is_better: best_progress = progress best_hp = enemy_hp best_is_shielded = is_shielded best = enemy return best func _tower_priority_label(priority: String) -> String: match priority: "shield": return "SCHILD ZUERST" "last": return "LETZTE" "strongest": return "MEISTE HP" _: return "ERSTE" func _cycle_selected_tower_priority() -> void: if selected_tower_index < 0 or selected_tower_index >= towers.size(): return var tower: Dictionary = towers[selected_tower_index] if int(tower.type) == 1: _show_status("LASER VISIERT SCHILDGEGNER IMMER ZUERST AN") return var priorities := ["first", "last", "strongest", "shield"] var current := priorities.find(str(tower.get("target_priority", "first"))) tower.target_priority = priorities[(current + 1) % priorities.size()] _show_status("TURMZIEL: %s" % _tower_priority_label(str(tower.target_priority))) queue_redraw() func _fire_tower(tower: Dictionary, target: Dictionary) -> void: var data: Dictionary = TOWER_DATA[tower.type] var damage := _tower_damage(tower.type, tower.level) if _tower_target_class(tower.type) == "both": damage *= float(data.get("both_damage_scale", 0.7)) if tower.type == ADMIN_TOWER_TYPE: _apply_damage(target, damage, int(tower.get("owner", 0)), tower.type) beams.append({"from": tower.pos, "to": target.pos, "ttl": 0.16, "color": data.color, "piercing": false}) elif tower.type == 1: _apply_damage(target, damage, int(tower.get("owner", 0)), tower.type) beams.append({"from": tower.pos, "to": target.pos, "ttl": 0.09, "color": data.color}) elif tower.type == 5: var direction: Vector2 = tower.pos.direction_to(target.pos) var beam_end: Vector2 = tower.pos + direction * _tower_range(tower.type, tower.level) for enemy in enemies.duplicate(): if not _tower_can_target(tower.type, int(enemy.type)): continue var offset: Vector2 = enemy.pos - tower.pos var along_beam: float = offset.dot(direction) var across_beam: float = absf(offset.cross(direction)) if along_beam >= 0.0 and along_beam <= _tower_range(tower.type, tower.level) and across_beam <= 18.0: _apply_damage(enemy, damage, int(tower.get("owner", 0)), tower.type) beams.append({"from": tower.pos, "to": beam_end, "ttl": 0.14, "color": data.color, "piercing": true}) else: projectiles.append({ "pos": tower.pos, "target_id": target.id, "damage": damage, "speed": data.shot_speed, "type": tower.type, "owner": int(tower.get("owner", 0)), "color": data.color }) func _update_projectiles(delta: float) -> void: for shot in projectiles.duplicate(): var target := _enemy_by_id(shot.target_id) if target.is_empty(): projectiles.erase(shot) continue 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 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 1.0) for enemy in enemies.duplicate(): if _tower_can_target(int(shot.type), int(enemy.type)) and enemy.pos.distance_to(target.pos) < blast_radius: _apply_damage(enemy, shot.damage if enemy.id == target.id else shot.damage * splash_factor, int(shot.get("owner", 0)), int(shot.type)) var blast_color: Color = Color("ff9b4a") if shot.type == 2 else (Color("db65ff") if shot.type == 3 else Color("71eaff")) _spawn_burst(target.pos, blast_color, 14 if shot.type == 2 else 18) else: _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, weapon_type := -1) -> void: if not enemies.has(enemy): return if weapon_type == ADMIN_TOWER_TYPE: enemy.shield = false enemy.shield_hp = 0.0 enemy.hp = 0.0 _award_credits(owner, int(enemy.reward)) _spawn_burst(enemy.pos, Color("ff6b3d"), 11 + enemy.type * 4) enemies.erase(enemy) return var applied_damage := amount if bool(enemy.get("shield", false)): if weapon_type == 1: var shield_hp := float(enemy.get("shield_hp", enemy.max_hp * 0.55)) var shield_damage := minf(shield_hp, amount) shield_hp -= shield_damage enemy.shield_hp = shield_hp if shield_hp <= 0.0: enemy.shield = false applied_damage = amount - shield_damage else: applied_damage = 0.0 elif weapon_type != 5: applied_damage *= float(enemy.get("shield_resistance", 0.28)) enemy.hp -= applied_damage enemy.flash = 0.06 if enemy.hp <= 0.0: _award_credits(owner, int(enemy.reward)) _spawn_burst(enemy.pos, Color("ff6b3d"), 11 + enemy.type * 4) enemies.erase(enemy) func _enemy_route(enemy: Dictionary) -> PackedVector2Array: if coop_mode and not coop_paths.is_empty(): return coop_paths[clampi(int(enemy.get("route_id", 0)), 0, coop_paths.size() - 1)] return path_points func _enemy_by_id(id: int) -> Dictionary: for enemy in enemies: if enemy.id == id: return enemy return {} func _spawn_burst(position: Vector2, color: Color, count: int) -> void: for index in count: var angle := randf() * TAU var speed := randf_range(45.0, 180.0) particles.append({"pos": position, "vel": Vector2.from_angle(angle) * speed, "ttl": randf_range(0.22, 0.55), "color": color}) func _update_effects(delta: float) -> void: for beam in beams.duplicate(): beam.ttl -= delta if beam.ttl <= 0.0: beams.erase(beam) for particle in particles.duplicate(): particle.pos += particle.vel * delta particle.vel *= 0.94 particle.ttl -= delta if particle.ttl <= 0.0: particles.erase(particle) func _unhandled_input(event: InputEvent) -> void: if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_F9 and game_mode in ["playing", "paused"]: if developer_password_prompt_open: developer_password_prompt_open = false developer_password_line_edit.hide() elif developer_unlocked_this_round: developer_panel_open = not developer_panel_open else: developer_password_prompt_open = true developer_password_line_edit.clear() developer_password_line_edit.show() developer_password_line_edit.grab_focus() queue_redraw() return if developer_password_prompt_open and event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: developer_password_prompt_open = false developer_password_line_edit.hide() queue_redraw() return if event is InputEventKey and event.pressed and not event.echo and event.keycode in [KEY_ESCAPE, KEY_BACK, KEY_Q]: if game_mode == "promo_code": game_mode = "menu" promo_code_line_edit.hide() queue_redraw() return 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() game_mode = "profile" elif event.keycode == KEY_BACKSPACE: profile_name_input = profile_name_input.left(maxi(0, profile_name_input.length() - 1)) elif event.keycode == KEY_ENTER: _save_profile_name() elif event.unicode > 0 and profile_name_input.length() < AccountManager.MAX_DISPLAY_NAME_LENGTH: var character := char(event.unicode) var allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 _-ÄÖÜäöüß" if allowed.contains(character): profile_name_input += character queue_redraw() return if game_mode == "room_code_entry" and event is InputEventKey and event.pressed and not event.echo: if event.keycode == KEY_ESCAPE: _hide_virtual_keyboard() game_mode = "multiplayer_menu" elif event.keycode == KEY_BACKSPACE: room_code_input = room_code_input.left(maxi(0, room_code_input.length() - 1)) elif event.keycode == KEY_ENTER: _join_entered_room() elif event.unicode > 0 and room_code_input.length() < 6: var character := char(event.unicode).to_upper() if "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".contains(character): room_code_input += character queue_redraw() return if game_mode == "friends" and event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: _close_friends() return if event is InputEventKey and event.pressed and not event.echo and event.keycode in [KEY_P, KEY_ESCAPE] and game_mode in ["playing", "paused"]: _toggle_pause() return if event is InputEventScreenTouch and event.pressed: cursor_screen_position = event.position cursor_position = event.position + camera_offset _handle_pointer(event.position, true) elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed: cursor_screen_position = event.position cursor_position = event.position + camera_offset _handle_pointer(event.position, false) elif event is InputEventMouseMotion: cursor_screen_position = event.position cursor_position = event.position + camera_offset func _handle_pointer(position: Vector2, is_touch := false) -> void: _play_ui_sound() if developer_password_prompt_open: if developer_password_cancel_button.has_point(position): developer_password_prompt_open = false developer_password_line_edit.hide() queue_redraw() return if developer_panel_open and game_mode in ["playing", "paused"]: if developer_strength_down_button.has_point(position): _set_enemy_strength_multiplier(enemy_strength_multiplier - 0.1) return if developer_strength_up_button.has_point(position): _set_enemy_strength_multiplier(enemy_strength_multiplier + 0.1) return if developer_crystals_down_button.has_point(position): _adjust_core_crystals(-25) return if developer_crystals_up_button.has_point(position): _adjust_core_crystals(25) return if developer_energy_down_button.has_point(position): _adjust_developer_energy(-500) return if developer_energy_up_button.has_point(position): _adjust_developer_energy(500) return if developer_admin_tower_button.has_point(position): if coop_online_mode and not developer_server_authorized: _show_status("ONLINE-TESTMODUS WIRD NOCH AUTORISIERT") return selected_tower_type = ADMIN_TOWER_TYPE selected_tower_index = -1 _show_status("ADMIN-TURM GEWÄHLT • BAUPLATZ AUF DER KARTE ANKLICKEN") queue_redraw() return if developer_panel_rect.has_point(position): return if audio_settings_open: if audio_close_button.has_point(position) or not audio_panel_rect.has_point(position): audio_settings_open = false _save_audio_settings() elif language_button_de.has_point(position): _set_ui_language("de") elif language_button_en.has_point(position): _set_ui_language("en") 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 _is_mobile_menu_layout(): _handle_mobile_menu_pointer(position) return if not pending_match_invite.is_empty() and Rect2(470, 226, 660, 54).has_point(position): room_code_input = String(pending_match_invite.get("room_code", "")) pending_match_invite.clear() _join_entered_room() return if update_notice_visible and update_button.has_point(position): OS.shell_open(str(available_update.get("url", "https://void-bastion.gergames.de/"))) return if update_notice_visible and update_dismiss_button.has_point(position): update_notice_visible = false queue_redraw() return if profile_button.has_point(position): game_mode = "profile" queue_redraw() return 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 promo_code_button.has_point(position): game_mode = "promo_code" promo_code_message = "" promo_code_line_edit.show() promo_code_line_edit.grab_focus() queue_redraw() 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() elif multiplayer_button.has_point(position): game_mode = "multiplayer_menu" queue_redraw() return if game_mode == "promo_code": if promo_code_cancel_button.has_point(position): game_mode = "menu" promo_code_line_edit.hide() elif promo_code_redeem_button.has_point(position): _submit_promo_code() elif promo_code_input_rect.has_point(position): promo_code_line_edit.grab_focus() 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" elif leaderboard_mission_button.has_point(position) and leaderboard_endless: leaderboard_endless = false _refresh_leaderboard() elif leaderboard_endless_button.has_point(position) and not leaderboard_endless: leaderboard_endless = true _refresh_leaderboard() elif leaderboard_refresh_button.has_point(position): _refresh_leaderboard() elif leaderboard_map_previous_button.has_point(position): leaderboard_map = wrapi(leaderboard_map - 1, 0, MAP_NAMES.size()) _refresh_leaderboard() elif leaderboard_map_button.has_point(position) or leaderboard_map_next_button.has_point(position): leaderboard_map = wrapi(leaderboard_map + 1, 0, MAP_NAMES.size()) _refresh_leaderboard() elif leaderboard_difficulty_previous_button.has_point(position): leaderboard_difficulty = wrapi(leaderboard_difficulty - 1, 0, DIFFICULTY_DATA.size()) _refresh_leaderboard() elif leaderboard_difficulty_button.has_point(position) or leaderboard_difficulty_next_button.has_point(position): leaderboard_difficulty = wrapi(leaderboard_difficulty + 1, 0, DIFFICULTY_DATA.size()) _refresh_leaderboard() queue_redraw() return if game_mode == "multiplayer_menu": if not pending_match_invite.is_empty() and Rect2(470, 585, 660, 55).has_point(position): room_code_input = String(pending_match_invite.get("room_code", "")) pending_match_invite.clear() _join_entered_room() return if multiplayer_back_button.has_point(position): game_mode = "menu" elif quick_match_button.has_point(position): _open_matchmaking("quick") elif friend_match_button.has_point(position): _open_room_code_entry() elif private_match_button.has_point(position): _open_private_room() elif ai_match_button.has_point(position): _start_coop_ai_game() elif friends_button.has_point(position): _open_friends("multiplayer_menu") queue_redraw() return if game_mode == "friends": if friends_back_button.has_point(position): _close_friends() elif friends_refresh_button.has_point(position): _refresh_friends() elif friend_add_button.has_point(position): _add_friend_from_input() elif friend_copy_button.has_point(position) and online_manager.session != null: DisplayServer.clipboard_set(account_manager.display_name) friends_message = _ui("Spielername kopiert.") else: for index in mini(friend_action_buttons.size(), online_manager.friends.size()): if friend_action_buttons[index].has_point(position): _friend_row_action(online_manager.friends[index]) break queue_redraw() return if game_mode == "room_code_entry": if multiplayer_back_button.has_point(position): _hide_virtual_keyboard() game_mode = "multiplayer_menu" elif room_code_input_rect.has_point(position): _show_room_code_keyboard() elif join_room_button.has_point(position): _join_entered_room() queue_redraw() return if game_mode == "matchmaking": if lobby_ready_button.has_point(position) and multiplayer_manager.matchmaking_state in ["hosting", "matched", "lobby", "reconnected"]: multiplayer_manager.toggle_lobby_ready() elif friends_button.has_point(position) and multiplayer_manager.matchmaking_mode == "private_host": _open_friends("matchmaking") elif cancel_search_button.has_point(position) or multiplayer_back_button.has_point(position): multiplayer_manager.cancel_search() game_mode = "multiplayer_menu" queue_redraw() return if game_mode == "profile": if profile_back_button.has_point(position): profile_delete_armed = false game_mode = "menu" elif profile_rename_button.has_point(position): _open_profile_name_entry() elif profile_support_button.has_point(position): OS.shell_open(_support_email_url()) elif profile_privacy_button.has_point(position): OS.shell_open("https://void-bastion.gergames.de/privacy.html") elif profile_delete_button.has_point(position): if not online_manager.is_authenticated(): profile_message = "Kontolöschung benötigt eine Verbindung zum GerGames-Server." elif not profile_delete_armed: profile_delete_armed = true profile_message = "Noch einmal bestätigen: Online-Konto, Ranglisten und lokale Werte werden gelöscht." else: _delete_profile_account() queue_redraw() return if game_mode == "profile_name_entry": if profile_back_button.has_point(position): _hide_virtual_keyboard() game_mode = "profile" elif profile_name_input_rect.has_point(position): _show_profile_name_keyboard() elif profile_name_save_button.has_point(position): _save_profile_name() queue_redraw() return if game_mode == "paused": if pause_button.has_point(position) or pause_resume_button.has_point(position): _toggle_pause() elif pause_exit_button.has_point(position): _return_to_menu() queue_redraw() return if coop_mode and coop_side_button.has_point(position): _toggle_coop_side() return if game_mode in ["game_over", "victory", "endless_result"]: if start_button.has_point(position): 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): _mission_complete() return if not tutorial_completed and tutorial_skip_button.has_point(position): _complete_tutorial() return if selected_tower_type < 0 and selected_tower_index < 0 and ads_manager.can_offer_rewarded() and reward_ad_button.has_point(position): ads_manager.request_rewarded_energy() return 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 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 if wave_button.has_point(position): _start_wave() return var tower_display_order := _tower_display_order() for index in tower_buttons.size(): if tower_buttons[index].has_point(position): var tower_type: int = tower_display_order[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: tutorial_step = 1 return if selected_tower_index >= 0: if target_priority_button.has_point(position): if coop_online_mode: _show_status("ZIELPRIORITÄT IST IM ONLINE-KOOP NOCH NICHT EINSTELLBAR") else: _cycle_selected_tower_priority() return if upgrade_button.has_point(position): _upgrade_selected() return if sell_button.has_point(position): _sell_selected() return if tower_info_rect.has_point(position): return var world_position := position + camera_offset var existing := _tower_at(world_position) if existing >= 0: if coop_mode and int(towers[existing].get("owner", 0)) != _local_credit_slot(): _show_status("PARTNER-TURM • NICHT VERFÜGBAR") return selected_tower_index = existing selected_tower_type = -1 pending_touch_build_position = Vector2(-1, -1) return if selected_tower_type >= 0 and position.y > PLAYFIELD_TOP and position.y < PLAYFIELD_BOTTOM: var snapped_position := _snap_to_grid(world_position) if is_touch and pending_touch_build_position != snapped_position: pending_touch_build_position = snapped_position _show_status("REICHWEITE PRÜFEN • ERNEUT TIPPEN ZUM BAUEN") return _place_tower(world_position) func _select_map(index: int) -> void: coop_mode = false coop_ai_mode = false coop_online_mode = false selected_map = clampi(index, 0, MAP_TEXTURES.size() - 1) path_points = PackedVector2Array() for point in MAP_PATHS[selected_map]: path_points.append(point * WORLD_SCALE) _validate_route_ends_at_reactor(path_points, selected_map) _rebuild_path_cells() build_zones.clear() for zone in MAP_BUILD_ZONES[selected_map]: build_zones.append(Rect2(zone.position * WORLD_SCALE, zone.size * WORLD_SCALE)) draw_geometry_dirty = true camera_offset = Vector2.ZERO queue_redraw() func _configure_coop_arena() -> void: coop_paths.clear() var left := PackedVector2Array(COOP_LEFT_PATH) var right: PackedVector2Array = multiplayer_manager.mirrored_path(left, COOP_WORLD_WIDTH) coop_paths.append(left) coop_paths.append(right) path_points = left path_cells.clear() for route in coop_paths: _add_path_cells(route) build_zones = [Rect2(120, PLAYFIELD_TOP, 1280, PLAYFIELD_BOTTOM - PLAYFIELD_TOP), Rect2(1560, PLAYFIELD_TOP, 1280, PLAYFIELD_BOTTOM - PLAYFIELD_TOP)] draw_geometry_dirty = true camera_offset = Vector2.ZERO if left[-1].distance_to(COOP_REACTOR_CENTER) > 1.0 or right[-1].distance_to(COOP_REACTOR_CENTER) > 1.0: push_error("Koop-Routen müssen sichtbar im gemeinsamen Reaktor enden.") func _validate_route_ends_at_reactor(route: PackedVector2Array, map_index: int) -> bool: if route.is_empty() or map_index < 0 or map_index >= MAP_REACTOR_ZONES.size(): push_error("Kartenroute oder Reaktordaten fehlen.") return false var reactor_center: Vector2 = MAP_REACTOR_ZONES[map_index][0].center var is_connected := route[-1].distance_to(reactor_center) <= 1.0 if not is_connected: push_error("Karte %s: Der Laufweg muss exakt im Reaktormittelpunkt enden." % MAP_NAMES[map_index]) return is_connected func _toggle_coop_side() -> void: selected_tower_type = -1 selected_tower_index = -1 pending_touch_build_position = Vector2(-1, -1) camera_offset.x = COOP_WORLD_WIDTH - SCREEN.x if camera_offset.x < 1.0 else 0.0 cursor_position = cursor_screen_position + camera_offset var viewing_right := camera_offset.x > 0.0 var viewing_own_side := (viewing_right and _local_credit_slot() == 1) or (not viewing_right and _local_credit_slot() == 0) _show_status("DEINE SEITE" if viewing_own_side else ("PARTNER-SEITE" if coop_online_mode else "KI-SEITE")) func _start_coop_ai_game() -> void: online_manager.set_presence_status("playing") _reset_developer_access() coop_mode = true coop_ai_mode = true coop_online_mode = false local_player_slot = 0 _configure_coop_arena() multiplayer_manager.reset_coop_session(int(_difficulty().start_credits), 30) 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() online_projectiles.clear() beams.clear() particles.clear() selected_tower_type = -1 selected_tower_index = -1 pending_touch_build_position = Vector2(-1, -1) wave_active = false 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 tutorial_step = 3 ads_manager.begin_mission() _run_ai_builder(true) _show_status("KOOP-ARENA ONLINE • KI-PARTNER BEREIT") func _start_coop_online_game(player_slot: int) -> void: online_manager.set_presence_status("playing") _reset_developer_access() local_player_slot = clampi(player_slot, 0, 1) coop_mode = true coop_ai_mode = false coop_online_mode = true _configure_coop_arena() wave = 0 towers.clear() enemies.clear() projectiles.clear() online_projectiles.clear() beams.clear() particles.clear() selected_tower_type = -1 selected_tower_index = -1 pending_touch_build_position = Vector2(-1, -1) wave_active = false 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 camera_offset.x = COOP_WORLD_WIDTH - SCREEN.x if local_player_slot == 1 else 0.0 online_manager.set_diagnostic_slot(local_player_slot, local_player_slot == 1) ads_manager.begin_mission() _apply_online_snapshot(multiplayer_manager.latest_snapshot) _show_status("ONLINE-KOOP BEREIT • DU VERTEIDIGST %s" % ("RECHTS" if local_player_slot == 1 else "LINKS")) func _open_matchmaking(mode: String) -> void: multiplayer_manager.reset_coop_session(int(_difficulty().start_credits), 30) multiplayer_manager.begin_search(mode, selected_difficulty) game_mode = "matchmaking" queue_redraw() func _open_private_room() -> void: multiplayer_manager.reset_coop_session(int(_difficulty().start_credits), 30) multiplayer_manager.create_private_room(selected_difficulty) game_mode = "matchmaking" queue_redraw() func _open_room_code_entry() -> void: room_code_input = "" game_mode = "room_code_entry" _show_room_code_keyboard() queue_redraw() func _join_entered_room() -> void: if room_code_input.length() != 6: _show_status("DER RAUMCODE MUSS SECHS ZEICHEN HABEN") return _hide_virtual_keyboard() multiplayer_manager.reset_coop_session(int(_difficulty().start_credits), 30) multiplayer_manager.join_private_room(room_code_input) game_mode = "matchmaking" queue_redraw() func _show_room_code_keyboard() -> void: if DisplayServer.has_feature(DisplayServer.FEATURE_VIRTUAL_KEYBOARD): DisplayServer.virtual_keyboard_show(room_code_input, room_code_input_rect, DisplayServer.KEYBOARD_TYPE_DEFAULT, 6, room_code_input.length(), room_code_input.length()) func _hide_virtual_keyboard() -> void: if DisplayServer.has_feature(DisplayServer.FEATURE_VIRTUAL_KEYBOARD): DisplayServer.virtual_keyboard_hide() func _app_version() -> String: return str(ProjectSettings.get_setting("application/config/version", "Entwicklung")) func _support_email_url() -> String: var subject := "Void Bastion Feedback %s" % _app_version() var body := "Version: %s\nPlattform: %s\nSpieler-ID: %s\n\nBeschreibung:\n" % [ _app_version(), OS.get_name(), account_manager.short_id(), ] return "mailto:support@gergames.de?subject=%s&body=%s" % [subject.uri_encode(), body.uri_encode()] func _open_profile_name_entry() -> void: profile_name_input = account_manager.display_name profile_message = "" profile_delete_armed = false game_mode = "profile_name_entry" _show_profile_name_keyboard() queue_redraw() func _show_profile_name_keyboard() -> void: if DisplayServer.has_feature(DisplayServer.FEATURE_VIRTUAL_KEYBOARD): DisplayServer.virtual_keyboard_show(profile_name_input, profile_name_input_rect, DisplayServer.KEYBOARD_TYPE_DEFAULT, AccountManager.MAX_DISPLAY_NAME_LENGTH, profile_name_input.length(), profile_name_input.length()) func _save_profile_name() -> void: var validation_error: String = account_manager.validate_display_name(profile_name_input) if not validation_error.is_empty(): profile_message = validation_error queue_redraw() return profile_message = "Spielername wird gespeichert …" queue_redraw() var result: Dictionary = await online_manager.update_display_name(profile_name_input) if not bool(result.get("accepted", false)): profile_message = str(result.get("error", "Der Spielername konnte nicht gespeichert werden.")) queue_redraw() return _hide_virtual_keyboard() profile_message = "Spielername gespeichert%s." % (" und online synchronisiert" if bool(result.get("online", false)) else "") game_mode = "profile" queue_redraw() func _delete_profile_account() -> void: profile_delete_armed = false profile_message = "Konto wird unwiderruflich gelöscht …" queue_redraw() var result: Dictionary = await online_manager.delete_online_account() if not bool(result.get("accepted", false)): profile_message = str(result.get("error", "Das Konto konnte nicht gelöscht werden.")) queue_redraw() return best_wave = 0 best_endless_wave = 0 core_crystals = 0 unlocked_towers = [0, 1, 2, 6] unlocked_maps = [0, 1, 2] _save_progress() profile_message = "Online-Konto und lokale Profildaten gelöscht. Ein neues Gastprofil wurde erstellt." queue_redraw() func _open_leaderboard() -> void: game_mode = "leaderboard" leaderboard_endless = false leaderboard_map = selected_map leaderboard_difficulty = selected_difficulty _refresh_leaderboard() func _refresh_leaderboard() -> void: if leaderboard_loading: return leaderboard_loading = true leaderboard_message = "BESTENLISTE WIRD GELADEN …" leaderboard_rows.clear() queue_redraw() if not online_manager.is_authenticated(): leaderboard_loading = false leaderboard_message = "GERGAMES-SERVER WIRD VERBUNDEN – BITTE GLEICH ERNEUT LADEN" queue_redraw() return leaderboard_rows = await online_manager.load_leaderboard(leaderboard_map, leaderboard_difficulty, leaderboard_endless, 12) leaderboard_loading = false leaderboard_message = "NOCH KEINE EINTRÄGE" if leaderboard_rows.is_empty() else "" queue_redraw() func _start_game() -> void: online_manager.set_presence_status("playing") _reset_developer_access() coop_online_mode = false 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() online_projectiles.clear() beams.clear() particles.clear() selected_tower_type = -1 selected_tower_index = -1 pending_touch_build_position = Vector2(-1, -1) wave_active = false 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 tutorial_step = 0 if not tutorial_completed else 3 ads_manager.begin_mission() _show_status("VERTEIDIGUNG ONLINE") func _return_to_menu() -> void: online_manager.set_presence_status("menu") _reset_developer_access() 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 towers.clear() enemies.clear() projectiles.clear() online_projectiles.clear() beams.clear() particles.clear() selected_tower_type = -1 selected_tower_index = -1 pending_touch_build_position = Vector2(-1, -1) camera_offset = Vector2.ZERO coop_mode = false coop_ai_mode = false coop_online_mode = false _select_map(selected_map) queue_redraw() func _reset_developer_access() -> void: developer_panel_open = false developer_unlocked_this_round = false developer_server_authorized = false developer_password_prompt_open = false if developer_password_line_edit: developer_password_line_edit.clear() developer_password_line_edit.hide() func _submit_developer_password() -> void: if developer_password_line_edit == null: return if developer_password_line_edit.text == DEVELOPER_PASSWORD: developer_unlocked_this_round = true developer_panel_open = true developer_password_prompt_open = false developer_password_line_edit.hide() if coop_online_mode: developer_server_authorized = false online_manager.send_match_command(DEVELOPER_AUTH_OP, {"password": developer_password_line_edit.text}) _show_status("TESTMODUS FREI • ONLINE-SERVER WIRD AUTORISIERT") else: developer_server_authorized = true _show_status("TESTMODUS FÜR DIESE RUNDE FREIGESCHALTET") else: developer_password_line_edit.clear() _show_status("PASSWORT FALSCH") developer_password_line_edit.grab_focus() queue_redraw() func _adjust_developer_energy(amount: int) -> void: if amount not in [-500, 500]: return if coop_online_mode: if not developer_server_authorized: _show_status("ONLINE-TESTMODUS WIRD NOCH AUTORISIERT") return online_manager.send_match_command(DEVELOPER_ENERGY_OP, {"amount": amount}) _show_status("ENERGIEÄNDERUNG AN SERVER GESENDET") return if coop_mode: var current_energy := int(multiplayer_manager.player_credits[local_player_slot]) multiplayer_manager.player_credits[local_player_slot] = maxi(0, current_energy + amount) credits = int(multiplayer_manager.player_credits[0]) else: credits = maxi(0, credits + amount) _show_status("TESTENERGIE %s500" % ("+" if amount > 0 else "−")) queue_redraw() func _start_wave() -> void: if wave_active: _show_status("WELLE LÄUFT BEREITS") return if coop_online_mode: online_manager.request_wave_start() _show_status("WELLENSTART AN SERVER GESENDET") return wave += 1 enemies_to_spawn = _wave_population(wave) wave_spawn_index = 0 wave_type_schedule = _build_wave_schedule(wave, enemies_to_spawn) spawn_timer = 0.15 wave_active = true if coop_ai_mode: _run_ai_builder(false) if not tutorial_completed and tutorial_step >= 2: _complete_tutorial() _show_status("BOSSWELLE %d" % wave if wave % BOSS_WAVE_INTERVAL == 0 else "WELLE %d NÄHERT SICH" % wave) func _toggle_pause() -> void: if coop_online_mode: if game_mode == "playing": game_mode = "paused" _show_status("ONLINE-PARTIE LÄUFT WÄHREND DER PAUSE WEITER") elif game_mode == "paused": game_mode = "playing" return if game_mode == "playing": game_mode = "paused" elif game_mode == "paused": game_mode = "playing" func _game_over() -> void: if game_mode != "playing": return if coop_online_mode: online_manager._finish_match_diagnostics("game_over") if endless_mode: _endless_run_complete() return game_mode = "game_over" wave_active = false if wave > best_wave: best_wave = wave _record_account_score(false) _save_progress() func _mission_complete() -> void: game_mode = "victory" wave_active = false func _offer_endless_mode() -> void: game_mode = "endless_offer" wave_active = false best_wave = maxi(best_wave, wave) _record_account_score(false) _save_progress() victory_reward_pending = true ads_manager.show_victory_interstitial() func _start_endless_mode() -> void: endless_mode = true game_mode = "playing" _show_status("ENDLOSMODUS AKTIV • NÄCHSTE WELLE: %d" % (wave + 1)) func _endless_run_complete() -> void: game_mode = "endless_result" wave_active = false 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() queue_redraw() func _record_account_score(was_endless: bool) -> void: 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: return DIFFICULTY_DATA[clampi(selected_difficulty, 0, DIFFICULTY_DATA.size() - 1)] func _on_update_available(info: Dictionary) -> void: available_update = info.duplicate(true) update_notice_visible = true queue_redraw() func _on_energy_reward_granted(amount: int) -> void: _award_credits(0, amount) _show_status("NOTFALLENERGIE +%d" % amount) func _complete_tutorial() -> void: tutorial_completed = true tutorial_step = 3 _save_progress() _show_status("EINFÜHRUNG ABGESCHLOSSEN") func _place_tower(position: Vector2) -> void: position = _snap_to_grid(position) var is_admin_tower := selected_tower_type == ADMIN_TOWER_TYPE if is_admin_tower and (not developer_unlocked_this_round or (coop_online_mode and not developer_server_authorized)): _show_status("ADMIN-TURM IST NOCH NICHT FREIGESCHALTET") return if selected_tower_type < 0 or (not is_admin_tower and 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 not is_admin_tower and _player_credits(credit_slot) < data.cost: _show_status("NICHT GENUG ENERGIE") return if not _is_valid_build_position(position, credit_slot, is_admin_tower): _show_status("BAUPLATZ BLOCKIERT") return if coop_online_mode: online_manager.request_tower_build(selected_tower_type, position) selected_tower_type = -1 pending_touch_build_position = Vector2(-1, -1) _show_status("BAUAUFTRAG AN SERVER GESENDET") return if not is_admin_tower: _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, "target_priority": "shield" if selected_tower_type == 1 else "first"}) selected_tower_index = towers.size() - 1 selected_tower_type = -1 pending_touch_build_position = Vector2(-1, -1) if not tutorial_completed and tutorial_step == 1: tutorial_step = 2 _spawn_burst(position, Color("fff2a1") if is_admin_tower else Color("65eaff"), 12) func _upgrade_selected() -> void: if selected_tower_index < 0 or selected_tower_index >= towers.size(): return var tower: Dictionary = towers[selected_tower_index] if coop_online_mode and int(tower.get("owner", -1)) != local_player_slot: _show_status("NUR EIGENE TÜRME KÖNNEN VERBESSERT WERDEN") return if tower.level >= 3: _show_status("MAXIMALE STUFE") return var cost: int = _upgrade_cost(tower) if _player_credits(_local_credit_slot()) < cost: _show_status("NICHT GENUG ENERGIE") return if coop_online_mode: online_manager.request_tower_upgrade(int(tower.get("id", -1))) _show_status("UPGRADE AN SERVER GESENDET") return _spend_credits(0, cost) tower.level += 1 _spawn_burst(tower.pos, Color("65eaff"), 16) func _sell_selected() -> void: if selected_tower_index < 0 or selected_tower_index >= towers.size(): return var tower: Dictionary = towers[selected_tower_index] if coop_online_mode: if int(tower.get("owner", -1)) != local_player_slot: _show_status("NUR EIGENE TÜRME KÖNNEN VERKAUFT WERDEN") return online_manager.request_tower_sell(int(tower.get("id", -1))) selected_tower_index = -1 _show_status("VERKAUF AN SERVER GESENDET") return var base_cost: int = TOWER_DATA[tower.type].cost _award_credits(0, int(base_cost * (0.55 + tower.level * 0.12))) towers.remove_at(selected_tower_index) selected_tower_index = -1 func _upgrade_cost(tower: Dictionary) -> int: return int(TOWER_DATA[tower.type].cost * (0.72 + tower.level * 0.28)) func _tower_damage(type: int, level: int) -> float: return TOWER_DATA[type].damage * (1.0 + (level - 1) * 0.52) func _tower_range(type: int, level: int) -> float: return TOWER_DATA[type].range * (1.0 + (level - 1) * 0.08) func _tower_cooldown(type: int, level: int) -> float: return TOWER_DATA[type].rate / (1.0 + (level - 1) * 0.12) func _tower_shots_per_second(type: int, level: int) -> float: return 1.0 / _tower_cooldown(type, level) func _tower_aim_angle(type: int, origin: Vector2, target: Vector2) -> float: return origin.angle_to_point(target) + (PI if type in [0, 3, 5, 6] else 0.0) func _tower_at(position: Vector2, radius := 52.0) -> int: for index in towers.size(): if towers[index].pos.distance_to(position) <= radius: return index return -1 func _grid_cell(position: Vector2) -> Vector2i: return Vector2i( roundi((position.x - GRID_SIZE * 0.5) / GRID_SIZE), roundi((position.y - GRID_SIZE * 0.5) / GRID_SIZE) ) func _cell_center(cell: Vector2i) -> Vector2: return Vector2(cell) * GRID_SIZE + Vector2.ONE * GRID_SIZE * 0.5 func _rebuild_path_cells() -> void: path_cells.clear() _add_path_cells(path_points) func _add_path_cells(route: PackedVector2Array) -> void: for index in route.size() - 1: var start := route[index] var finish := route[index + 1] var steps := maxi(1, ceili(start.distance_to(finish) / (GRID_SIZE * 0.20))) for step in steps + 1: var sample := start.lerp(finish, float(step) / float(steps)) path_cells[_grid_cell(sample)] = true func _is_path_cell(position: Vector2) -> bool: return path_cells.has(_grid_cell(position)) func _snap_to_grid(position: Vector2) -> Vector2: return Vector2( floor(position.x / GRID_SIZE) * GRID_SIZE + GRID_SIZE * 0.5, floor(position.y / GRID_SIZE) * GRID_SIZE + GRID_SIZE * 0.5 ) func _is_in_build_zone(position: Vector2) -> bool: for zone in build_zones: if zone.has_point(position): return true return false func _is_in_reactor_zone(position: Vector2) -> bool: if coop_mode: return position.distance_to(COOP_REACTOR_CENTER) <= COOP_REACTOR_RADIUS for reactor in MAP_REACTOR_ZONES[selected_map]: if position.distance_to(reactor.center) <= float(reactor.radius): return true return false func _player_credits(player_index: int) -> int: if coop_mode: return int(multiplayer_manager.player_credits[clampi(player_index, 0, 1)]) return credits func _spend_credits(player_index: int, amount: int) -> bool: if coop_mode: var success: bool = multiplayer_manager.spend_player_credits(player_index, amount) credits = multiplayer_manager.player_credits[0] return success if credits < amount: return false credits -= amount return true func _award_credits(player_index: int, amount: int) -> void: if coop_mode: multiplayer_manager.add_player_credits(player_index, amount) credits = multiplayer_manager.player_credits[0] else: credits += amount func _run_ai_builder(initial_setup: bool) -> void: if not coop_ai_mode: return var desired_towers := mini(9, 2 + int(wave / 2)) if initial_setup: desired_towers = 2 var ai_tower_count := 0 for tower in towers: if int(tower.get("owner", 0)) == 1: ai_tower_count += 1 while ai_tower_count < desired_towers: var preferred_category := _ai_priority_category() var tower_type := _ai_choose_tower_type(preferred_category) var cost: int = int(TOWER_DATA[tower_type].cost) if _player_credits(1) < cost: break var build_position := _find_ai_build_position(ai_tower_count, tower_type) if build_position == Vector2.ZERO: break _spend_credits(1, cost) towers.append({"pos": build_position, "type": tower_type, "level": 1, "cooldown": 0.15, "angle": PI, "owner": 1}) _spawn_burst(build_position, Color("ffbf69"), 12) ai_tower_count += 1 if not initial_setup and ai_tower_count >= desired_towers: _upgrade_ai_tower() func _ai_priority_category() -> String: var incoming := {"air": 0.0, "ground": 0.0} var first_wave := maxi(1, wave) var weights := [1.0, 0.65, 0.35] for offset in weights.size(): var forecast_wave := first_wave + offset if forecast_wave > MAX_WAVES and not endless_mode: continue for enemy_type in _wave_preview_types(forecast_wave): var category := _enemy_category(int(enemy_type)) incoming[category] = float(incoming[category]) + float(weights[offset]) var air_defense := 0.0 var ground_defense := 0.0 for tower in towers: if int(tower.get("owner", 0)) != 1: continue var type := int(tower.type) var dps := _tower_damage(type, int(tower.get("level", 1))) * _tower_shots_per_second(type, int(tower.get("level", 1))) match _tower_target_class(type): "air": air_defense += dps "ground": ground_defense += dps _: var both_scale := float(TOWER_DATA[type].get("both_damage_scale", 1.0)) air_defense += dps * both_scale * 0.62 ground_defense += dps * both_scale * 0.62 var air_need := float(incoming.air) / maxf(1.0, air_defense) var ground_need := float(incoming.ground) / maxf(1.0, ground_defense) if incoming.air <= 0.0: return "ground" if incoming.ground <= 0.0: return "air" return "air" if air_need >= ground_need else "ground" func _ai_choose_tower_type(category: String) -> int: var best_type := 1 var best_score := -INF for tower_type in TOWER_DATA.size() - 1: var target_class := _tower_target_class(tower_type) if target_class != category and target_class != "both": continue var data: Dictionary = TOWER_DATA[tower_type] var level := 1 var dps := _tower_damage(tower_type, level) * _tower_shots_per_second(tower_type, level) if target_class == "both": dps *= float(data.get("both_damage_scale", 1.0)) * 0.72 if str(data.damage_type).contains("FLÄCHE") or str(data.damage_type).contains("EMP-FLÄCHE"): dps *= 1.18 var specialization_bonus := 1.18 if target_class == category else 1.0 var score := dps * specialization_bonus / maxf(1.0, float(data.cost)) if score > best_score: best_score = score best_type = tower_type return best_type func _find_ai_build_position(offset: int, tower_type: int) -> 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[tower_type].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, 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: var result := INF for index in route.size() - 1: var closest := Geometry2D.get_closest_point_to_segment(position, route[index], route[index + 1]) result = minf(result, position.distance_to(closest)) return result func _upgrade_ai_tower() -> void: for tower in towers: if int(tower.get("owner", 0)) != 1 or int(tower.level) >= 3: continue var cost := _upgrade_cost(tower) if _spend_credits(1, cost): tower.level += 1 _spawn_burst(tower.pos, Color("ffbf69"), 10) return func _local_credit_slot() -> int: return local_player_slot if coop_online_mode else 0 func _apply_online_snapshot(snapshot: Dictionary) -> void: if not coop_online_mode or snapshot.is_empty(): return var apply_start_us := Time.get_ticks_usec() online_snapshot_interval = clampf(online_snapshot_elapsed, 0.08, 0.2) if online_snapshot_elapsed > 0.0 else 0.1 online_snapshot_elapsed = 0.0 selected_difficulty = clampi(int(snapshot.get("difficulty", selected_difficulty)), 0, DIFFICULTY_DATA.size() - 1) if bool(snapshot.get("connection_interrupted", false)): _show_status("MITPILOT GETRENNT • WIEDERBEITRITT %d S" % int(snapshot.get("reconnect_seconds", 0))) reactor_lives = int(snapshot.get("reactor_lives", reactor_lives)) wave = int(snapshot.get("wave", wave)) wave_active = bool(snapshot.get("wave_active", false)) enemies_to_spawn = int(snapshot.get("enemies_to_spawn", 0)) online_wave_types_remaining.clear() for enemy_type in snapshot.get("wave_types_remaining", []): online_wave_types_remaining.append(int(enemy_type)) credits = _player_credits(local_player_slot) var previous_enemies: Dictionary = {} for enemy in enemies: previous_enemies[int(enemy.get("id", -1))] = enemy var synced_enemies: Array[Dictionary] = [] for entry in snapshot.get("enemies", []): if not entry is Dictionary: continue var enemy_id := int(entry.get("id", -1)) var target_position := Vector2(float(entry.get("x", 0.0)), float(entry.get("y", 0.0))) var enemy: Dictionary = previous_enemies.get(enemy_id, {}) if enemy.is_empty(): enemy = {"id": enemy_id, "pos": target_position, "flash": 0.0} enemy["from_pos"] = enemy.get("pos", target_position) enemy["target_pos"] = target_position enemy["type"] = int(entry.get("type", 0)) enemy["path_index"] = int(entry.get("path_index", 1)) enemy["route_id"] = int(entry.get("route_id", 0)) enemy["hp"] = float(entry.get("hp", 1.0)) enemy["max_hp"] = float(entry.get("max_hp", 1.0)) enemy["shield"] = bool(entry.get("shield", false)) enemy["shield_hp"] = float(entry.get("shield_hp", 0.0)) enemy["max_shield_hp"] = float(entry.get("max_shield_hp", 0.0)) enemy["angle"] = float(entry.get("angle", 0.0)) enemy["boss"] = bool(entry.get("boss", false)) enemy["size_scale"] = 1.34 if enemy["boss"] else 1.0 synced_enemies.append(enemy) enemies = synced_enemies var previous_towers: Dictionary = {} for tower in towers: previous_towers[int(tower.get("id", -1))] = tower var synced_towers: Array[Dictionary] = [] for entry in snapshot.get("towers", []): if entry is Dictionary: var tower_id := int(entry.get("id", -1)) var tower: Dictionary = previous_towers.get(tower_id, {}) tower["id"] = tower_id tower["pos"] = Vector2(float(entry.get("x", 0.0)), float(entry.get("y", 0.0))) tower["type"] = int(entry.get("type", 0)) tower["level"] = int(entry.get("level", 1)) tower["owner"] = int(entry.get("owner", 0)) tower["angle"] = float(entry.get("angle", 0.0)) synced_towers.append(tower) towers = synced_towers for fired_shot in snapshot.get("shots", []): if not fired_shot is Dictionary: continue var tower_type := clampi(int(fired_shot.get("type", 0)), 0, TOWER_DATA.size() - 1) var shot_from := Vector2(float(fired_shot.get("from_x", 0.0)), float(fired_shot.get("from_y", 0.0))) var shot_to := Vector2(float(fired_shot.get("to_x", 0.0)), float(fired_shot.get("to_y", 0.0))) var shot_color: Color = TOWER_DATA[tower_type].color if tower_type in [1, 5, ADMIN_TOWER_TYPE]: beams.append({"from": shot_from, "to": shot_to, "ttl": 0.12 if tower_type == 1 else 0.16, "color": shot_color, "piercing": tower_type == 5}) else: online_projectiles.append({"pos": shot_from, "target": shot_to, "speed": float(TOWER_DATA[tower_type].shot_speed), "type": tower_type, "color": shot_color}) if selected_tower_index >= towers.size(): selected_tower_index = -1 if bool(snapshot.get("game_over", false)) and game_mode == "playing": _game_over() online_manager.record_snapshot_apply(Time.get_ticks_usec() - apply_start_us, enemies.size(), towers.size()) func _update_online_interpolation(delta: float) -> void: online_snapshot_elapsed += delta var weight := clampf(online_snapshot_elapsed / online_snapshot_interval, 0.0, 1.0) for enemy in enemies: enemy.pos = (enemy.get("from_pos", enemy.pos) as Vector2).lerp(enemy.get("target_pos", enemy.pos), weight) func _update_online_projectiles(delta: float) -> void: for index in range(online_projectiles.size() - 1, -1, -1): var shot: Dictionary = online_projectiles[index] var target: Vector2 = shot.target var step := maxf(0.0, float(shot.speed) * delta) if shot.pos.distance_to(target) <= maxf(14.0, step): _spawn_burst(target, shot.color, 6) online_projectiles.remove_at(index) else: shot.pos = shot.pos.move_toward(target, step) func _handle_online_event(event: Dictionary) -> void: if not coop_online_mode: return match String(event.get("type", "")): "developer_authorized": if int(event.get("slot", -1)) == local_player_slot: developer_server_authorized = true _show_status("ONLINE-TESTMODUS AUF DEM SERVER FREIGESCHALTET") "developer_auth_rejected": if int(event.get("slot", -1)) == local_player_slot: developer_server_authorized = false _show_status("SERVER HAT DAS TESTPASSWORT ABGELEHNT") "wave_complete": _show_status("WELLE %d ABGEWEHRT +%d" % [int(event.get("wave", wave)), int(event.get("bonus", 0))]) if int(event.get("wave", wave)) >= MAX_WAVES: online_manager._finish_match_diagnostics("victory") "partner_left": _show_status("VERBINDUNG ZUM PARTNER VERLOREN") "command_rejected": _show_status("AKTION VOM SERVER ABGELEHNT") "server_shutdown": _show_status("SPIELSERVER WIRD NEU GESTARTET") func _show_status(text: String) -> void: status_text = _ui(text) 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) ui_language = String(config.get_value("interface", "language", "de")) if ui_language not in ["de", "en"]: ui_language = "de" _apply_master_volume() if friend_name_line_edit: friend_name_line_edit.placeholder_text = _ui("Spielername eingeben") func _ui(text: String) -> String: return String(ENGLISH_UI.get(text, text)) if ui_language == "en" else text func _set_ui_language(value: String) -> void: if value not in ["de", "en"]: return ui_language = value if friend_name_line_edit: friend_name_line_edit.placeholder_text = _ui("Spielername eingeben") _save_audio_settings() queue_redraw() 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.set_value("interface", "language", ui_language) 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, "unlocked_towers": unlocked_towers, "unlocked_maps": unlocked_maps, "selected_difficulty": selected_difficulty, "tutorial_completed": tutorial_completed })) func _load_progress() -> void: if not FileAccess.file_exists(SAVE_PATH): 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: 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, 6] var saved_unlocks = data.get("unlocked_towers", [0, 1, 2, 6]) 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", "promo_code", "store", "leaderboard", "profile", "profile_name_entry", "multiplayer_menu", "friends", "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 == "promo_code": _draw_menu() _draw_promo_code_dialog() elif game_mode == "store": _draw_store() elif game_mode == "leaderboard": _draw_leaderboard() elif game_mode == "profile": _draw_profile() elif game_mode == "profile_name_entry": _draw_profile_name_entry() elif game_mode == "multiplayer_menu": _draw_multiplayer_menu() elif game_mode == "friends": _draw_friends() elif game_mode == "room_code_entry": _draw_room_code_entry() else: _draw_matchmaking() return draw_set_transform(-camera_offset, 0.0, Vector2.ONE) if coop_mode: _draw_coop_background() else: draw_texture_rect(MAP_TEXTURES[selected_map], Rect2(Vector2.ZERO, WORLD_SIZE), false, Color.WHITE) draw_rect(Rect2(Vector2.ZERO, Vector2(_active_world_width(), WORLD_SIZE.y)), Color(0.01, 0.025, 0.055, 0.035)) _draw_world() draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) _draw_hud() if game_mode == "playing" and not tutorial_completed and not coop_mode: _draw_tutorial() if game_mode == "paused": _draw_pause_menu() elif game_mode == "game_over": _draw_game_over() elif game_mode == "victory": _draw_victory() elif game_mode == "endless_offer": _draw_endless_offer() elif game_mode == "endless_result": _draw_endless_result() if developer_password_prompt_open: _draw_developer_password_prompt() func _active_world_width() -> float: return COOP_WORLD_WIDTH if coop_mode else WORLD_SIZE.x func _draw_coop_background() -> void: var texture: Texture2D = MAP_TEXTURES[selected_map] var source_width := texture.get_width() * COOP_MIRROR_X / WORLD_SIZE.x var source := Rect2(0, 0, source_width, texture.get_height()) draw_texture_rect_region(texture, Rect2(0, 0, COOP_MIRROR_X, WORLD_SIZE.y), source, Color.WHITE) draw_set_transform(Vector2(COOP_WORLD_WIDTH, 0) - camera_offset, 0.0, Vector2(-1, 1)) draw_texture_rect_region(texture, Rect2(0, 0, COOP_MIRROR_X, WORLD_SIZE.y), source, Color.WHITE) draw_set_transform(-camera_offset, 0.0, Vector2.ONE) func _draw_world() -> void: _prepare_draw_geometry() _draw_route() if coop_mode: draw_circle(COOP_REACTOR_CENTER, COOP_REACTOR_RADIUS, Color(0.02, 0.20, 0.30, 0.72)) 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) var valid_position := _is_valid_build_position(snapped_position, _local_credit_slot()) var placement_color := Color(0.25, 1.0, 0.7, 0.22) if valid_position else Color(1.0, 0.2, 0.2, 0.22) var preview_range := _tower_range(selected_tower_type, 1) draw_circle(snapped_position, preview_range, Color(placement_color, 0.025)) draw_arc(snapped_position, preview_range, 0, TAU, 96, Color(placement_color, 0.46), 1.5) draw_rect(Rect2(snapped_position - Vector2(35, 35), Vector2(70, 70)), Color(placement_color, 0.10), true) draw_rect(Rect2(snapped_position - Vector2(35, 35), Vector2(70, 70)), Color(placement_color, 0.62), false, 1.5) _draw_tower({"pos": snapped_position, "type": selected_tower_type, "level": 1, "angle": 0.0}) for tower_index in towers.size(): var tower: Dictionary = towers[tower_index] if tower_index == selected_tower_index: var range_value := _tower_range(tower.type, tower.level) draw_circle(tower.pos, range_value, Color(0.2, 0.85, 1.0, 0.07)) draw_arc(tower.pos, range_value, 0, TAU, 80, Color(0.35, 0.9, 1.0, 0.45), 2.0) _draw_tower(tower) for enemy in enemies: _draw_enemy(enemy) for shot in projectiles: draw_circle(shot.pos, 6.0 if shot.type == 2 else 3.5, shot.color) draw_circle(shot.pos, 12.0 if shot.type == 2 else 7.0, Color(shot.color, 0.20)) for shot in online_projectiles: draw_circle(shot.pos, 6.0 if shot.type == 2 else 3.5, shot.color) draw_circle(shot.pos, 12.0 if shot.type == 2 else 7.0, Color(shot.color, 0.20)) for beam in beams: if bool(beam.get("piercing", false)): draw_line(beam.from, beam.to, Color("c86bff", 0.18), 18.0) draw_line(beam.from, beam.to, Color("c86bff", 0.9), 8.0) draw_line(beam.from, beam.to, Color("f3ddff", 1.0), 2.0) else: draw_line(beam.from, beam.to, Color(beam.color, 0.92), 3.0) draw_line(beam.from, beam.to, Color.WHITE, 1.0) for particle in particles: draw_circle(particle.pos, maxf(1.0, particle.ttl * 8.0), Color(particle.color, minf(1.0, particle.ttl * 3.0))) func _prepare_draw_geometry() -> void: if not draw_geometry_dirty: return road_tile_cache.clear() build_cell_cache.clear() for cell in path_cells: var center := _cell_center(cell) if center.x < -GRID_SIZE or center.x > _active_world_width() + GRID_SIZE or center.y < PLAYFIELD_TOP - GRID_SIZE or center.y > PLAYFIELD_BOTTOM + GRID_SIZE: continue var left := path_cells.has(cell + Vector2i(-1, 0)) var right := path_cells.has(cell + Vector2i(1, 0)) var up := path_cells.has(cell + Vector2i(0, -1)) var down := path_cells.has(cell + Vector2i(0, 1)) var texture := ROAD_STRAIGHT_TEXTURE var angle := 0.0 var is_corner := (left or right) and (up or down) if is_corner: texture = ROAD_CORNER_TEXTURE if left and down: angle = 0.0 elif left and up: angle = PI * 0.5 elif right and up: angle = PI else: angle = PI * 1.5 elif up or down: angle = PI * 0.5 var variant := absi(cell.x * 3 + cell.y * 5) % 4 road_tile_cache.append({"center": center, "angle": angle, "texture": texture, "corner": is_corner, "variant": variant, "opacity": _path_cell_opacity(cell)}) for x_index in int(_active_world_width() / GRID_SIZE): for y_index in int(WORLD_SIZE.y / GRID_SIZE): var center := Vector2(x_index * GRID_SIZE + GRID_SIZE * 0.5, y_index * GRID_SIZE + GRID_SIZE * 0.5) if not _is_in_build_zone(center) or _is_in_reactor_zone(center) or _is_path_cell(center): continue var cell_side := 1 if center.x > COOP_MIRROR_X else 0 build_cell_cache.append({"rect": Rect2(center - Vector2(34, 34), Vector2(68, 68)), "partner": coop_mode and cell_side != _local_credit_slot()}) draw_geometry_dirty = false func _draw_route() -> void: for tile in road_tile_cache: draw_set_transform(tile.center - camera_offset, tile.angle, Vector2.ONE) var variant: int = tile.variant var ray_x := -18.0 if variant % 2 == 0 else 18.0 var tile_opacity: float = tile.opacity var texture: Texture2D = tile.texture var is_corner: bool = tile.corner 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 * 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)), 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, 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) for cell in build_cell_cache: var cell_line_color := Color(1.0, 0.67, 0.32, 0.16) if cell.partner else line_color var cell_fill_color := Color(0.75, 0.35, 0.08, 0.025) if cell.partner else fill_color draw_rect(cell.rect, cell_fill_color, true) draw_rect(cell.rect, cell_line_color, false, 1.0) func _draw_tower(tower: Dictionary) -> void: var size: float = 74.0 + (tower.level - 1) * 4.0 if coop_mode and int(tower.get("owner", 0)) != _local_credit_slot(): 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) var destination := Rect2(-size * 0.5, -size * 0.5, size, size) if tower.type == ADMIN_TOWER_TYPE: draw_circle(Vector2.ZERO, size * 0.42, Color("504621")) draw_arc(Vector2.ZERO, size * 0.47, 0.0, TAU, 40, Color("fff2a1"), 4.0) draw_circle(Vector2.ZERO, size * 0.20, Color("fff2a1")) draw_string(ThemeDB.fallback_font, Vector2(-10, 5), "A", HORIZONTAL_ALIGNMENT_CENTER, 20, 14, Color("201a0b")) elif 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())) elif tower.type == 6: var source_width: float = TOWER_TEXTURE.get_width() / 3.0 draw_texture_rect_region(TOWER_TEXTURE, destination, Rect2(0, 0, source_width, TOWER_TEXTURE.get_height()), Color("ff8b92")) 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")) func _draw_enemy(enemy: Dictionary) -> void: var enemy_type := int(enemy.type) var destination: Rect2 var size: float = ENEMY_DATA[enemy.type].size * enemy.size_scale var enemy_texture := _enemy_texture(enemy_type) var source := _enemy_source_rect(enemy_type) destination = Rect2(-size * 0.75, -size * 0.5, size * 1.5, size) var tint := Color.WHITE if enemy.flash <= 0.0 else Color("ffb6a3") if bool(enemy.get("shield", false)): var shield_ratio := clampf(float(enemy.get("shield_hp", 1.0)) / maxf(1.0, float(enemy.get("max_shield_hp", 1.0))), 0.0, 1.0) draw_circle(enemy.pos, size * 0.63, Color(0.25, 0.80, 1.0, 0.08)) draw_arc(enemy.pos, size * 0.63, 0.0, TAU, 40, Color(0.28, 0.66, 0.82, 0.28), 2.0) draw_arc(enemy.pos, size * 0.63, -PI * 0.5, -PI * 0.5 - TAU * shield_ratio, 48, Color(0.38, 0.86, 1.0, 0.92), 3.2) draw_set_transform(enemy.pos - camera_offset, enemy.angle, Vector2.ONE) draw_texture_rect_region(enemy_texture, destination, source, tint) draw_set_transform(-camera_offset, 0.0, Vector2.ONE) var bar_width := size * 0.72 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), _ui("SCHILD"), HORIZONTAL_ALIGNMENT_CENTER, 60, 9, Color("75eaff")) if enemy.boss: draw_string(ThemeDB.fallback_font, enemy.pos + Vector2(-45, -size * 0.58), _ui("KOMMANDOSCHIFF"), HORIZONTAL_ALIGNMENT_CENTER, 90, 11, Color("ffab78")) func _enemy_texture(enemy_type: int) -> Texture2D: if enemy_type < 3: return ENEMY_TEXTURE if enemy_type < 6: return [ENEMY_INTERCEPTOR_TEXTURE, ENEMY_SHIELD_CARRIER_TEXTURE, ENEMY_SIEGE_DESTROYER_TEXTURE][enemy_type - 3] return [ENEMY_GROUND_ROVER_TEXTURE, ENEMY_GROUND_TANK_TEXTURE, ENEMY_GROUND_WALKER_TEXTURE][enemy_type - 6] func _enemy_source_rect(enemy_type: int) -> Rect2: var texture := _enemy_texture(enemy_type) if enemy_type < 3: var source_width := texture.get_width() / 3.0 return Rect2(source_width * enemy_type, 0.0, source_width, texture.get_height()) return Rect2(Vector2.ZERO, texture.get_size()) func _draw_hud() -> void: var font := ThemeDB.fallback_font draw_rect(Rect2(0, 0, 1600, 82), Color(0.015, 0.035, 0.07, 0.92)) draw_line(Vector2(0, 82), Vector2(1600, 82), Color("2e7593"), 2.0) draw_string(font, Vector2(25, 31), _ui("ENERGIE %04d") % _player_credits(_local_credit_slot()), HORIZONTAL_ALIGNMENT_LEFT, 190, 16, Color("65eaff")) var reactor_label := _ui("REAKTOR %02d") % max(reactor_lives, 0) draw_string(font, Vector2(25, 59), reactor_label, HORIZONTAL_ALIGNMENT_LEFT, 165, 16, Color("8feeff")) if coop_mode: var partner_slot := 1 - _local_credit_slot() var partner_name := _ui("PARTNER" if coop_online_mode else "KI") draw_string(font, Vector2(752, 58), "%s %04d EN" % [partner_name, _player_credits(partner_slot)], HORIZONTAL_ALIGNMENT_CENTER, 154, 12, Color("ffcf7a")) if coop_mode: var viewing_right := camera_offset.x > 0.0 var viewing_own_side := (viewing_right and _local_credit_slot() == 1) or (not viewing_right and _local_credit_slot() == 0) var switch_label := ("< PARTNER" if viewing_right else "PARTNER >") if viewing_own_side else ("< ICH" if viewing_right else "ICH >") _draw_button(coop_side_button, switch_label, false, Color("604827")) _draw_button(speed_button, "%.0f×" % game_speed, game_speed > 1.0) _draw_button(pause_button, "II" if game_mode == "playing" else ">", false) _draw_button(wave_button, _ui("WELLE %d STARTEN") % (wave + 1) if not wave_active else _ui("%d IM ANFLUG") % (enemies.size() + enemies_to_spawn), wave_active) if tutorial_completed and selected_tower_type < 0 and selected_tower_index < 0 and ads_manager.can_offer_rewarded(): _draw_button(reward_ad_button, "VIDEO: +150 NOTFALLENERGIE", false, Color("285044")) draw_rect(Rect2(0, 760, 1600, 140), Color(0.012, 0.028, 0.055, 0.97)) draw_line(Vector2(0, 760), Vector2(1600, 760), Color("2e7593"), 2.0) var tower_display_order := _tower_display_order() for index in tower_buttons.size(): var tower_type: int = tower_display_order[index] _draw_tower_card(index, tower_type, selected_tower_type == tower_type) if selected_tower_index >= 0 and selected_tower_index < towers.size(): var tower: Dictionary = towers[selected_tower_index] var upgrade_label := "MAX. STUFE" if tower.level >= 3 else _ui("UPGRADE %d") % _upgrade_cost(tower) _draw_button(upgrade_button, upgrade_label, false) _draw_button(sell_button, "VERKAUFEN", false, Color("6b3542")) if not coop_online_mode: var priority_label := "ZIEL: %s" % _tower_priority_label(str(tower.get("target_priority", "first"))) if int(tower.type) == 1: priority_label = "ZIEL: SCHILD ZUERST (LASER FIX)" _draw_button(target_priority_button, priority_label, int(tower.type) == 1, Color("26394a")) if selected_tower_type >= 0 or selected_tower_index >= 0: _draw_tower_info_panel() draw_rect(Rect2(wave_current_preview_rect.position, Vector2(wave_next_preview_rect.end.x - wave_current_preview_rect.position.x, wave_current_preview_rect.size.y)), Color(0.008, 0.028, 0.052, 0.90), true) draw_rect(Rect2(wave_current_preview_rect.position, Vector2(wave_next_preview_rect.end.x - wave_current_preview_rect.position.x, wave_current_preview_rect.size.y)), Color(0.22, 0.54, 0.66, 0.68), false, 1.0) draw_line(Vector2(wave_next_preview_rect.position.x - 4.0, wave_current_preview_rect.position.y + 6.0), Vector2(wave_next_preview_rect.position.x - 4.0, wave_current_preview_rect.end.y - 6.0), Color("2b596a", 0.8), 1.0) _draw_wave_preview_panel(wave_current_preview_rect, _ui("JETZT • %02d/%02d") % [wave, MAX_WAVES], wave) var next_wave := wave + 1 if wave >= MAX_WAVES and not endless_mode: next_wave = 0 var next_heading := _ui("DANN • %02d") % next_wave if next_wave > 0 else _ui("DANN • --") _draw_wave_preview_panel(wave_next_preview_rect, next_heading, next_wave) draw_rect(auto_wave_button, Color("102d40"), true) draw_rect(auto_wave_button, Color("37677b"), false, 1.0) draw_rect(Rect2(auto_wave_button.position + Vector2(7, 4), Vector2(14, 14)), Color("07131f"), true) 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, 14), _ui("NÄCHSTE WELLE AUTO STARTEN"), HORIZONTAL_ALIGNMENT_LEFT, 220, 10, 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 developer_panel_open: draw_rect(developer_panel_rect, Color(0.008, 0.024, 0.045, 0.96), true) draw_rect(developer_panel_rect, Color("ffad62"), false, 1.5) draw_string(font, developer_panel_rect.position + Vector2(12, 22), "TESTMODUS • F9 ZUM SCHLIESSEN", HORIZONTAL_ALIGNMENT_LEFT, 620, 11, Color("ffc277")) _draw_button(developer_strength_down_button, "−", false, Color("44333a")) draw_string(font, developer_panel_rect.position + Vector2(58, 57), "GEGNER ×%.1f" % enemy_strength_multiplier, HORIZONTAL_ALIGNMENT_CENTER, 128, 13, Color.WHITE) _draw_button(developer_strength_up_button, "+", false, Color("44333a")) _draw_button(developer_crystals_down_button, "−25", false, Color("44333a")) draw_string(font, developer_panel_rect.position + Vector2(298, 57), "KRISTALLE %04d" % core_crystals, HORIZONTAL_ALIGNMENT_CENTER, 136, 13, Color("8ff4ff")) _draw_button(developer_crystals_up_button, "+25", false, Color("44333a")) _draw_button(developer_energy_down_button, "−500", false, Color("44333a")) _draw_button(developer_energy_up_button, "+500", false, Color("44333a")) draw_string(font, Vector2(1102, 195), "ENERGIE %04d" % _player_credits(_local_credit_slot()), HORIZONTAL_ALIGNMENT_CENTER, 230, 11, Color("8ff4ff")) _draw_button(developer_admin_tower_button, "ADMIN-TURM BAUEN", false, Color("5c4d2e" if (not coop_online_mode or developer_server_authorized) else "34404b")) 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_developer_password_prompt() -> void: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.0, 0.01, 0.03, 0.76), true) draw_rect(developer_password_dialog_rect, Color("071725"), true) draw_rect(developer_password_dialog_rect, Color("ffad62"), false, 2.0) draw_string(font, Vector2(developer_password_dialog_rect.position.x, 375), "ENTWICKLERMODUS", HORIZONTAL_ALIGNMENT_CENTER, developer_password_dialog_rect.size.x, 25, Color("ffc277")) draw_string(font, Vector2(developer_password_dialog_rect.position.x + 28, 399), "Passwort einmal für diese Runde eingeben", HORIZONTAL_ALIGNMENT_CENTER, developer_password_dialog_rect.size.x - 56, 13, Color("b8d4df")) draw_rect(developer_password_input_rect, Color("4ab8d1"), false, 1.0) _draw_button(developer_password_cancel_button, "ABBRECHEN", false, Color("26394a")) func _draw_wave_preview_panel(rect: Rect2, heading: String, for_wave: int) -> void: var font := ThemeDB.fallback_font var summary := _wave_preview_summary(for_wave) draw_string(font, rect.position + Vector2(8, 14), heading, HORIZONTAL_ALIGNMENT_LEFT, 160, 9, Color("96d8e8")) draw_string(font, rect.position + Vector2(164, 14), _ui("%d SCHIFFE") % int(summary.total), HORIZONTAL_ALIGNMENT_RIGHT, 84, 8, Color("dceaf0")) var type_counts: Dictionary = summary.types var type_order: Array = summary.get("type_order", []) for category_info in [{"name": "air", "x": 7.0, "color": Color("ff777d")}, {"name": "ground", "x": 133.0, "color": Color("78f29a")}]: var category: String = category_info.name var category_x: float = float(category_info.x) var category_color: Color = category_info.color _draw_wave_category_icon(Vector2(rect.position.x + category_x + 7.0, rect.position.y + 44.0), category, category_color) var drawn := 0 for type in type_order: if _enemy_category(type) != category: continue if drawn >= 3: break var icon_rect := Rect2(rect.position.x + category_x + 17.0 + drawn * 35.0, rect.position.y + 32.0, 15.0, 13.0) _draw_enemy_miniature(type, icon_rect, category_color) draw_string(font, icon_rect.position + Vector2(16, 12), "×%d" % int(type_counts[type]), HORIZONTAL_ALIGNMENT_LEFT, 19, 8, Color("dceaf0")) drawn += 1 func _wave_preview_summary(for_wave: int) -> Dictionary: var summary := {"total": 0, "air": 0, "ground": 0, "types": {}, "type_order": []} for enemy_type in _wave_preview_types(for_wave): var type := int(enemy_type) var category := _enemy_category(type) if not summary.types.has(type): summary.types[type] = 0 summary.type_order.append(type) summary.types[type] = int(summary.types[type]) + 1 summary[category] = int(summary[category]) + 1 summary.total = int(summary.total) + 1 return summary func _wave_preview_types(for_wave: int) -> Array[int]: var result: Array[int] = [] if for_wave <= 0: return result if coop_online_mode and for_wave == wave: for enemy in enemies: result.append(int(enemy.type)) result.append_array(online_wave_types_remaining) return result if for_wave == wave and not wave_type_schedule.is_empty(): for entry in wave_type_schedule: result.append(int(entry.type)) return result for entry in _build_wave_schedule(for_wave, _wave_population(for_wave)): result.append(int(entry.type)) return result func _draw_wave_category_icon(center: Vector2, category: String, color: Color) -> void: var direction := 1.0 if category == "air" else -1.0 var arrow := PackedVector2Array([ center + Vector2(0, -7.0 * direction), center + Vector2(-5, -1.0 * direction), center + Vector2(-2, -1.0 * direction), center + Vector2(-2, 5.0 * direction), center + Vector2(2, 5.0 * direction), center + Vector2(2, -1.0 * direction), center + Vector2(5, -1.0 * direction) ]) draw_colored_polygon(arrow, color) func _draw_enemy_miniature(enemy_type: int, rect: Rect2, category_color := Color("4e8194")) -> void: draw_rect(rect.grow(1.0), Color(category_color, 0.18), true) draw_texture_rect_region(_enemy_texture(enemy_type), rect, _enemy_source_rect(enemy_type), Color.WHITE) draw_rect(rect.grow(1.0), Color(category_color, 0.82), false, 1.0) func _speed_option_rect(index: int) -> Rect2: return Rect2(speed_button.position.x, speed_button.end.y + 2.0 + index * 28.0, speed_button.size.x, 27.0) 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 var unlocked := unlocked_towers.has(type) var class_color := _tower_class_color(type) var panel_color := class_color.lightened(0.28) if active else (class_color.darkened(0.42) if unlocked else class_color.darkened(0.62)) draw_rect(rect, panel_color, true) draw_rect(rect, class_color.lightened(0.30) if active else class_color.darkened(0.18), false, 1.5) draw_rect(Rect2(rect.position, Vector2(5, rect.size.y)), class_color.lightened(0.25) if unlocked else class_color.darkened(0.15), true) draw_string(font, rect.position + Vector2(9, 20), _ui(data.name), HORIZONTAL_ALIGNMENT_LEFT, 122, 13, Color.WHITE if unlocked else Color("9ab0ba")) draw_string(font, rect.position + Vector2(132, 20), "%d EN" % data.cost, HORIZONTAL_ALIGNMENT_RIGHT, 77, 10, Color("7ce7ff")) draw_string(font, rect.position + Vector2(9, 42), _ui("SCH %.0f • %.2f SCHUSS/S") % [data.damage, _tower_shots_per_second(type, 1)], HORIZONTAL_ALIGNMENT_LEFT, 200, 9, Color("dceaf0")) draw_string(font, rect.position + Vector2(9, 63), _ui("RW %.0f • %s") % [data.range, _ui(_tower_target_label(type))], HORIZONTAL_ALIGNMENT_LEFT, 200, 9, class_color.lightened(0.45)) var hint := "ANTIPPEN • BAUPLATZ WÄHLEN" if unlocked else "GESPERRT • IM SHOP FREISCHALTEN" draw_string(font, rect.position + Vector2(9, 85), _ui(hint), HORIZONTAL_ALIGNMENT_LEFT, 200, 8, Color("719aaa") if unlocked else Color("ffcf7a")) func _tower_class_color(tower_type: int) -> Color: match _tower_target_class(tower_type): "air": return Color("bb5260") # Rot: Luft "ground": return Color("668c65") # Grün: Boden _: return Color("347b98") # Blau-Cyan: Luft + Boden func _draw_store() -> void: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.70), true) _draw_button(shop_back_button, "< ZURÜCK", false, Color("26394a")) draw_string(font, Vector2(0, 115), _ui("GERGAMES-ARSENAL"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 42, Color("c7f5ff")) draw_string(font, Vector2(0, 160), _ui("Türme und Einsatzsektoren mit Kernkristallen freischalten"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("8feeff")) draw_string(font, Vector2(1270, 55), _ui("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), _ui(MAP_NAMES[map_index]), HORIZONTAL_ALIGNMENT_CENTER, 330, 17, Color("9feeff")) draw_string(font, card.position + Vector2(10, 250), _ui(MAP_DESCRIPTIONS[map_index]), HORIZONTAL_ALIGNMENT_CENTER, 330, 13, Color("dceaf0")) draw_string(font, card.position + Vector2(10, 282), _ui("80-PX-RASTER • DIREKTZIEL REAKTOR"), HORIZONTAL_ALIGNMENT_CENTER, 330, 10, Color("8fddef")) var button_text := "FREIGESCHALTET" if is_unlocked else _ui("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), _ui("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), _ui(data.name), HORIZONTAL_ALIGNMENT_CENTER, 320, 23, Color(data.color)) draw_string(font, card.position + Vector2(22, 224), _ui("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), _ui("RW %.0f • %s") % [data.range, _ui(_tower_target_label(tower_type))], HORIZONTAL_ALIGNMENT_CENTER, 316, 14, Color("9fdce9")) draw_string(font, card.position + Vector2(22, 295), _ui(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 _ui("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), _ui("Weitere Verteidigungen lassen sich dauerhaft mit Kernkristallen freischalten."), HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("ffcf7a")) func _draw_tower_info_panel() -> void: var type := selected_tower_type var level := 1 var is_built := selected_tower_index >= 0 and selected_tower_index < towers.size() if is_built: var tower: Dictionary = towers[selected_tower_index] type = tower.type level = tower.level if type < 0: return var data: Dictionary = TOWER_DATA[type] var font := ThemeDB.fallback_font draw_rect(tower_info_rect, Color(0.008, 0.028, 0.055, 0.58), true) draw_rect(tower_info_rect, Color(0.29, 0.60, 0.70, 0.72), false, 1.5) var heading := _ui("%s • STUFE %d") % [_ui(data.name), level] if is_built else _ui("%s • BAUVORSCHAU") % _ui(data.name) draw_string(font, tower_info_rect.position + Vector2(10, 19), heading, HORIZONTAL_ALIGNMENT_LEFT, 300, 11, Color("a9efff")) draw_string(font, tower_info_rect.position + Vector2(10, 37), _ui("SCH %.0f • %.2f/S • RW %.0f") % [_tower_damage(type, level), _tower_shots_per_second(type, level), _tower_range(type, level)], HORIZONTAL_ALIGNMENT_LEFT, 300, 9, Color.WHITE) if is_built and level < 3: var next_level := level + 1 draw_string(font, tower_info_rect.position + Vector2(10, 55), _ui("+%.0f SCH +%.2f/S +%.0f RW BEIM UPGRADE") % [_tower_damage(type, next_level) - _tower_damage(type, level), _tower_shots_per_second(type, next_level) - _tower_shots_per_second(type, level), _tower_range(type, next_level) - _tower_range(type, level)], HORIZONTAL_ALIGNMENT_LEFT, 300, 8, Color("84ffca")) elif is_built: draw_string(font, tower_info_rect.position + Vector2(10, 55), _ui("MAXIMALE AUSBAUSTUFE"), HORIZONTAL_ALIGNMENT_LEFT, 300, 8, Color("84ffca")) else: draw_string(font, tower_info_rect.position + Vector2(10, 55), "%s • %s" % [_ui(_tower_target_label(type)), _ui(str(data.damage_type))], HORIZONTAL_ALIGNMENT_LEFT, 300, 8, Color("84ffca")) func _draw_button(rect: Rect2, text: String, active: bool, base := Color("173c55")) -> void: var font := ThemeDB.fallback_font var color: Color = Color("226c88") if active else base draw_rect(rect, color, true) draw_rect(rect, Color("65dfff") if active else Color("37677b"), false, 2.0) var lines := _ui(text).split("\n") if lines.size() == 1: draw_string(font, rect.position + Vector2(0, rect.size.y * 0.62), lines[0], HORIZONTAL_ALIGNMENT_CENTER, rect.size.x, 17, Color.WHITE) else: draw_string(font, rect.position + Vector2(0, 28), lines[0], HORIZONTAL_ALIGNMENT_CENTER, rect.size.x, 18, Color.WHITE) draw_string(font, rect.position + Vector2(0, 54), lines[1], HORIZONTAL_ALIGNMENT_CENTER, rect.size.x, 14, Color("7ce7ff")) func _draw_menu() -> void: if _is_mobile_menu_layout(): _draw_mobile_menu() return var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.66), true) draw_string(font, Vector2(2, 147), "VOID BASTION", HORIZONTAL_ALIGNMENT_CENTER, 1600, 64, Color(0.0, 0.0, 0.0, 0.62)) draw_string(font, Vector2(0, 143), "VOID BASTION", HORIZONTAL_ALIGNMENT_CENTER, 1600, 64, Color("d9f9ff")) draw_string(font, Vector2(0, 200), "ORBITAL DEFENSE PROTOCOL", HORIZONTAL_ALIGNMENT_CENTER, 1600, 21, Color("56cce9")) if not pending_match_invite.is_empty(): _draw_button(Rect2(470, 226, 660, 54), _ui("EINLADUNG VON %s ANNEHMEN") % String(pending_match_invite.get("from", "MITSPIELER")), true, Color("1d665b")) _draw_button(profile_button, "%s • %s" % [account_manager.display_name, _ui("PROFIL")], false, Color("233f57")) if menu_step == "map": draw_rect(Rect2(34, 252, 1532, 414), Color(0.004, 0.018, 0.032, 0.32), true) draw_rect(Rect2(34, 252, 1532, 414), Color(0.18, 0.55, 0.67, 0.42), false, 1.0) draw_string(font, Vector2(0, 280), _ui("EINSATZSEKTOREN"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("84eaff")) draw_string(font, Vector2(0, 301), _ui("KARTE WÄHLEN • GESPERRTE SEKTOREN IM SHOP FREISCHALTEN"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 13, Color("98b8c7")) for index in map_buttons.size(): var card: Rect2 = map_buttons[index] var map_index := index var is_unlocked := unlocked_maps.has(map_index) var is_selected := selected_map == map_index and is_unlocked draw_rect(card.grow(4.0), Color(0.0, 0.0, 0.0, 0.4), true) draw_texture_rect(MAP_TEXTURES[map_index], card, false, Color(0.96, 0.99, 1.0, 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.08 if is_unlocked else 0.38), true) var footer := Rect2(card.position.x, card.end.y - 54.0, card.size.x, 54.0) draw_rect(footer, Color(0.005, 0.018, 0.032, 0.9), true) draw_rect(card, Color("70eaff") if is_selected else (Color("d99d55") if not is_unlocked else Color("345769")), false, 3.0 if is_selected else 1.5) var badge_text := _ui("AUSGEWÄHLT") if is_selected else (_ui("GESPERRT") if not is_unlocked else "") if not badge_text.is_empty(): var badge := Rect2(card.position + Vector2(10, 10), Vector2(116, 25)) draw_rect(badge, Color("116c83", 0.96) if is_selected else Color("80562d", 0.96), true) draw_string(font, badge.position + Vector2(0, 17), badge_text, HORIZONTAL_ALIGNMENT_CENTER, badge.size.x, 10, Color.WHITE) draw_string(font, card.position + Vector2(0, card.size.y - 33), _ui(MAP_NAMES[map_index]), HORIZONTAL_ALIGNMENT_CENTER, card.size.x, 16, Color.WHITE) var description: String = _ui(MAP_DESCRIPTIONS[map_index]) if is_unlocked else _ui("GESPERRT • %d KERNKRISTALLE") % int(MAP_UNLOCK_COSTS[map_index]) draw_string(font, card.position + Vector2(0, card.size.y - 11), description, HORIZONTAL_ALIGNMENT_CENTER, card.size.x, 12, Color("8fddef") if is_unlocked else Color("ffcf7a")) draw_string(font, Vector2(0, 698), _ui("Wähle einen Sektor. Danach legst du Schwierigkeit und Missionsstart fest."), HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, 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), _ui(MAP_NAMES[selected_map]), HORIZONTAL_ALIGNMENT_CENTER, selected_card.size.x, 18, Color.WHITE) draw_string(font, selected_card.position + Vector2(0, 138), _ui(MAP_DESCRIPTIONS[selected_map]), HORIZONTAL_ALIGNMENT_CENTER, selected_card.size.x, 13, Color("8fddef")) draw_string(font, Vector2(0, 490), _ui("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), _ui(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), _ui("KARTENBONUS %d • BESTE WELLE %02d • KERNKRISTALLE %04d") % [int(_difficulty().crystals), best_wave, core_crystals], HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, Color("86c8d8")) draw_line(Vector2(40, 744), Vector2(1560, 744), Color("448094", 0.68), 1.0) draw_string(font, Vector2(40, 761), _ui("KOMMUNITY"), HORIZONTAL_ALIGNMENT_CENTER, 500, 10, Color("7fa8b8")) draw_string(font, Vector2(550, 761), _ui("HANGAR & KONTO"), HORIZONTAL_ALIGNMENT_CENTER, 755, 10, Color("7fa8b8")) draw_string(font, Vector2(1315, 761), _ui("SYSTEM"), HORIZONTAL_ALIGNMENT_CENTER, 245, 10, Color("7fa8b8")) _draw_button(leaderboard_button, "BESTENLISTE", false, Color("24475b")) _draw_button(multiplayer_button, "MULTIPLAYER", false, Color("254f75")) _draw_button(shop_button, "ARSENAL / SHOP", false, Color("24516a")) _draw_button(audio_settings_button, "EINSTELLUNGEN", false, Color("24516a")) _draw_button(promo_code_button, "CODE EINLÖSEN", false, Color("34576b")) _draw_button(exit_button, "SPIEL BEENDEN", false, Color("5c3342")) draw_string(font, Vector2(0, 867), _ui("VERSION %s • GERGAMES-ONLINEKONTO • GLOBALE BESTENLISTEN") % _app_version(), HORIZONTAL_ALIGNMENT_CENTER, 1600, 12, 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 _is_mobile_menu_layout() -> bool: if OS.has_feature("mobile") or OS.get_cmdline_user_args().has("--mobile-menu-preview"): return true var viewport_size := get_viewport_rect().size return viewport_size.x < 1180.0 or viewport_size.aspect() < 1.15 func _mobile_map_card_rect() -> Rect2: return Rect2(240, 154, 1120, 300) func _mobile_map_previous_rect() -> Rect2: return Rect2(148, 268, 72, 72) func _mobile_map_next_rect() -> Rect2: return Rect2(1380, 268, 72, 72) func _mobile_invite_rect() -> Rect2: return Rect2(470, 112, 660, 42) func _mobile_difficulty_rect(index: int) -> Rect2: return Rect2(330 + index * 315, 535, 300, 62) func _mobile_start_rect() -> Rect2: return Rect2(350, 645, 900, 76) func _mobile_nav_rect(index: int) -> Rect2: return Rect2(50 + (index % 3) * 500, 758 + floori(float(index) / 3.0) * 62, 470, 54) func _handle_mobile_menu_pointer(position: Vector2) -> void: if not pending_match_invite.is_empty() and _mobile_invite_rect().has_point(position): room_code_input = String(pending_match_invite.get("room_code", "")) pending_match_invite.clear() _join_entered_room() return if update_notice_visible and update_button.has_point(position): OS.shell_open(str(available_update.get("url", "https://void-bastion.gergames.de/"))) return if update_notice_visible and update_dismiss_button.has_point(position): update_notice_visible = false queue_redraw() return if profile_button.has_point(position): game_mode = "profile" queue_redraw() return for index in range(6): if not _mobile_nav_rect(index).has_point(position): continue match index: 0: _open_leaderboard() 1: game_mode = "multiplayer_menu" 2: store_return_mode = "menu" store_tab = "towers" game_mode = "store" 3: audio_settings_open = true 4: game_mode = "promo_code" promo_code_message = "" promo_code_line_edit.show() promo_code_line_edit.grab_focus() 5: get_tree().quit() queue_redraw() return if _mobile_map_previous_rect().has_point(position): _select_map(wrapi(selected_map - 1, 0, MAP_NAMES.size())) menu_step = "map" queue_redraw() return if _mobile_map_next_rect().has_point(position): _select_map(wrapi(selected_map + 1, 0, MAP_NAMES.size())) menu_step = "map" queue_redraw() return for index in difficulty_buttons.size(): if _mobile_difficulty_rect(index).has_point(position): selected_difficulty = index _save_progress() queue_redraw() return if _mobile_start_rect().has_point(position): if not unlocked_maps.has(selected_map): store_return_mode = "menu" store_tab = "maps" game_mode = "store" queue_redraw() else: _start_game() func _draw_mobile_menu() -> void: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.52), true) draw_string(font, Vector2(0, 74), "VOID BASTION", HORIZONTAL_ALIGNMENT_CENTER, 1600, 46, Color("c7f5ff")) draw_string(font, Vector2(0, 108), "ORBITAL DEFENSE PROTOCOL", HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("56cce9")) _draw_button(profile_button, "%s • %s" % [account_manager.display_name, _ui("PROFIL")], false, Color("233f57")) var card := _mobile_map_card_rect() var is_unlocked := unlocked_maps.has(selected_map) draw_texture_rect(MAP_TEXTURES[selected_map], 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.18 if is_unlocked else 0.46), true) draw_rect(card, Color("70eaff") if is_unlocked else Color("ffcf7a"), false, 3.0) draw_rect(Rect2(card.position.x, card.end.y - 84, card.size.x, 84), Color(0.01, 0.025, 0.05, 0.9), true) if not pending_match_invite.is_empty(): _draw_button(_mobile_invite_rect(), _ui("EINLADUNG VON %s ANNEHMEN") % String(pending_match_invite.get("from", "MITSPIELER")), true, Color("1d665b")) draw_string(font, Vector2(card.position.x, card.end.y - 47), _ui(MAP_NAMES[selected_map]), HORIZONTAL_ALIGNMENT_CENTER, card.size.x, 24, Color.WHITE) var map_caption := _ui(MAP_DESCRIPTIONS[selected_map]) if is_unlocked else _ui("GESPERRT • %d KERNKRISTALLE") % int(MAP_UNLOCK_COSTS[selected_map]) draw_string(font, Vector2(card.position.x, card.end.y - 18), map_caption, HORIZONTAL_ALIGNMENT_CENTER, card.size.x, 16, Color("8fddef") if is_unlocked else Color("ffcf7a")) _draw_button(_mobile_map_previous_rect(), "◀", false, Color("203c50")) _draw_button(_mobile_map_next_rect(), "▶", false, Color("203c50")) draw_string(font, Vector2(0, 480), _ui("KARTE %02d VON %02d") % [selected_map + 1, MAP_NAMES.size()], HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, Color("9abac7")) draw_string(font, Vector2(0, 519), _ui("SCHWIERIGKEIT WÄHLEN"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("9fdce9")) for index in difficulty_buttons.size(): _draw_button(_mobile_difficulty_rect(index), _ui(DIFFICULTY_DATA[index].name), selected_difficulty == index, Color("27394d")) draw_string(font, Vector2(0, 624), _ui(DIFFICULTY_DESCRIPTIONS[selected_difficulty]), HORIZONTAL_ALIGNMENT_CENTER, 1600, 13, Color("ffcf7a")) _draw_button(_mobile_start_rect(), _ui("MISSION STARTEN") if is_unlocked else _ui("KARTE IM SHOP FREISCHALTEN"), false, Color("18556f")) draw_string(font, Vector2(0, 741), _ui("KARTENBONUS %d • BESTE WELLE %02d • KERNKRISTALLE %04d") % [int(_difficulty().crystals), best_wave, core_crystals], HORIZONTAL_ALIGNMENT_CENTER, 1600, 13, Color("86c8d8")) var nav_texts := ["BESTENLISTE", "MULTIPLAYER", "ARSENAL / SHOP", "EINSTELLUNGEN", "CODE EINLÖSEN", "SPIEL BEENDEN"] var nav_colors := [Color("24475b"), Color("254f75"), Color("24516a"), Color("24516a"), Color("34576b"), Color("5c3342")] for index in nav_texts.size(): _draw_button(_mobile_nav_rect(index), _ui(nav_texts[index]), false, nav_colors[index]) draw_string(font, Vector2(0, 892), _ui("VERSION %s • GERGAMES-ONLINEKONTO • GLOBALE BESTENLISTEN") % _app_version(), HORIZONTAL_ALIGNMENT_CENTER, 1600, 11, 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_promo_code_dialog() -> void: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.0, 0.01, 0.03, 0.72), true) var panel := Rect2(420, 265, 760, 390) draw_rect(panel, Color(0.008, 0.028, 0.055, 0.98), true) draw_rect(panel, Color("4ab8d1"), false, 2.0) _draw_button(promo_code_cancel_button, "< ZURÜCK", false, Color("26394a")) draw_string(font, Vector2(panel.position.x, 330), _ui("SONDER-CODE"), HORIZONTAL_ALIGNMENT_CENTER, panel.size.x, 30, Color("c7f5ff")) draw_string(font, Vector2(panel.position.x + 60, 370), _ui("Gib einen persönlichen Freischaltcode ein."), HORIZONTAL_ALIGNMENT_CENTER, panel.size.x - 120, 16, Color("9dbac8")) draw_string(font, Vector2(promo_code_input_rect.position.x, promo_code_input_rect.position.y - 12), _ui("FREISCHALTCODE"), HORIZONTAL_ALIGNMENT_LEFT, promo_code_input_rect.size.x, 12, Color("8feeff")) _draw_button(promo_code_redeem_button, "CODE EINLÖSEN" if not promo_code_pending else "BITTE WARTEN …", promo_code_pending, Color("185f68")) if not promo_code_message.is_empty(): draw_string(font, Vector2(panel.position.x + 35, 555), promo_code_message, HORIZONTAL_ALIGNMENT_CENTER, panel.size.x - 70, 15, Color("8fffea") if promo_code_message.begins_with("Freigeschaltet") else Color("ffcf7a")) 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), _ui("EINSTELLUNGEN"), 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), _ui("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 + 115, 530), _ui("SPRACHE"), HORIZONTAL_ALIGNMENT_LEFT, 460, 17, Color("dceaf0")) _draw_button(language_button_de, "DEUTSCH", ui_language == "de", Color("24516a")) _draw_button(language_button_en, "ENGLISCH", ui_language == "en", Color("24516a")) draw_string(font, Vector2(audio_panel_rect.position.x + 30, audio_panel_rect.end.y - 28), _ui("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)) _draw_button(leaderboard_back_button, "< ZURÜCK", false, Color("26394a")) _draw_button(leaderboard_refresh_button, "AKTUALISIEREN", leaderboard_loading, Color("24516a")) draw_string(font, Vector2(0, 108), _ui("GLOBALE BESTENLISTE"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 42, Color("c7f5ff")) _draw_button(leaderboard_map_previous_button, "<", false, Color("26394a")) _draw_button(leaderboard_map_button, "%s • %s" % [_ui("KARTE"), _ui(MAP_NAMES[leaderboard_map])], true, Color("24475b")) _draw_button(leaderboard_map_next_button, ">", false, Color("26394a")) _draw_button(leaderboard_difficulty_previous_button, "<", false, Color("26394a")) _draw_button(leaderboard_difficulty_button, "%s • %s" % [_ui("STUFE"), _ui(DIFFICULTY_DATA[leaderboard_difficulty].name)], true, Color("5b452b")) _draw_button(leaderboard_difficulty_next_button, ">", false, Color("26394a")) _draw_button(leaderboard_mission_button, "MISSION", not leaderboard_endless, Color("24475b")) _draw_button(leaderboard_endless_button, "ENDLOSMODUS", leaderboard_endless, Color("5b452b")) var panel := Rect2(205, 280, 1190, 505) draw_rect(panel, Color(0.012, 0.035, 0.065, 0.96), true) draw_rect(panel, Color("39788e"), false, 2.0) draw_string(font, Vector2(235, 319), _ui("RANG"), HORIZONTAL_ALIGNMENT_LEFT, 75, 13, Color("91a8bc")) draw_string(font, Vector2(325, 319), _ui("PILOT"), HORIZONTAL_ALIGNMENT_LEFT, 270, 13, Color("91a8bc")) draw_string(font, Vector2(610, 319), _ui("KARTE"), HORIZONTAL_ALIGNMENT_LEFT, 240, 13, Color("91a8bc")) draw_string(font, Vector2(850, 319), _ui("STUFE"), HORIZONTAL_ALIGNMENT_LEFT, 130, 13, Color("91a8bc")) draw_string(font, Vector2(995, 319), _ui("WELLE"), HORIZONTAL_ALIGNMENT_CENTER, 110, 13, Color("91a8bc")) draw_string(font, Vector2(1160, 319), _ui("PUNKTE"), HORIZONTAL_ALIGNMENT_RIGHT, 190, 13, Color("91a8bc")) draw_line(Vector2(230, 334), Vector2(1370, 334), Color("315c70"), 1.0) var own_user_id: String = str(online_manager.session.user_id) if online_manager.session != null else "" for index in leaderboard_rows.size(): var row: Dictionary = leaderboard_rows[index] var y := 370.0 + index * 32.0 if index % 2 == 0: draw_rect(Rect2(225, y - 23, 1150, 29), Color(0.03, 0.09, 0.14, 0.64), true) var owner_color := Color("83ffc8") if not own_user_id.is_empty() and str(row.get("owner_id", "")) == own_user_id else Color.WHITE draw_string(font, Vector2(240, y), "%02d" % int(row.get("rank", index + 1)), HORIZONTAL_ALIGNMENT_LEFT, 65, 14, Color("8ff4ff")) draw_string(font, Vector2(325, y), str(row.get("username", "PILOT")).to_upper(), HORIZONTAL_ALIGNMENT_LEFT, 270, 14, owner_color) draw_string(font, Vector2(610, y), _ui(MAP_NAMES[int(row.get("map", leaderboard_map))]), HORIZONTAL_ALIGNMENT_LEFT, 225, 13, Color("9fdce9")) draw_string(font, Vector2(850, y), _ui(DIFFICULTY_DATA[int(row.get("difficulty", leaderboard_difficulty))].name), HORIZONTAL_ALIGNMENT_LEFT, 130, 13, Color("ffcf7a")) draw_string(font, Vector2(995, y), "%02d" % int(row.get("wave", 0)), HORIZONTAL_ALIGNMENT_CENTER, 110, 14, Color("ffcf7a")) draw_string(font, Vector2(1160, y), "%09d" % int(row.get("score", 0)), HORIZONTAL_ALIGNMENT_RIGHT, 190, 14, Color("8fffea")) 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), _ui("Spielernamen werden aus dem aktuellen öffentlichen Profil geladen."), HORIZONTAL_ALIGNMENT_CENTER, 1600, 14, Color("91a8bc")) draw_string(font, Vector2(0, 838), _ui("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: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.78)) _draw_button(multiplayer_back_button, "< ZURÜCK", false, Color("26394a")) draw_string(font, Vector2(0, 105), _ui("KOOPERATIVE VERTEIDIGUNG"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 42, Color("c7f5ff")) draw_string(font, Vector2(0, 142), _ui("ZWEI KOMMANDANTEN • EIN GEMEINSAMER REAKTOR"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("66dff3")) var arena := Rect2(170, 180, 1260, 410) draw_rect(arena, Color(0.01, 0.04, 0.075, 0.96), true) draw_rect(arena, Color("39788e"), false, 2.0) draw_line(Vector2(800, 200), Vector2(800, 570), Color(0.35, 0.82, 1.0, 0.24), 2.0) _draw_coop_lane_preview(false) _draw_coop_lane_preview(true) for radius in [66.0, 48.0, 30.0]: draw_arc(Vector2(800, 385), radius, 0.0, TAU, 48, Color(0.25, 0.92, 1.0, 0.78), 3.0) draw_circle(Vector2(800, 385), 18.0, Color("8ffaff")) draw_string(font, Vector2(665, 490), _ui("GEMEINSAMER REAKTOR"), HORIZONTAL_ALIGNMENT_CENTER, 270, 15, Color("8ff4ff")) draw_string(font, Vector2(215, 225), "%s • %s %d" % [account_manager.display_name, _ui("ENERGIE"), multiplayer_manager.player_credits[0]], HORIZONTAL_ALIGNMENT_LEFT, 440, 17, Color("8ff4ff")) draw_string(font, Vector2(945, 225), "%s • %s %d" % [_ui("MITSPIELER"), _ui("ENERGIE"), multiplayer_manager.player_credits[1]], HORIZONTAL_ALIGNMENT_RIGHT, 440, 17, Color("ffcf7a")) draw_string(font, Vector2(570, 555), _ui("REAKTORLEBEN %02d • GETRENNTE RESSOURCEN") % multiplayer_manager.shared_reactor_lives, HORIZONTAL_ALIGNMENT_CENTER, 460, 16, Color.WHITE) _draw_button(quick_match_button, "SCHNELLES SPIEL", false, Color("185f68")) _draw_button(friend_match_button, "RAUM BEITRETEN", false, Color("254f75")) _draw_button(private_match_button, "RAUM ERSTELLEN", false, Color("493f67")) _draw_button(ai_match_button, "MIT KI-PARTNER", false, Color("6b4b28")) _draw_button(friends_button, "FREUNDESLISTE", false, Color("285568")) var service_text := "GERGAMES-SERVER VERBUNDEN • ONLINE-KOOP IST SPIELBEREIT" if online_manager.is_online() else "VERBINDUNG ZUM GERGAMES-SERVER WIRD AUTOMATISCH AUFGEBAUT" var service_color := Color("83ffc8") if online_manager.is_online() else Color("ffcf7a") draw_string(font, Vector2(0, 842), _ui(service_text), HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, service_color) if not pending_match_invite.is_empty(): _draw_button(Rect2(470, 585, 660, 55), _ui("EINLADUNG VON %s ANNEHMEN") % String(pending_match_invite.get("from", "MITSPIELER")), true, Color("1d665b")) func _draw_friends() -> void: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.84)) _draw_button(friends_back_button, "< ZURÜCK", false, Color("26394a")) _draw_button(friends_refresh_button, "AKTUALISIEREN", false, Color("24516a")) draw_string(font, Vector2(0, 105), _ui("FREUNDESLISTE"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 40, Color("c7f5ff")) var own_name: String = account_manager.display_name if account_manager != null else "OFFLINE" draw_string(font, Vector2(260, 169), _ui("DEIN SPIELERNAME: %s") % own_name, HORIZONTAL_ALIGNMENT_LEFT, 740, 18, Color("8ff4ff")) _draw_button(friend_copy_button, "NAMEN KOPIEREN", false, Color("285568")) draw_rect(friend_name_rect, Color(0.01, 0.035, 0.065, 0.98), true) draw_rect(friend_name_rect, Color("4ab8d1"), false, 2.0) _draw_button(friend_add_button, "ANFRAGE SENDEN", false, Color("185f68")) draw_string(font, Vector2(260, 292), _ui("Füge Freunde über ihren Spielernamen hinzu. Beide müssen die Anfrage bestätigen."), HORIZONTAL_ALIGNMENT_LEFT, 1090, 15, Color("9fdce9")) draw_rect(Rect2(220, 315, 1160, 455), Color(0.01, 0.035, 0.065, 0.96), true) draw_rect(Rect2(220, 315, 1160, 455), Color("39788e"), false, 2.0) friend_action_buttons.clear() for index in mini(online_manager.friends.size(), 8): var friend: Dictionary = online_manager.friends[index] var row := Rect2(240, 336 + index * 52, 1120, 46) var action := Rect2(1100, row.position.y + 3, 235, 40) friend_action_buttons.append(action) draw_rect(row, Color(0.035, 0.09, 0.14, 0.82), true) var state := int(friend.get("state", -1)) var status := "" var action_text := "" if state == 0: var presence := String(online_manager.friend_statuses.get(String(friend.get("id", "")), "menu" if bool(friend.get("online", false)) else "")) status = "IM SPIEL" if presence == "playing" else ("ONLINE" if not presence.is_empty() else "OFFLINE") action_text = "EINLADEN" if _can_invite_friends() else "ENTFERNEN" elif state == 1: status = "ANFRAGE GESENDET" action_text = "ZURÜCKZIEHEN" elif state == 2: status = "ANFRAGE ERHALTEN" action_text = "ANNEHMEN" else: status = "BLOCKIERT" draw_string(font, row.position + Vector2(20, 30), String(friend.get("name", "PILOT")), HORIZONTAL_ALIGNMENT_LEFT, 450, 19, Color.WHITE) draw_string(font, row.position + Vector2(520, 30), _ui(status), HORIZONTAL_ALIGNMENT_LEFT, 270, 15, Color("83ffc8") if status == "ONLINE" or status == "IM SPIEL" else Color("ffcf7a")) if not action_text.is_empty(): _draw_button(action, action_text, false, Color("24516a")) if online_manager.friends.is_empty(): draw_string(font, Vector2(280, 410), _ui("Noch keine Freunde oder offenen Anfragen."), HORIZONTAL_ALIGNMENT_LEFT, 850, 19, Color("9dbac8")) if not friends_message.is_empty(): draw_string(font, Vector2(240, 805), friends_message, HORIZONTAL_ALIGNMENT_LEFT, 1120, 16, Color("ffcf7a")) if _can_invite_friends(): draw_string(font, Vector2(240, 835), _ui("Privater Raum %s: Freunde mit EINLADEN direkt benachrichtigen.") % multiplayer_manager.private_room_code, HORIZONTAL_ALIGNMENT_LEFT, 1120, 14, Color("8feeff")) draw_string(font, Vector2(240, 866), _ui("Kein Chat: Einladungen enthalten nur einen Raumcode, keine freie Nachricht."), HORIZONTAL_ALIGNMENT_LEFT, 1120, 13, Color("91a8bc")) func _draw_room_code_entry() -> void: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.88)) _draw_button(multiplayer_back_button, "< ZURÜCK", false, Color("26394a")) draw_string(font, Vector2(0, 205), _ui("PRIVATEN RAUM BEITRETEN"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 42, Color("c7f5ff")) draw_string(font, Vector2(0, 260), _ui("GIB DEN SECHSSTELLIGEN CODE DEINES MITPILOTEN EIN"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("66dff3")) draw_rect(room_code_input_rect, Color(0.01, 0.035, 0.065, 0.98), true) draw_rect(room_code_input_rect, Color("65eaff") if room_code_input.length() == 6 else Color("39788e"), false, 3.0) var shown_code := room_code_input if shown_code.is_empty(): shown_code = "------" draw_string(font, Vector2(room_code_input_rect.position.x, room_code_input_rect.position.y + 61), " ".join(shown_code.split("")), HORIZONTAL_ALIGNMENT_CENTER, room_code_input_rect.size.x, 38, Color("8ffaff") if not room_code_input.is_empty() else Color("688293")) draw_string(font, Vector2(0, 470), _ui("BUCHSTABEN UND ZAHLEN • GROSS-/KLEINSCHREIBUNG IST EGAL"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 14, Color("9fdce9")) _draw_button(join_room_button, "RAUM BEITRETEN", room_code_input.length() == 6, Color("185f68") if room_code_input.length() == 6 else Color("26394a")) func _draw_coop_lane_preview(mirrored: bool) -> void: var points := PackedVector2Array([Vector2(205, 300), Vector2(505, 300), Vector2(505, 385), Vector2(720, 385)]) if mirrored: points = multiplayer_manager.mirrored_path(points, 1600.0) for index in points.size() - 1: draw_line(points[index], points[index + 1], Color(0.18, 0.34, 0.42, 0.95), 54.0, true) draw_line(points[index], points[index + 1], Color(0.52, 0.82, 0.88, 0.42), 2.0, true) for index in 5: var x := 300.0 + index * 78.0 if mirrored: x = 1600.0 - x draw_rect(Rect2(Vector2(x - 27, 465), Vector2(54, 54)), Color(0.08, 0.42, 0.55, 0.12), true) draw_rect(Rect2(Vector2(x - 27, 465), Vector2(54, 54)), Color(0.28, 0.72, 0.82, 0.36), false, 1.0) func _draw_matchmaking() -> void: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.88)) _draw_button(multiplayer_back_button, "< ZURÜCK", false, Color("26394a")) var private_mode: bool = multiplayer_manager.matchmaking_mode in ["private_host", "private_join"] draw_string(font, Vector2(0, 210), _ui("PRIVATER KOOP-RAUM" if private_mode else "MITSPIELERSUCHE"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 46, Color("c7f5ff")) var in_lobby: bool = multiplayer_manager.player_slot >= 0 and multiplayer_manager.matchmaking_state in ["hosting", "matched", "lobby", "reconnected"] if in_lobby: _draw_online_lobby_players(font) else: var pulse := 56.0 + sin(elapsed * 3.0) * 8.0 draw_arc(Vector2(800, 390), pulse, -PI * 0.2, PI * 1.45, 52, Color("65eaff"), 5.0) draw_circle(Vector2(800, 390), 22.0, Color("8ffaff")) var mode_labels := {"quick": "SCHNELLES SPIEL", "private_host": "RAUM ERSTELLEN", "private_join": "RAUM BEITRETEN"} var mode_text: String = mode_labels.get(multiplayer_manager.matchmaking_mode, "KOOP-SPIEL") if not multiplayer_manager.private_room_code.is_empty(): mode_text = "RAUMCODE %s" % " ".join(multiplayer_manager.private_room_code.split("")) draw_string(font, Vector2(0, 500), _ui("RAUMCODE %s") % " ".join(multiplayer_manager.private_room_code.split("")) if not multiplayer_manager.private_room_code.is_empty() else _ui(mode_text), HORIZONTAL_ALIGNMENT_CENTER, 1600, 23, Color.WHITE) if in_lobby: _draw_button(lobby_ready_button, "NICHT BEREIT" if multiplayer_manager.local_ready else "BEREIT", multiplayer_manager.local_ready, Color("8b6330") if multiplayer_manager.local_ready else Color("185f68")) _draw_button(cancel_search_button, "ZURÜCK" if multiplayer_manager.matchmaking_state == "room_not_found" else "SUCHE ABBRECHEN", false, Color("5c3342")) if multiplayer_manager.matchmaking_mode == "private_host": _draw_button(friends_button, "FREUNDE EINLADEN", false, Color("285568")) var online_text := "ONLINE • SUCHE NACH EINEM MITPILOTEN" var online_color := Color("83ffc8") if multiplayer_manager.matchmaking_state == "waiting_for_service": online_text = "VERBINDUNG ZUM GERGAMES-SERVER WIRD AUFGEBAUT …" online_color = Color("ffcf7a") elif multiplayer_manager.matchmaking_state == "offline": online_text = "SERVER NOCH NICHT ERREICHBAR • BITTE SPÄTER ERNEUT VERSUCHEN" online_color = Color("ff8e8e") elif multiplayer_manager.matchmaking_state == "matched": online_text = "MITPILOT GEFUNDEN • WÄHLE BEREIT" online_color = Color("8ffaff") elif multiplayer_manager.matchmaking_state in ["lobby", "reconnected"]: online_text = "BEIDE SPIELER MÜSSEN BEREIT SEIN" online_color = Color("8ffaff") elif multiplayer_manager.matchmaking_state == "reconnecting": online_text = "VERBINDUNG WIRD WIEDERHERGESTELLT ." online_color = Color("ffcf7a") elif multiplayer_manager.matchmaking_state == "connection_lost": online_text = "VERBINDUNG VERLOREN • BITTE ZURÜCKKEHREN" online_color = Color("ff8e8e") elif multiplayer_manager.matchmaking_state == "creating_room": online_text = "PRIVATER RAUM WIRD ERSTELLT ." online_color = Color("ffcf7a") elif multiplayer_manager.matchmaking_state == "hosting": online_text = "TEILE DEN RAUMCODE MIT DEINEM MITPILOTEN • WARTE AUF BEITRITT" online_color = Color("83ffc8") elif multiplayer_manager.matchmaking_state == "joining_room": online_text = "PRIVATER RAUM WIRD GESUCHT ." online_color = Color("ffcf7a") elif multiplayer_manager.matchmaking_state == "room_not_found": online_text = "RAUM NICHT GEFUNDEN ODER BEREITS VOLL" online_color = Color("ff8e8e") draw_rect(Rect2(180, 520, 1240, 44), Color(0.005, 0.015, 0.035, 0.98), true) draw_string(font, Vector2(0, 548), _ui(online_text), HORIZONTAL_ALIGNMENT_CENTER, 1600, 16, online_color) func _draw_online_lobby_players(font: Font) -> void: for slot in 2: var rect := Rect2(270 + slot * 560, 285, 500, 130) var player = multiplayer_manager.lobby_players[slot] if slot < multiplayer_manager.lobby_players.size() else null var occupied: bool = player is Dictionary var connected: bool = occupied and bool(player.get("connected", false)) var ready: bool = occupied and bool(player.get("ready", false)) var border := Color("5de8a8") if ready else (Color("65eaff") if connected else Color("425567")) draw_rect(rect, Color(0.012, 0.035, 0.065, 0.96), true) draw_rect(rect, border, false, 3.0) var name := String(player.get("name", "PILOT")) if occupied else "WARTE AUF MITPILOTEN" var side := _ui("LINKE VERTEIDIGUNG" if slot == 0 else "RECHTE VERTEIDIGUNG") var state := _ui("BEREIT" if ready else ("NICHT BEREIT" if connected else "NICHT VERBUNDEN")) draw_string(font, Vector2(rect.position.x + 24, rect.position.y + 43), name.to_upper(), HORIZONTAL_ALIGNMENT_LEFT, rect.size.x - 48, 24, Color("e8fbff")) draw_string(font, Vector2(rect.position.x + 24, rect.position.y + 78), side, HORIZONTAL_ALIGNMENT_LEFT, rect.size.x - 48, 14, Color("8fcddd")) draw_string(font, Vector2(rect.position.x + 24, rect.position.y + 108), state, HORIZONTAL_ALIGNMENT_LEFT, rect.size.x - 48, 16, border) func _draw_profile() -> void: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.76)) _draw_button(profile_back_button, "< ZURÜCK", false, Color("26394a")) draw_string(font, Vector2(0, 112), _ui("PILOTENPROFIL"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 44, Color("c7f5ff")) draw_string(font, Vector2(1110, 112), "VERSION %s" % _app_version(), HORIZONTAL_ALIGNMENT_RIGHT, 290, 14, Color("91a8bc")) draw_rect(Rect2(170, 155, 1260, 600), Color(0.012, 0.035, 0.065, 0.94), true) draw_rect(Rect2(170, 155, 1260, 600), Color("4ab8d1"), false, 2.0) draw_string(font, Vector2(220, 220), account_manager.display_name, HORIZONTAL_ALIGNMENT_LEFT, 520, 30, Color("8ff4ff")) draw_string(font, Vector2(220, 258), account_manager.provider_label(), HORIZONTAL_ALIGNMENT_LEFT, 650, 16, Color("ffcf7a")) draw_string(font, Vector2(220, 292), _ui("SPIELER-ID %s") % account_manager.short_id(), HORIZONTAL_ALIGNMENT_LEFT, 650, 14, Color("91a8bc")) _draw_button(profile_rename_button, "SPIELERNAME ÄNDERN", false, Color("24516a")) draw_string(font, Vector2(840, 215), _ui("KONTO & CLOUD"), HORIZONTAL_ALIGNMENT_LEFT, 470, 18, Color("8feeff")) var cloud_label := "GERGAMES ONLINE VERBUNDEN" if online_manager.is_authenticated() else "GERGAMES ONLINE OFFLINE" var cloud_color := Color("83ffc8") if online_manager.is_authenticated() else Color("ffcf7a") draw_string(font, Vector2(840, 252), _ui(cloud_label), HORIZONTAL_ALIGNMENT_LEFT, 470, 15, cloud_color) draw_string(font, Vector2(840, 282), _ui("GAME CENTER / APPLE NÄCHSTE STUFE"), HORIZONTAL_ALIGNMENT_LEFT, 470, 15, Color("d7e2ea")) draw_string(font, Vector2(840, 312), _ui("GOOGLE PLAY GAMES NÄCHSTE STUFE"), HORIZONTAL_ALIGNMENT_LEFT, 470, 15, Color("d7e2ea")) draw_line(Vector2(220, 345), Vector2(1380, 345), Color("315c70"), 1.0) draw_string(font, Vector2(220, 392), _ui("LOKALE BESTWERTE"), HORIZONTAL_ALIGNMENT_LEFT, 1160, 20, Color("8feeff")) draw_string(font, Vector2(220, 430), _ui("EINSATZSEKTOR"), HORIZONTAL_ALIGNMENT_LEFT, 420, 14, Color("91a8bc")) draw_string(font, Vector2(700, 430), _ui("MISSION"), HORIZONTAL_ALIGNMENT_CENTER, 180, 14, Color("91a8bc")) draw_string(font, Vector2(930, 430), _ui("ENDLOS"), HORIZONTAL_ALIGNMENT_CENTER, 180, 14, Color("91a8bc")) draw_string(font, Vector2(1160, 430), _ui("BESTE PUNKTE"), HORIZONTAL_ALIGNMENT_CENTER, 180, 14, Color("91a8bc")) for index in MAP_NAMES.size(): 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), _ui(MAP_NAMES[index]), HORIZONTAL_ALIGNMENT_LEFT, 420, 12, Color.WHITE) draw_string(font, Vector2(700, y), _ui("WELLE") + " %02d" % account_manager.get_map_best_wave(index, false), HORIZONTAL_ALIGNMENT_CENTER, 180, 12, Color("8ff4ff")) draw_string(font, Vector2(930, y), _ui("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, 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")) _draw_button(profile_privacy_button, "DATENSCHUTZ", false, Color("24475b")) _draw_button(profile_delete_button, "LÖSCHEN BESTÄTIGEN" if profile_delete_armed else "KONTO LÖSCHEN", profile_delete_armed, Color("6b2d3b")) func _draw_profile_name_entry() -> void: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.82)) _draw_button(profile_back_button, "< ZURÜCK", false, Color("26394a")) draw_string(font, Vector2(0, 190), _ui("SPIELERNAME"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 44, Color("c7f5ff")) draw_string(font, Vector2(0, 250), _ui("Dieser Name ist für Mitspieler und in Ranglisten sichtbar."), HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("a9d8e5")) draw_rect(profile_name_input_rect, Color(0.012, 0.035, 0.065, 0.96), true) draw_rect(profile_name_input_rect, Color("65dfff"), false, 3.0) draw_string(font, profile_name_input_rect.position + Vector2(24, 53), profile_name_input, HORIZONTAL_ALIGNMENT_CENTER, profile_name_input_rect.size.x - 48, 26, Color.WHITE) draw_string(font, Vector2(0, 448), _ui("3–20 Zeichen · Buchstaben, Zahlen, Leerzeichen, _ und -"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, Color("91a8bc")) _draw_button(profile_name_save_button, "SPIELERNAME SPEICHERN", false, Color("185f68")) if not profile_message.is_empty(): draw_string(font, Vector2(300, 585), profile_message, HORIZONTAL_ALIGNMENT_CENTER, 1000, 16, Color("ffcf7a")) func _draw_pause_menu() -> void: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.0, 0.01, 0.03, 0.72)) var panel := Rect2(500, 330, 600, 290) draw_rect(panel, Color(0.008, 0.028, 0.055, 0.96), true) draw_rect(panel, Color("3d8ba5"), false, 2.0) draw_string(font, Vector2(panel.position.x, 405), "PAUSE", HORIZONTAL_ALIGNMENT_CENTER, panel.size.x, 38, Color("c7f5ff")) var pause_description := "Die Online-Partie läuft währenddessen weiter." if coop_online_mode else "Die Verteidigung ist angehalten." draw_string(font, Vector2(panel.position.x, 447), _ui(pause_description), HORIZONTAL_ALIGNMENT_CENTER, panel.size.x, 16, Color("9dbac8")) _draw_button(pause_resume_button, "FORTSETZEN", false, Color("185f68")) _draw_button(pause_exit_button, "PARTIE VERLASSEN" if coop_online_mode else "MISSION VERLASSEN", false, Color("60404a")) func _draw_overlay(title: String, subtitle: String) -> void: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.0, 0.01, 0.03, 0.72)) draw_string(font, Vector2(0, 390), title, HORIZONTAL_ALIGNMENT_CENTER, 1600, 54, Color.WHITE) draw_string(font, Vector2(0, 445), subtitle, HORIZONTAL_ALIGNMENT_CENTER, 1600, 19, Color("8adff2")) func _draw_game_over() -> void: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.05, 0.005, 0.015, 0.76)) draw_string(font, Vector2(0, 330), _ui("REAKTOR VERLOREN"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 48, Color("ff8b78")) draw_string(font, Vector2(0, 395), _ui("Erreichte Welle: %d") % wave, HORIZONTAL_ALIGNMENT_CENTER, 1600, 22, Color.WHITE) draw_string(font, Vector2(0, 430), _ui("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")) func _draw_victory() -> void: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.0, 0.035, 0.05, 0.82)) draw_string(font, Vector2(0, 310), _ui("SEKTOR GESICHERT"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 50, Color("8fffea")) draw_string(font, Vector2(0, 375), _ui("Alle %d Angriffswellen wurden abgewehrt.") % MAX_WAVES, HORIZONTAL_ALIGNMENT_CENTER, 1600, 22, Color.WHITE) draw_string(font, Vector2(0, 415), _ui("Der Orbitalkern bleibt unter unserer Kontrolle."), HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("8adff2")) draw_string(font, Vector2(0, 458), _ui("+%d KERNKRISTALLE • GUTHABEN %d") % [last_mission_reward, core_crystals], HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("8fffea")) draw_string(font, Vector2(0, 492), _ui("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: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.0, 0.025, 0.055, 0.88)) draw_string(font, Vector2(0, 280), _ui("SEKTOR GESICHERT"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 50, Color("8fffea")) draw_string(font, Vector2(0, 345), _ui("Alle %d regulären Wellen wurden abgewehrt.") % MAX_WAVES, HORIZONTAL_ALIGNMENT_CENTER, 1600, 22, Color.WHITE) draw_string(font, Vector2(0, 405), _ui("Möchtest du den Einsatz im immer schwereren Endlosmodus fortsetzen?"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 19, Color("8adff2")) if victory_reward_pending: draw_string(font, Vector2(0, 450), _ui("WERBUNG WIRD GELADEN • KERNKRISTALLE DANACH: %d") % int(_difficulty().crystals), HORIZONTAL_ALIGNMENT_CENTER, 1600, 16, Color("ffcf7a")) else: draw_string(font, Vector2(0, 450), _ui("+%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: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.0, 0.02, 0.045, 0.84)) draw_string(font, Vector2(0, 315), _ui("ENDLOSAUFTRAG BEENDET"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 48, Color("8feeff")) draw_string(font, Vector2(0, 382), _ui("Erreichte Welle: %d") % wave, HORIZONTAL_ALIGNMENT_CENTER, 1600, 23, Color.WHITE) draw_string(font, Vector2(0, 425), _ui("Der reguläre Sektorauftrag bleibt erfolgreich abgeschlossen."), HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("8adff2")) draw_string(font, Vector2(0, 462), _ui("KERNKRISTALLE %d") % core_crystals, HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("8fffea")) draw_string(font, Vector2(0, 492), _ui("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")) func _draw_tutorial() -> void: var font := ThemeDB.fallback_font var title := "1/3 VERTEIDIGUNG WÄHLEN" var body := "Wähle unten das KINETIK-Geschütz." if tutorial_step == 1: title = "2/3 TURM PLATZIEREN" body = "Setze den Turm auf eine freie Metallfläche neben der Flugroute." elif tutorial_step == 2: title = "3/3 ANGRIFF STARTEN" body = "Starte oben rechts die erste Welle." draw_rect(Rect2(30, 110, 500, 132), Color(0.01, 0.035, 0.07, 0.95)) draw_rect(Rect2(30, 110, 500, 132), Color("55d8ee"), false, 2.0) draw_string(font, Vector2(50, 147), _ui(title), HORIZONTAL_ALIGNMENT_LEFT, 450, 19, Color("8ff4ff")) draw_string(font, Vector2(50, 184), _ui(body), HORIZONTAL_ALIGNMENT_LEFT, 445, 15, Color.WHITE) _draw_button(tutorial_skip_button, "ÜBERSPRINGEN", false, Color("24394c")) func _is_valid_build_position(position: Vector2, owner := 0, allow_any_coop_side := false) -> bool: var correct_coop_side := true if coop_mode and not allow_any_coop_side: correct_coop_side = position.x < COOP_MIRROR_X - GRID_SIZE * 0.5 if owner == 0 else position.x > COOP_MIRROR_X + GRID_SIZE * 0.5 return correct_coop_side and _is_in_build_zone(position) and not _is_in_reactor_zone(position) and not _is_path_cell(position) and position.x >= GRID_SIZE * 0.5 and position.x <= _active_world_width() - GRID_SIZE * 0.5 and position.y >= GRID_SIZE * 0.5 and position.y <= WORLD_SIZE.y - GRID_SIZE * 0.5 and _tower_at(position, GRID_SIZE * 0.72) < 0