Files
Void-Bastion/main.gd
T
2026-10-06 18:34:49 +02:00

2601 lines
118 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
extends Node2D
const SCREEN := Vector2(1600.0, 900.0)
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 := 92.0
const PLAYFIELD_BOTTOM := 758.0
const GRID_SIZE := 80.0
const TOWER_TEXTURE := preload("res://assets/defense_towers.png")
const PLASMA_TEXTURE := preload("res://assets/tower_plasma.png")
const EMP_TEXTURE := preload("res://assets/tower_emp.png")
const RAILGUN_TEXTURE := preload("res://assets/tower_railgun.png")
const ENEMY_TEXTURE := preload("res://assets/enemy_ships.png")
const 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 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"
]
const TOWER_DATA := [
{"name": "KINETIK", "cost": 120, "damage": 22.0, "range": 190.0, "rate": 0.55, "shot_speed": 620.0, "damage_type": "EINZELZIEL", "color": Color("7de7ff"), "unlock_cost": 0, "description": "Zuverlässiges Projektilgeschütz."},
{"name": "LASER", "cost": 180, "damage": 12.0, "range": 165.0, "rate": 0.18, "shot_speed": 1100.0, "damage_type": "EINZELZIEL", "color": Color("62fff1"), "unlock_cost": 0, "description": "Schneller Strahl, stark gegen Schilde."},
{"name": "RAKETE", "cost": 250, "damage": 65.0, "range": 260.0, "rate": 1.40, "shot_speed": 360.0, "damage_type": "FLÄCHE 76", "color": Color("ffb35a"), "unlock_cost": 0, "description": "Explosionsschaden gegen Gruppen."},
{"name": "PLASMA", "cost": 230, "damage": 48.0, "range": 240.0, "rate": 1.05, "shot_speed": 480.0, "damage_type": "PLASMA-FLÄCHE 92", "color": Color("e27bff"), "unlock_cost": 18, "description": "Plasmaexplosion trifft Gegner im Umkreis."},
{"name": "EMP-PULS", "cost": 210, "damage": 34.0, "range": 215.0, "rate": 0.92, "shot_speed": 540.0, "damage_type": "EMP-FLÄCHE 118", "color": Color("77e8ff"), "unlock_cost": 24, "description": "Breiter Impuls trifft ganze Gegnergruppen."},
{"name": "RAILGUN", "cost": 300, "damage": 110.0, "range": 330.0, "rate": 1.85, "shot_speed": 1450.0, "damage_type": "SCHILD-DURCHSCHLAG", "color": Color("87efff"), "unlock_cost": 30, "description": "Weitreichender Schuss, ignoriert Schildschutz."}
]
const ENEMY_DATA := [
{"name": "JAEGER", "hp": 70.0, "speed": 105.0, "reward": 18, "size": 58.0, "breach_damage": 1, "shield_resistance": 0.28},
{"name": "FREGATTE", "hp": 185.0, "speed": 72.0, "reward": 34, "size": 78.0, "breach_damage": 2, "shield_resistance": 0.28},
{"name": "KREUZER", "hp": 520.0, "speed": 43.0, "reward": 78, "size": 112.0, "breach_damage": 3, "shield_resistance": 0.28},
{"name": "ABFANGJAEGER", "hp": 52.0, "speed": 178.0, "reward": 22, "size": 48.0, "breach_damage": 2, "shield_resistance": 0.28},
{"name": "SCHILDKREUZER", "hp": 260.0, "speed": 58.0, "reward": 52, "size": 92.0, "breach_damage": 2, "shield_resistance": 0.18, "default_shield": true},
{"name": "BELAGERER", "hp": 740.0, "speed": 48.0, "reward": 108, "size": 128.0, "breach_damage": 4, "shield_resistance": 0.28}
]
var path_points := PackedVector2Array()
var path_cells: Dictionary = {}
var build_zones: Array[Rect2] = []
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]
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 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(30, 390, 340, 130), Rect2(430, 390, 340, 130), Rect2(830, 390, 340, 130), Rect2(1230, 390, 340, 130),
Rect2(255, 540, 340, 130), Rect2(630, 540, 340, 130), Rect2(1005, 540, 340, 130)
]
var difficulty_buttons := [Rect2(535, 520, 165, 50), Rect2(718, 520, 165, 50), Rect2(901, 520, 165, 50)]
var wave_button := Rect2(1320, 18, 255, 54)
var pause_button := Rect2(1242, 18, 62, 54)
var speed_button := Rect2(1170, 18, 62, 54)
var auto_wave_button := Rect2(1320, 73, 255, 22)
var back_button := Rect2(1070, 18, 90, 54)
var tower_buttons := [
Rect2(15, 768, 255, 122), Rect2(278, 768, 255, 122), Rect2(541, 768, 255, 122),
Rect2(804, 768, 255, 122), Rect2(1067, 768, 255, 122), Rect2(1330, 768, 255, 122)
]
var upgrade_button := Rect2(1275, 187, 145, 44)
var sell_button := Rect2(1430, 187, 145, 44)
var tutorial_skip_button := Rect2(365, 205, 145, 30)
var reward_ad_button := Rect2(1070, 690, 300, 54)
var tower_info_rect := Rect2(1275, 103, 300, 76)
var endless_button := Rect2(545, 515, 510, 70)
var finish_mission_button := Rect2(625, 610, 350, 62)
var profile_button := Rect2(1280, 25, 270, 52)
var leaderboard_button := Rect2(100, 790, 250, 54)
var shop_button := Rect2(640, 790, 250, 54)
var map_step_back_button := Rect2(55, 320, 170, 50)
var shop_back_button := Rect2(55, 35, 185, 54)
var shop_purchase_buttons := [Rect2(190, 545, 360, 62), Rect2(620, 545, 360, 62), Rect2(1050, 545, 360, 62)]
var map_shop_purchase_buttons := [Rect2(70, 565, 350, 62), Rect2(460, 565, 350, 62), Rect2(850, 565, 350, 62), Rect2(1240, 565, 350, 62)]
var store_tower_tab_button := Rect2(590, 174, 200, 42)
var store_map_tab_button := Rect2(810, 174, 200, 42)
var leaderboard_back_button := Rect2(55, 35, 185, 54)
var leaderboard_mission_button := Rect2(510, 212, 270, 54)
var leaderboard_endless_button := Rect2(820, 212, 270, 54)
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(370, 790, 250, 54)
var multiplayer_back_button := Rect2(55, 35, 185, 54)
var quick_match_button := Rect2(125, 665, 300, 64)
var friend_match_button := Rect2(475, 665, 300, 64)
var private_match_button := Rect2(825, 665, 300, 64)
var ai_match_button := Rect2(1175, 665, 300, 64)
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(920, 18, 140, 54)
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(910, 790, 250, 54)
var exit_button := Rect2(1180, 790, 250, 54)
var audio_panel_rect := Rect2(450, 325, 700, 270)
var audio_close_button := Rect2(1010, 345, 110, 44)
var audio_slider_rect := Rect2(565, 460, 520, 32)
@onready var ads_manager = $AdsManager
@onready var account_manager = $AccountManager
@onready var multiplayer_manager = $MultiplayerManager
@onready var online_manager = $OnlineManager
@onready var update_manager = $UpdateManager
@onready var music_player: AudioStreamPlayer = $MusicPlayer
@onready var ui_sound_player: AudioStreamPlayer = $UiSoundPlayer
func _ready() -> void:
_select_map(selected_map)
_load_progress()
_load_audio_settings()
# 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)
queue_redraw()
func _process(delta: float) -> void:
elapsed += delta
if status_timer > 0.0:
status_timer -= delta
if game_mode == "playing":
if auto_wave_countdown > 0.0:
auto_wave_countdown -= delta
if auto_wave_countdown <= 0.0 and auto_start_waves and not wave_active and (endless_mode or wave < MAX_WAVES):
_start_wave()
if coop_online_mode:
_update_online_interpolation(delta)
_update_effects(delta)
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 is_boss := wave > 0 and wave % BOSS_WAVE_INTERVAL == 0 and enemies_to_spawn <= (2 if coop_mode else 1)
var type := _enemy_type_for_wave(wave, randf(), is_boss)
var data: Dictionary = ENEMY_DATA[type]
var health_scale := _enemy_health_scale(wave, is_boss)
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,
"speed": data.speed * (1.0 + wave * 0.012) * (0.82 if is_boss else 1.0) * float(_difficulty().speed),
"reward": int((data.reward + wave) * (4 if is_boss else 1) * float(_difficulty().income)),
"angle": 0.0,
"flash": 0.0,
"boss": is_boss,
"size_scale": 1.34 if is_boss else 1.0,
"shield": bool(data.get("default_shield", false)) or (wave >= 4 and randf() < minf(0.42, 0.10 + wave * 0.018 + selected_difficulty * 0.06)),
"shield_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 5
var advanced_offset := 2 if selected_difficulty == 2 else (1 if selected_difficulty == 1 else 0)
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 _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 best_progress := -1.0
var effective_range := _tower_range(tower.type, tower.level)
for enemy in enemies:
if tower.pos.distance_to(enemy.pos) <= effective_range:
var route: PackedVector2Array = _enemy_route(enemy)
var progress: float = enemy.path_index * 1000.0 - enemy.pos.distance_to(route[enemy.path_index])
if progress > best_progress:
best_progress = progress
best = enemy
return best
func _fire_tower(tower: Dictionary, target: Dictionary) -> void:
var data: Dictionary = TOWER_DATA[tower.type]
var damage := _tower_damage(tower.type, tower.level)
if tower.type == 1:
_apply_damage(target, damage, int(tower.get("owner", 0)), tower.type)
beams.append({"from": tower.pos, "to": target.pos, "ttl": 0.09, "color": data.color})
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 0.38)
for enemy in enemies.duplicate():
if enemy.pos.distance_to(target.pos) < blast_radius:
_apply_damage(enemy, shot.damage if enemy.id == target.id else shot.damage * splash_factor, int(shot.get("owner", 0)), int(shot.type))
var blast_color: Color = Color("ff9b4a") if shot.type == 2 else (Color("db65ff") if shot.type == 3 else Color("71eaff"))
_spawn_burst(target.pos, blast_color, 14 if shot.type == 2 else 18)
else:
_apply_damage(target, shot.damage, int(shot.get("owner", 0)), 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
var applied_damage := amount
if bool(enemy.get("shield", false)) and weapon_type not in [1, 5]:
applied_damage *= float(enemy.get("shield_resistance", 0.28))
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 in [KEY_ESCAPE, KEY_BACK, KEY_Q]:
if audio_settings_open:
audio_settings_open = false
_save_audio_settings()
queue_redraw()
return
if game_mode == "menu":
get_tree().quit()
return
if audio_settings_open and event is InputEventMouseMotion and (event.button_mask & MOUSE_BUTTON_MASK_LEFT) != 0:
_set_master_volume((event.position.x - audio_slider_rect.position.x) / audio_slider_rect.size.x)
return
if audio_settings_open and event is InputEventScreenDrag:
_set_master_volume((event.position.x - audio_slider_rect.position.x) / audio_slider_rect.size.x)
return
if audio_settings_open and ((event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and not event.pressed) or (event is InputEventScreenTouch and not event.pressed)):
_save_audio_settings()
return
if game_mode == "profile_name_entry" and event is InputEventKey and event.pressed and not event.echo:
if event.keycode == KEY_ESCAPE:
_hide_virtual_keyboard()
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 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 audio_settings_open:
if audio_close_button.has_point(position) or not audio_panel_rect.has_point(position):
audio_settings_open = false
_save_audio_settings()
elif audio_slider_rect.grow(12.0).has_point(position):
_set_master_volume((position.x - audio_slider_rect.position.x) / audio_slider_rect.size.x)
_save_audio_settings()
queue_redraw()
return
if game_mode == "menu":
if update_notice_visible and update_button.has_point(position):
OS.shell_open(str(available_update.get("url", "https://void-bastion.gergames.de/")))
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 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 == "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 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()
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 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 back_button.has_point(position):
_return_to_menu()
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 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
for index in tower_buttons.size():
if tower_buttons[index].has_point(position):
var tower_type := index
if not unlocked_towers.has(tower_type):
store_return_mode = game_mode
game_mode = "store"
queue_redraw()
return
selected_tower_type = tower_type
selected_tower_index = -1
pending_touch_build_position = Vector2(-1, -1)
if not tutorial_completed and tutorial_step == 0:
tutorial_step = 1
return
if selected_tower_index >= 0:
if upgrade_button.has_point(position):
_upgrade_selected()
return
if sell_button.has_point(position):
_sell_selected()
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))
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)]
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 side_name := "PARTNER-SEITE" if coop_online_mode else "KI-SEITE"
_show_status(side_name if camera_offset.x > 0.0 else "DEINE SEITE")
func _start_coop_ai_game() -> void:
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()
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:
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()
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
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]
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:
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()
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:
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()
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 _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
var wave_population_scale := 1.0 + maxf(0.0, wave - 3.0) * 0.08
if selected_difficulty == 2:
wave_population_scale += maxf(0.0, wave - 4.0) * 0.025
if endless_mode:
wave_population_scale += maxf(0.0, wave - MAX_WAVES) * 0.035
enemies_to_spawn = ceili((7 + wave * 2) * float(_difficulty().count) * wave_population_scale) * (2 if coop_mode else 1)
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:
_show_status("ONLINE-PARTIEN KÖNNEN NICHT PAUSIERT WERDEN")
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 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)
if selected_tower_type < 0 or not unlocked_towers.has(selected_tower_type):
_show_status("TURM ZUERST IM ARSENAL FREISCHALTEN")
return
var data: Dictionary = TOWER_DATA[selected_tower_type]
var credit_slot := _local_credit_slot()
if _player_credits(credit_slot) < data.cost:
_show_status("NICHT GENUG ENERGIE")
return
if not _is_valid_build_position(position, credit_slot):
_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
_spend_credits(0, int(data.cost))
towers_built_this_run += 1
towers.append({"pos": position, "type": selected_tower_type, "level": 1, "cooldown": 0.15, "angle": 0.0, "owner": 0})
selected_tower_index = towers.size() - 1
selected_tower_type = -1
pending_touch_build_position = Vector2(-1, -1)
if not tutorial_completed and tutorial_step == 1:
tutorial_step = 2
_spawn_burst(position, 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] 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 tower_type := ai_tower_count % TOWER_DATA.size()
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)
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 _find_ai_build_position(offset: 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[offset % TOWER_DATA.size()].range
var best_position := Vector2.ZERO
var best_score := INF
for x in range(1560, 2841, 80):
for y in range(120, 721, 80):
var candidate := Vector2(x, y)
if _is_valid_build_position(candidate, 1):
var distance := _distance_to_route(candidate, 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
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))
credits = _player_credits(local_player_slot)
var previous_enemy_positions: Dictionary = {}
for enemy in enemies:
previous_enemy_positions[int(enemy.get("id", -1))] = enemy.get("pos", Vector2.ZERO)
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)))
synced_enemies.append({
"id": enemy_id,
"type": int(entry.get("type", 0)),
"pos": previous_enemy_positions.get(enemy_id, target_position),
"target_pos": target_position,
"path_index": int(entry.get("path_index", 1)),
"route_id": int(entry.get("route_id", 0)),
"hp": float(entry.get("hp", 1.0)),
"max_hp": float(entry.get("max_hp", 1.0)),
"speed": float(entry.get("speed", 0.0)),
"reward": int(entry.get("reward", 0)),
"angle": float(entry.get("angle", 0.0)),
"flash": 0.0,
"boss": bool(entry.get("boss", false)),
"size_scale": 1.34 if bool(entry.get("boss", false)) else 1.0
})
enemies = synced_enemies
var synced_towers: Array[Dictionary] = []
for entry in snapshot.get("towers", []):
if entry is Dictionary:
synced_towers.append({
"id": int(entry.get("id", -1)),
"pos": Vector2(float(entry.get("x", 0.0)), float(entry.get("y", 0.0))),
"type": int(entry.get("type", 0)),
"level": int(entry.get("level", 1)),
"owner": int(entry.get("owner", 0)),
"cooldown": float(entry.get("cooldown", 0.0)),
"angle": float(entry.get("angle", 0.0))
})
towers = synced_towers
if selected_tower_index >= towers.size():
selected_tower_index = -1
if bool(snapshot.get("game_over", false)) and game_mode == "playing":
_game_over()
func _update_online_interpolation(delta: float) -> void:
var weight := clampf(delta * 12.0, 0.0, 1.0)
for enemy in enemies:
enemy.pos = enemy.pos.lerp(enemy.get("target_pos", enemy.pos), weight)
func _handle_online_event(event: Dictionary) -> void:
if not coop_online_mode:
return
match String(event.get("type", "")):
"wave_complete":
_show_status("WELLE %d ABGEWEHRT +%d" % [int(event.get("wave", wave)), int(event.get("bonus", 0))])
"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 = 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)
_apply_master_volume()
func _set_master_volume(value: float) -> void:
master_volume = clampf(value, 0.0, 1.0)
_apply_master_volume()
queue_redraw()
func _apply_master_volume() -> void:
var master_bus := AudioServer.get_bus_index("Master")
if master_bus >= 0:
AudioServer.set_bus_volume_db(master_bus, linear_to_db(maxf(master_volume, 0.0001)))
func _save_audio_settings() -> void:
var config := ConfigFile.new()
config.set_value("audio", "master_volume", master_volume)
config.save("user://void_bastion_settings.cfg")
func _build_ambient_stream() -> AudioStreamWAV:
const sample_rate := 22050
const duration := 12
var frame_count := sample_rate * duration
var samples := PackedByteArray()
samples.resize(frame_count * 4)
for frame in frame_count:
var time := float(frame) / sample_rate
var slow_breathe := 0.72 + 0.28 * sin(TAU * time / duration)
var chord := sin(TAU * 55.0 * time) * 0.34 + sin(TAU * 82.5 * time) * 0.22 + sin(TAU * 110.0 * time) * 0.16 + sin(TAU * 165.0 * time) * 0.08
var shimmer := sin(TAU * 220.0 * time + sin(TAU * time / duration) * 0.45) * 0.035
var mono := clampf((chord + shimmer) * slow_breathe * 0.12, -1.0, 1.0)
var left := int(mono * 32767.0)
var right := int(clampf(mono * (0.97 + 0.03 * sin(TAU * time / duration)), -1.0, 1.0) * 32767.0)
samples.encode_s16(frame * 4, left)
samples.encode_s16(frame * 4 + 2, right)
var stream := AudioStreamWAV.new()
stream.format = AudioStreamWAV.FORMAT_16_BITS
stream.mix_rate = sample_rate
stream.stereo = true
stream.loop_mode = AudioStreamWAV.LOOP_FORWARD
stream.loop_end = frame_count
stream.data = samples
return stream
func _build_ui_click_stream() -> AudioStreamWAV:
const sample_rate := 22050
const duration := 0.075
var frame_count := int(sample_rate * duration)
var samples := PackedByteArray()
samples.resize(frame_count * 2)
var phase := 0.0
for frame in frame_count:
var time := float(frame) / sample_rate
var frequency := lerpf(1350.0, 720.0, time / duration)
phase += TAU * frequency / sample_rate
var envelope := 1.0 - time / duration
samples.encode_s16(frame * 2, int(sin(phase) * envelope * 0.14 * 32767.0))
var stream := AudioStreamWAV.new()
stream.format = AudioStreamWAV.FORMAT_16_BITS
stream.mix_rate = sample_rate
stream.stereo = false
stream.data = samples
return stream
func _play_ui_sound() -> void:
if ui_sound_player.stream != null:
ui_sound_player.play()
func _save_progress() -> void:
var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if file:
file.store_string(JSON.stringify({
"best_wave": best_wave,
"best_endless_wave": best_endless_wave,
"core_crystals": core_crystals,
"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]
var saved_unlocks = data.get("unlocked_towers", [0, 1, 2])
if saved_unlocks is Array:
for tower_type in saved_unlocks:
if int(tower_type) >= 0 and int(tower_type) < 3 and not unlocked_towers.has(int(tower_type)):
unlocked_towers.append(int(tower_type))
elif int(tower_type) >= 3 and int(tower_type) < TOWER_DATA.size() and not unlocked_towers.has(int(tower_type)):
unlocked_towers.append(int(tower_type))
unlocked_maps = [0, 1, 2]
var saved_maps = data.get("unlocked_maps", [0, 1, 2])
if saved_maps is Array:
for map_index in saved_maps:
if int(map_index) >= 3 and int(map_index) < MAP_NAMES.size() and not unlocked_maps.has(int(map_index)):
unlocked_maps.append(int(map_index))
selected_difficulty = clampi(int(data.get("selected_difficulty", 1)), 0, DIFFICULTY_DATA.size() - 1)
tutorial_completed = bool(data.get("tutorial_completed", false))
func _draw() -> void:
if game_mode in ["menu", "store", "leaderboard", "profile", "profile_name_entry", "multiplayer_menu", "room_code_entry", "matchmaking"]:
draw_texture_rect(MAP_TEXTURES[selected_map], Rect2(Vector2.ZERO, SCREEN), false, Color("f3f6fb"))
draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.01, 0.025, 0.055, 0.06))
if game_mode == "menu":
_draw_menu()
if audio_settings_open:
_draw_audio_settings()
elif game_mode == "store":
_draw_store()
elif game_mode == "leaderboard":
_draw_leaderboard()
elif game_mode == "profile":
_draw_profile()
elif game_mode == "profile_name_entry":
_draw_profile_name_entry()
elif game_mode == "multiplayer_menu":
_draw_multiplayer_menu()
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_overlay("PAUSE", "Zum Fortsetzen tippen oder P drücken")
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()
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:
_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 beam in beams:
draw_line(beam.from, beam.to, Color(beam.color, 0.92), 3.0)
draw_line(beam.from, beam.to, Color.WHITE, 1.0)
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 _draw_route() -> void:
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
draw_set_transform(center - camera_offset, angle, Vector2.ONE)
var variant := absi(cell.x * 3 + cell.y * 5) % 4
var ray_x := -18.0 if variant % 2 == 0 else 18.0
var tile_opacity := _path_cell_opacity(cell)
if not is_corner:
draw_colored_polygon(PackedVector2Array([Vector2(ray_x - 5, -36), Vector2(ray_x + 5, -36), Vector2(ray_x + 14, -62), Vector2(ray_x - 14, -62)]), Color(1.0, 0.48, 0.12, 0.055 * 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 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):
continue
if _is_path_cell(center):
continue
var cell_line_color := line_color
var cell_fill_color := fill_color
if coop_mode and center.x > COOP_MIRROR_X:
cell_line_color = Color(1.0, 0.67, 0.32, 0.16)
cell_fill_color = Color(0.75, 0.35, 0.08, 0.025)
var cell := Rect2(center - Vector2(34, 34), Vector2(68, 68))
draw_rect(cell, cell_fill_color, true)
draw_rect(cell, 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)) == 1:
draw_circle(tower.pos, size * 0.54, Color(1.0, 0.56, 0.20, 0.18))
draw_arc(tower.pos, size * 0.54, 0.0, TAU, 28, Color(1.0, 0.70, 0.34, 0.72), 2.0)
draw_set_transform(tower.pos - camera_offset, tower.angle, Vector2.ONE)
var destination := Rect2(-size * 0.5, -size * 0.5, size, size)
if tower.type < 3:
var source_width: float = TOWER_TEXTURE.get_width() / 3.0
draw_texture_rect_region(TOWER_TEXTURE, destination, Rect2(source_width * tower.type, 0, source_width, TOWER_TEXTURE.get_height()))
else:
var tower_texture: Texture2D = PLASMA_TEXTURE if tower.type == 3 else (EMP_TEXTURE if tower.type == 4 else RAILGUN_TEXTURE)
draw_texture_rect(tower_texture, destination, false)
draw_set_transform(-camera_offset, 0.0, Vector2.ONE)
for level_mark in tower.level:
draw_circle(tower.pos + Vector2(-10 + level_mark * 10, 43), 3.0, Color("6cecff"))
func _draw_enemy(enemy: Dictionary) -> void:
var enemy_type := int(enemy.type)
var enemy_texture: Texture2D = ENEMY_TEXTURE
var source: Rect2
var destination: Rect2
var size: float = ENEMY_DATA[enemy.type].size * enemy.size_scale
if enemy_type < 3:
var source_width: float = ENEMY_TEXTURE.get_width() / 3.0
source = Rect2(source_width * enemy_type, 0, source_width, ENEMY_TEXTURE.get_height())
destination = Rect2(-size * 0.5, -size * 0.5, size, size)
else:
enemy_texture = [ENEMY_INTERCEPTOR_TEXTURE, ENEMY_SHIELD_CARRIER_TEXTURE, ENEMY_SIEGE_DESTROYER_TEXTURE][enemy_type - 3]
source = Rect2(Vector2.ZERO, enemy_texture.get_size())
destination = Rect2(-size * 0.75, -size * 0.5, size * 1.5, size)
var tint := Color.WHITE if enemy.flash <= 0.0 else Color("ffb6a3")
if bool(enemy.get("shield", false)):
draw_circle(enemy.pos, size * 0.63, Color(0.25, 0.80, 1.0, 0.12))
draw_arc(enemy.pos, size * 0.63, 0.0, TAU, 28, Color(0.38, 0.86, 1.0, 0.76), 2.2)
draw_set_transform(enemy.pos - camera_offset, enemy.angle, Vector2.ONE)
draw_texture_rect_region(enemy_texture, 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), "SCHILD", HORIZONTAL_ALIGNMENT_CENTER, 60, 9, Color("75eaff"))
if enemy.boss:
draw_string(ThemeDB.fallback_font, enemy.pos + Vector2(-45, -size * 0.58), "KOMMANDOSCHIFF", HORIZONTAL_ALIGNMENT_CENTER, 90, 11, Color("ffab78"))
func _draw_hud() -> void:
var font := ThemeDB.fallback_font
draw_rect(Rect2(0, 0, 1600, 90), Color(0.015, 0.035, 0.07, 0.92))
draw_line(Vector2(0, 90), Vector2(1600, 90), Color("2e7593"), 2.0)
draw_string(font, Vector2(25, 35), "VOID BASTION", HORIZONTAL_ALIGNMENT_LEFT, -1, 25, Color("bcefff"))
draw_string(font, Vector2(25, 65), "ENERGIE %04d" % _player_credits(_local_credit_slot()), HORIZONTAL_ALIGNMENT_LEFT, -1, 20, Color("65eaff"))
draw_string(font, Vector2(310, 52), "REAKTOR %02d" % max(reactor_lives, 0), HORIZONTAL_ALIGNMENT_LEFT, -1, 22, Color("8feeff"))
draw_string(font, Vector2(535, 52), "WELLE %02d/%02d" % [wave, MAX_WAVES], HORIZONTAL_ALIGNMENT_LEFT, -1, 22, Color.WHITE)
var partner_slot := 1 - _local_credit_slot()
var partner_name := "PARTNER" if coop_online_mode else "KI"
var map_label := "KOOP-ARENA • %s %04d EN" % [partner_name, _player_credits(partner_slot)] if coop_mode else "KARTE %s" % MAP_NAMES[selected_map]
draw_string(font, Vector2(715, 52), map_label, HORIZONTAL_ALIGNMENT_LEFT, -1, 15, Color("ffcf7a") if coop_mode else Color("91a8bc"))
draw_string(font, Vector2(920, 34), _difficulty().name, HORIZONTAL_ALIGNMENT_LEFT, -1, 13, Color("ffcf7a"))
draw_string(font, Vector2(920, 62), "KERNKRISTALLE %04d" % core_crystals, HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color("89f4ff"))
_draw_button(back_button, "ZURÜCK", false, Color("26394a"))
if coop_mode:
var viewing_right := camera_offset.x > 0.0
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, "WELLE %d STARTEN" % (wave + 1) if not wave_active else "%d IM ANFLUG" % (enemies.size() + enemies_to_spawn), wave_active)
if tutorial_completed 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)
for index in tower_buttons.size():
_draw_tower_card(index, index, selected_tower_type == index)
if selected_tower_index >= 0 and selected_tower_index < towers.size():
var tower: Dictionary = towers[selected_tower_index]
var upgrade_label := "MAX. STUFE" if tower.level >= 3 else "UPGRADE %d" % _upgrade_cost(tower)
_draw_button(upgrade_button, upgrade_label, false)
_draw_button(sell_button, "VERKAUFEN", false, Color("6b3542"))
if selected_tower_type >= 0 or selected_tower_index >= 0:
_draw_tower_info_panel()
draw_rect(auto_wave_button, Color("102d40"), true)
draw_rect(auto_wave_button, Color("37677b"), false, 1.0)
draw_rect(Rect2(auto_wave_button.position + Vector2(7, 4), Vector2(14, 14)), Color("07131f"), true)
if auto_start_waves:
draw_rect(Rect2(auto_wave_button.position + Vector2(10, 7), Vector2(8, 8)), Color("65eaff"), true)
draw_string(font, auto_wave_button.position + Vector2(29, 16), "NÄCHSTE WELLE AUTO STARTEN", HORIZONTAL_ALIGNMENT_LEFT, 220, 11, Color("dceaf0"))
if speed_dropdown_open:
for speed_index in SPEED_OPTIONS.size():
var option_rect := _speed_option_rect(speed_index)
draw_rect(option_rect, Color("102d40"), true)
draw_rect(option_rect, Color("37677b"), false, 1.0)
draw_string(font, option_rect.position + Vector2(0, 18), "%.0f×" % SPEED_OPTIONS[speed_index], HORIZONTAL_ALIGNMENT_CENTER, option_rect.size.x, 13, Color("8ff4ff") if is_equal_approx(game_speed, SPEED_OPTIONS[speed_index]) else Color.WHITE)
if status_timer > 0.0:
draw_rect(Rect2(560, 108, 480, 44), Color(0.01, 0.035, 0.07, 0.88), true)
draw_string(font, Vector2(570, 138), status_text, HORIZONTAL_ALIGNMENT_CENTER, 460, 18, Color("a9efff"))
func _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)
draw_rect(rect, Color("226c88") if active else (Color("173c55") if unlocked else Color("102b3b")), true)
draw_rect(rect, Color("65dfff") if active else (Color("37677b") if unlocked else Color("29485a")), false, 1.5)
draw_string(font, rect.position + Vector2(9, 22), data.name, HORIZONTAL_ALIGNMENT_LEFT, 150, 15, Color.WHITE if unlocked else Color("9ab0ba"))
draw_string(font, rect.position + Vector2(158, 22), "%d EN" % data.cost, HORIZONTAL_ALIGNMENT_RIGHT, 86, 12, Color("7ce7ff"))
draw_string(font, rect.position + Vector2(9, 47), "SCH %.0f • %.2f SCHUSS/S" % [data.damage, _tower_shots_per_second(type, 1)], HORIZONTAL_ALIGNMENT_LEFT, 237, 11, Color("dceaf0"))
draw_string(font, rect.position + Vector2(9, 72), "RW %.0f • %s" % [data.range, data.damage_type], HORIZONTAL_ALIGNMENT_LEFT, 237, 10, Color("9fdce9"))
var hint := "ANTIPPEN • BAUPLATZ WÄHLEN" if unlocked else "GESPERRT • IM SHOP FREISCHALTEN"
draw_string(font, rect.position + Vector2(9, 105), hint, HORIZONTAL_ALIGNMENT_LEFT, 237, 10, Color("719aaa") if unlocked else Color("ffcf7a"))
func _draw_store() -> void:
var font := ThemeDB.fallback_font
draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.70), true)
_draw_button(shop_back_button, "< ZURÜCK", false, Color("26394a"))
draw_string(font, Vector2(0, 115), "GERGAMES-ARSENAL", HORIZONTAL_ALIGNMENT_CENTER, 1600, 42, Color("c7f5ff"))
draw_string(font, Vector2(0, 160), "Türme und Einsatzsektoren mit Kernkristallen freischalten", HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("8feeff"))
draw_string(font, Vector2(1270, 55), "KERNKRISTALLE %04d" % core_crystals, HORIZONTAL_ALIGNMENT_RIGHT, 270, 20, Color("8ff4ff"))
_draw_button(store_tower_tab_button, "TÜRME", store_tab == "towers", Color("24475b"))
_draw_button(store_map_tab_button, "KARTEN", store_tab == "maps", Color("24475b"))
if store_tab == "maps":
for slot in map_shop_purchase_buttons.size():
var map_index := slot + 3
var button: Rect2 = map_shop_purchase_buttons[slot]
var card := Rect2(button.position.x, 225, 350, 405)
var is_unlocked := unlocked_maps.has(map_index)
draw_rect(card, Color(0.012, 0.04, 0.07, 0.94), true)
draw_rect(card, Color("65dfff", 0.72), false, 2.0)
draw_texture_rect(MAP_TEXTURES[map_index], Rect2(card.position + Vector2(12, 12), Vector2(326, 176)), false)
draw_string(font, card.position + Vector2(10, 222), MAP_NAMES[map_index], HORIZONTAL_ALIGNMENT_CENTER, 330, 17, Color("9feeff"))
draw_string(font, card.position + Vector2(10, 250), MAP_DESCRIPTIONS[map_index], HORIZONTAL_ALIGNMENT_CENTER, 330, 13, Color("dceaf0"))
draw_string(font, card.position + Vector2(10, 282), "80-PX-RASTER • DIREKTZIEL REAKTOR", HORIZONTAL_ALIGNMENT_CENTER, 330, 10, Color("8fddef"))
var button_text := "FREIGESCHALTET" if is_unlocked else "FREISCHALTEN • %d KERNKRISTALLE" % int(MAP_UNLOCK_COSTS[map_index])
_draw_button(button, button_text, is_unlocked, Color("24516a") if not is_unlocked else Color("245843"))
draw_string(font, Vector2(0, 685), "Jeder Sektor hat einen eigenen, rastergenauen Laufweg bis in den Reaktor.", HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("ffcf7a"))
return
var textures: Array[Texture2D] = [PLASMA_TEXTURE, EMP_TEXTURE, RAILGUN_TEXTURE]
for slot in shop_purchase_buttons.size():
var tower_type := slot + 3
var card := Rect2(shop_purchase_buttons[slot].position.x, 215, 360, 400)
var data: Dictionary = TOWER_DATA[tower_type]
draw_rect(card, Color(0.012, 0.04, 0.07, 0.94), true)
draw_rect(card, Color(data.color, 0.72), false, 2.0)
draw_texture_rect(textures[slot], Rect2(card.position + Vector2(112, 18), Vector2(136, 136)), false)
draw_string(font, card.position + Vector2(20, 190), data.name, HORIZONTAL_ALIGNMENT_CENTER, 320, 23, Color(data.color))
draw_string(font, card.position + Vector2(22, 224), "SCH %.0f • %.2f SCHUSS/S" % [data.damage, _tower_shots_per_second(tower_type, 1)], HORIZONTAL_ALIGNMENT_CENTER, 316, 15, Color.WHITE)
draw_string(font, card.position + Vector2(22, 251), "RW %.0f • %s" % [data.range, data.damage_type], HORIZONTAL_ALIGNMENT_CENTER, 316, 14, Color("9fdce9"))
draw_string(font, card.position + Vector2(22, 295), data.description, HORIZONTAL_ALIGNMENT_CENTER, 316, 14, Color("c4d7df"))
var is_unlocked := unlocked_towers.has(tower_type)
var button_text := "FREIGESCHALTET" if is_unlocked else "FREISCHALTEN • %d KERNKRISTALLE" % int(data.unlock_cost)
_draw_button(shop_purchase_buttons[slot], button_text, is_unlocked, Color("24516a") if not is_unlocked else Color("245843"))
draw_string(font, Vector2(0, 685), "Weitere Verteidigungen lassen sich dauerhaft mit Kernkristallen freischalten.", HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("ffcf7a"))
func _draw_tower_info_panel() -> void:
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 := "%s • STUFE %d" % [data.name, level] if is_built else "%s • BAUVORSCHAU" % data.name
draw_string(font, tower_info_rect.position + Vector2(10, 20), heading, HORIZONTAL_ALIGNMENT_LEFT, 280, 13, Color("a9efff"))
draw_string(font, tower_info_rect.position + Vector2(10, 41), "SCH %.0f • %.2f/S • RW %.0f" % [_tower_damage(type, level), _tower_shots_per_second(type, level), _tower_range(type, level)], HORIZONTAL_ALIGNMENT_LEFT, 280, 10, Color.WHITE)
if is_built and level < 3:
var next_level := level + 1
draw_string(font, tower_info_rect.position + Vector2(10, 62), "UPGRADE +%.0f SCH +%.2f/S +%.0f RW" % [_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, 280, 9, Color("84ffca"))
elif is_built:
draw_string(font, tower_info_rect.position + Vector2(10, 62), "MAXIMALE AUSBAUSTUFE", HORIZONTAL_ALIGNMENT_LEFT, 280, 9, Color("84ffca"))
else:
draw_string(font, tower_info_rect.position + Vector2(10, 62), str(data.damage_type), HORIZONTAL_ALIGNMENT_LEFT, 280, 9, Color("84ffca"))
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 := 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:
var font := ThemeDB.fallback_font
draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.52))
draw_string(font, Vector2(0, 145), "VOID BASTION", HORIZONTAL_ALIGNMENT_CENTER, 1600, 64, Color("c7f5ff"))
draw_string(font, Vector2(0, 202), "ORBITAL DEFENSE PROTOCOL", HORIZONTAL_ALIGNMENT_CENTER, 1600, 23, Color("56cce9"))
_draw_button(profile_button, "%s • PROFIL" % account_manager.display_name, false, Color("233f57"))
if menu_step == "map":
draw_string(font, Vector2(0, 300), "EINSATZKARTE WÄHLEN • GESPERRTE SEKTOREN IM SHOP FREISCHALTEN", HORIZONTAL_ALIGNMENT_CENTER, 1600, 20, Color("8feeff"))
for index in map_buttons.size():
var card: Rect2 = map_buttons[index]
var map_index := index
var is_unlocked := unlocked_maps.has(map_index)
draw_texture_rect(MAP_TEXTURES[map_index], card, false, Color(0.82, 0.88, 0.94, 1.0) if is_unlocked else Color(0.38, 0.46, 0.54, 1.0))
draw_rect(card, Color(0.005, 0.015, 0.035, 0.22 if is_unlocked else 0.48), true)
draw_rect(card, Color("70eaff") if selected_map == map_index and is_unlocked else Color("436574"), false, 4.0 if selected_map == map_index and is_unlocked else 2.0)
var footer_y := card.size.y - 52.0
draw_rect(Rect2(card.position + Vector2(0, footer_y), Vector2(card.size.x, 52)), Color(0.01, 0.025, 0.05, 0.88), true)
draw_string(font, card.position + Vector2(0, footer_y + 21), MAP_NAMES[map_index], HORIZONTAL_ALIGNMENT_CENTER, card.size.x, 14, Color.WHITE)
var description: String = MAP_DESCRIPTIONS[map_index] if is_unlocked else "GESPERRT • %d KERNKRISTALLE" % int(MAP_UNLOCK_COSTS[map_index])
draw_string(font, card.position + Vector2(0, footer_y + 42), description, HORIZONTAL_ALIGNMENT_CENTER, card.size.x, 11, Color("8fddef") if is_unlocked else Color("ffcf7a"))
draw_string(font, Vector2(0, 720), "Wähle einen Sektor, um Schwierigkeit und Missionsstart festzulegen.", HORIZONTAL_ALIGNMENT_CENTER, 1600, 16, Color("d7e2ea"))
else:
_draw_button(map_step_back_button, "< KARTE", false, Color("26394a"))
var selected_card := Rect2(500, 315, 600, 145)
draw_texture_rect(MAP_TEXTURES[selected_map], selected_card, false, Color(0.86, 0.90, 0.95, 1.0))
draw_rect(selected_card, Color(0.005, 0.015, 0.035, 0.34), true)
draw_rect(selected_card, Color("70eaff"), false, 3.0)
draw_rect(Rect2(selected_card.position + Vector2(0, 92), Vector2(selected_card.size.x, 53)), Color(0.01, 0.025, 0.05, 0.88), true)
draw_string(font, selected_card.position + Vector2(0, 116), MAP_NAMES[selected_map], HORIZONTAL_ALIGNMENT_CENTER, selected_card.size.x, 18, Color.WHITE)
draw_string(font, selected_card.position + Vector2(0, 138), MAP_DESCRIPTIONS[selected_map], HORIZONTAL_ALIGNMENT_CENTER, selected_card.size.x, 13, Color("8fddef"))
draw_string(font, Vector2(0, 490), "SCHWIERIGKEIT WÄHLEN", HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("9fdce9"))
for index in difficulty_buttons.size():
_draw_button(difficulty_buttons[index], DIFFICULTY_DATA[index].name, selected_difficulty == index, Color("27394d"))
draw_string(font, Vector2(0, 603), DIFFICULTY_DESCRIPTIONS[selected_difficulty], HORIZONTAL_ALIGNMENT_CENTER, 1600, 14, Color("ffcf7a"))
_draw_button(start_button, "MISSION STARTEN", false, Color("18556f"))
draw_string(font, Vector2(0, 755), "KARTENBONUS %d • BESTE WELLE %02d • KERNKRISTALLE %04d" % [int(_difficulty().crystals), best_wave, core_crystals], HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, Color("86c8d8"))
_draw_button(leaderboard_button, "BESTENLISTE", false, Color("24475b"))
_draw_button(multiplayer_button, "MULTIPLAYER", false, Color("254f75"))
_draw_button(shop_button, "ARSENAL / SHOP", false, Color("24516a"))
_draw_button(audio_settings_button, "EINSTELLUNGEN", false, Color("24516a"))
_draw_button(exit_button, "SPIEL BEENDEN", false, Color("5c3342"))
draw_string(font, Vector2(0, 860), "VERSION %s • GERGAMES-ONLINEKONTO • GLOBALE BESTENLISTEN" % _app_version(), HORIZONTAL_ALIGNMENT_CENTER, 1600, 14, Color("688293"))
if update_notice_visible:
_draw_button(update_button, "UPDATE %s VERFÜGBAR" % str(available_update.get("version", "")), true, Color("1f5d4d"))
_draw_button(update_dismiss_button, "×", false, Color("3d4650"))
func _draw_audio_settings() -> void:
var font := ThemeDB.fallback_font
draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.002, 0.008, 0.02, 0.62), true)
draw_rect(audio_panel_rect, Color(0.01, 0.035, 0.065, 0.98), true)
draw_rect(audio_panel_rect, Color("4ab8d1"), false, 2.0)
draw_string(font, audio_panel_rect.position + Vector2(30, 54), "AUDIOEINSTELLUNGEN", HORIZONTAL_ALIGNMENT_LEFT, 450, 25, Color("bcefff"))
_draw_button(audio_close_button, "SCHLIESSEN", false, Color("26394a"))
draw_string(font, Vector2(audio_slider_rect.position.x, audio_slider_rect.position.y - 18), "MASTER-LAUTSTÄRKE", HORIZONTAL_ALIGNMENT_LEFT, 260, 16, Color("dceaf0"))
draw_string(font, Vector2(audio_slider_rect.end.x - 80, audio_slider_rect.position.y - 18), "%d%%" % roundi(master_volume * 100.0), HORIZONTAL_ALIGNMENT_RIGHT, 80, 16, Color("8ff4ff"))
var track := Rect2(audio_slider_rect.position.x, audio_slider_rect.position.y + 13, audio_slider_rect.size.x, 6)
draw_rect(track, Color("24475b"), true)
draw_rect(Rect2(track.position, Vector2(track.size.x * master_volume, track.size.y)), Color("45d4ee"), true)
var knob_position := Vector2(track.position.x + track.size.x * master_volume, track.position.y + track.size.y * 0.5)
draw_circle(knob_position, 13, Color("0b1d2c"))
draw_circle(knob_position, 9, Color("8ff4ff"))
draw_string(font, Vector2(audio_panel_rect.position.x + 30, audio_panel_rect.end.y - 28), "Die Einstellung wird automatisch gespeichert.", HORIZONTAL_ALIGNMENT_LEFT, audio_panel_rect.size.x - 60, 13, Color("91a8bc"))
func _draw_leaderboard() -> void:
var font := ThemeDB.fallback_font
draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.84))
_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), "GLOBALE BESTENLISTE", HORIZONTAL_ALIGNMENT_CENTER, 1600, 42, Color("c7f5ff"))
_draw_button(leaderboard_map_previous_button, "<", false, Color("26394a"))
_draw_button(leaderboard_map_button, "KARTE • %s" % 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, "STUFE • %s" % 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), "RANG", HORIZONTAL_ALIGNMENT_LEFT, 75, 13, Color("91a8bc"))
draw_string(font, Vector2(325, 319), "PILOT", HORIZONTAL_ALIGNMENT_LEFT, 270, 13, Color("91a8bc"))
draw_string(font, Vector2(610, 319), "KARTE", HORIZONTAL_ALIGNMENT_LEFT, 240, 13, Color("91a8bc"))
draw_string(font, Vector2(850, 319), "STUFE", HORIZONTAL_ALIGNMENT_LEFT, 130, 13, Color("91a8bc"))
draw_string(font, Vector2(995, 319), "WELLE", HORIZONTAL_ALIGNMENT_CENTER, 110, 13, Color("91a8bc"))
draw_string(font, Vector2(1160, 319), "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), MAP_NAMES[int(row.get("map", leaderboard_map))], HORIZONTAL_ALIGNMENT_LEFT, 225, 13, Color("9fdce9"))
draw_string(font, Vector2(850, y), 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), "Spielernamen werden aus dem aktuellen öffentlichen Profil geladen.", HORIZONTAL_ALIGNMENT_CENTER, 1600, 14, Color("91a8bc"))
draw_string(font, Vector2(0, 838), "WERTUNG: WELLEN + REAKTORREST + RESTENERGIE • WENIGER GEBAUTE TÜRME ERHÖHEN DEN EFFIZIENZ-MULTIPLIKATOR", HORIZONTAL_ALIGNMENT_CENTER, 1600, 12, Color("75aaba"))
func _draw_multiplayer_menu() -> void:
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), "KOOPERATIVE VERTEIDIGUNG", HORIZONTAL_ALIGNMENT_CENTER, 1600, 42, Color("c7f5ff"))
draw_string(font, Vector2(0, 142), "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), "GEMEINSAMER REAKTOR", HORIZONTAL_ALIGNMENT_CENTER, 270, 15, Color("8ff4ff"))
draw_string(font, Vector2(215, 225), "%s • ENERGIE %d" % [account_manager.display_name, multiplayer_manager.player_credits[0]], HORIZONTAL_ALIGNMENT_LEFT, 440, 17, Color("8ff4ff"))
draw_string(font, Vector2(945, 225), "MITSPIELER • ENERGIE %d" % multiplayer_manager.player_credits[1], HORIZONTAL_ALIGNMENT_RIGHT, 440, 17, Color("ffcf7a"))
draw_string(font, Vector2(570, 555), "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"))
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, 770), service_text, HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, service_color)
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), "PRIVATEN RAUM BEITRETEN", HORIZONTAL_ALIGNMENT_CENTER, 1600, 42, Color("c7f5ff"))
draw_string(font, Vector2(0, 260), "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), "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), "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), 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"))
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), 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 := "LINKE VERTEIDIGUNG" if slot == 0 else "RECHTE VERTEIDIGUNG"
var state := "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), "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), "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), "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), cloud_label, HORIZONTAL_ALIGNMENT_LEFT, 470, 15, cloud_color)
draw_string(font, Vector2(840, 282), "GAME CENTER / APPLE NÄCHSTE STUFE", HORIZONTAL_ALIGNMENT_LEFT, 470, 15, Color("d7e2ea"))
draw_string(font, Vector2(840, 312), "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), "LOKALE BESTWERTE", HORIZONTAL_ALIGNMENT_LEFT, 1160, 20, Color("8feeff"))
draw_string(font, Vector2(220, 430), "EINSATZSEKTOR", HORIZONTAL_ALIGNMENT_LEFT, 420, 14, Color("91a8bc"))
draw_string(font, Vector2(700, 430), "MISSION", HORIZONTAL_ALIGNMENT_CENTER, 180, 14, Color("91a8bc"))
draw_string(font, Vector2(930, 430), "ENDLOS", HORIZONTAL_ALIGNMENT_CENTER, 180, 14, Color("91a8bc"))
draw_string(font, Vector2(1160, 430), "BESTE PUNKTE", HORIZONTAL_ALIGNMENT_CENTER, 180, 14, Color("91a8bc"))
for index in MAP_NAMES.size():
var y := 466.0 + index * 28.0
draw_rect(Rect2(210, y - 19, 1180, 25), Color(0.03, 0.09, 0.14, 0.70), true)
draw_string(font, Vector2(235, y), MAP_NAMES[index], HORIZONTAL_ALIGNMENT_LEFT, 420, 12, Color.WHITE)
draw_string(font, Vector2(700, y), "WELLE %02d" % account_manager.get_map_best_wave(index, false), HORIZONTAL_ALIGNMENT_CENTER, 180, 12, Color("8ff4ff"))
draw_string(font, Vector2(930, y), "WELLE %02d" % account_manager.get_map_best_wave(index, true), HORIZONTAL_ALIGNMENT_CENTER, 180, 12, Color("ffcf7a"))
var score := maxi(account_manager.get_map_best_score(index, false), account_manager.get_map_best_score(index, true))
draw_string(font, Vector2(1160, y), "%07d" % score, HORIZONTAL_ALIGNMENT_CENTER, 180, 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), "SPIELERNAME", HORIZONTAL_ALIGNMENT_CENTER, 1600, 44, Color("c7f5ff"))
draw_string(font, Vector2(0, 250), "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), "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_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), "REAKTOR VERLOREN", HORIZONTAL_ALIGNMENT_CENTER, 1600, 48, Color("ff8b78"))
draw_string(font, Vector2(0, 395), "Erreichte Welle: %d" % wave, HORIZONTAL_ALIGNMENT_CENTER, 1600, 22, Color.WHITE)
draw_string(font, Vector2(0, 430), "Highscore: %d • Effizienzbonus: ×%.2f" % [_calculate_run_score(), clampf(1.0 + (10.0 - towers_built_this_run) * 0.05, 0.50, 1.50)], HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, Color("d6eaf0"))
_draw_button(start_button, "NEUER VERSUCH", false, Color("6a2835"))
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), "SEKTOR GESICHERT", HORIZONTAL_ALIGNMENT_CENTER, 1600, 50, Color("8fffea"))
draw_string(font, Vector2(0, 375), "Alle %d Angriffswellen wurden abgewehrt." % MAX_WAVES, HORIZONTAL_ALIGNMENT_CENTER, 1600, 22, Color.WHITE)
draw_string(font, Vector2(0, 415), "Der Orbitalkern bleibt unter unserer Kontrolle.", HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("8adff2"))
draw_string(font, Vector2(0, 458), "+%d KERNKRISTALLE • GUTHABEN %d" % [last_mission_reward, core_crystals], HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("8fffea"))
draw_string(font, Vector2(0, 492), "Highscore: %d • Effizienzbonus: ×%.2f" % [_calculate_run_score(), clampf(1.0 + (10.0 - towers_built_this_run) * 0.05, 0.50, 1.50)], HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, Color("d6eaf0"))
_draw_button(start_button, "ZUM HAUPTMENÜ", false, Color("185f68"))
func _draw_endless_offer() -> void:
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), "SEKTOR GESICHERT", HORIZONTAL_ALIGNMENT_CENTER, 1600, 50, Color("8fffea"))
draw_string(font, Vector2(0, 345), "Alle %d regulären Wellen wurden abgewehrt." % MAX_WAVES, HORIZONTAL_ALIGNMENT_CENTER, 1600, 22, Color.WHITE)
draw_string(font, Vector2(0, 405), "Möchtest du den Einsatz im immer schwereren Endlosmodus fortsetzen?", HORIZONTAL_ALIGNMENT_CENTER, 1600, 19, Color("8adff2"))
if victory_reward_pending:
draw_string(font, Vector2(0, 450), "WERBUNG WIRD GELADEN • KERNKRISTALLE DANACH: %d" % int(_difficulty().crystals), HORIZONTAL_ALIGNMENT_CENTER, 1600, 16, Color("ffcf7a"))
else:
draw_string(font, Vector2(0, 450), "+%d Kernkristalle gutgeschrieben. Ein späteres Ende gilt nicht als gescheitert." % last_mission_reward, HORIZONTAL_ALIGNMENT_CENTER, 1600, 16, Color("8fffea"))
_draw_button(endless_button, "ENDLOSMODUS STARTEN", false, Color("215b68"))
_draw_button(finish_mission_button, "MISSION BEENDEN", false, Color("24394c"))
func _draw_endless_result() -> void:
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), "ENDLOSAUFTRAG BEENDET", HORIZONTAL_ALIGNMENT_CENTER, 1600, 48, Color("8feeff"))
draw_string(font, Vector2(0, 382), "Erreichte Welle: %d" % wave, HORIZONTAL_ALIGNMENT_CENTER, 1600, 23, Color.WHITE)
draw_string(font, Vector2(0, 425), "Der reguläre Sektorauftrag bleibt erfolgreich abgeschlossen.", HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("8adff2"))
draw_string(font, Vector2(0, 462), "KERNKRISTALLE %d" % core_crystals, HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("8fffea"))
draw_string(font, Vector2(0, 492), "Highscore: %d • Effizienzbonus: ×%.2f" % [_calculate_run_score(), clampf(1.0 + (10.0 - towers_built_this_run) * 0.05, 0.50, 1.50)], HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, Color("d6eaf0"))
_draw_button(start_button, "NEUER EINSATZ", false, Color("18556f"))
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), title, HORIZONTAL_ALIGNMENT_LEFT, 450, 19, Color("8ff4ff"))
draw_string(font, Vector2(50, 184), 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) -> bool:
var correct_coop_side := true
if coop_mode:
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