From a536b698f7a1bc722f7472609a5a7dc02429dba0 Mon Sep 17 00:00:00 2001 From: Dominic Date: Sun, 11 Oct 2026 20:21:59 +0200 Subject: [PATCH] Release alpha 14 menu and developer test tools --- main.gd | 426 +++++++++++++++++++++--- packaging/windows/installer.nsi | 4 +- project.godot | 4 +- server/nakama/runtime/build/index.js | 49 ++- server/nakama/tests/runtime_sim_test.js | 57 +++- 5 files changed, 464 insertions(+), 76 deletions(-) diff --git a/main.gd b/main.gd index a402531..da0a394 100644 --- a/main.gd +++ b/main.gd @@ -3,7 +3,9 @@ extends Node2D const SCREEN := Vector2(1600.0, 900.0) const ENGLISH_UI := { "PROFIL": "PROFILE", "EINSATZKARTE WÄHLEN • GESPERRTE SEKTOREN IM SHOP FREISCHALTEN": "SELECT A MAP • UNLOCK SECTORS IN THE SHOP", - "GESPERRT": "LOCKED", "Wähle einen Sektor, um Schwierigkeit und Missionsstart festzulegen.": "Select a sector to choose difficulty and start the mission.", + "EINSATZSEKTOREN": "DEPLOYMENT SECTORS", "KARTE WÄHLEN • GESPERRTE SEKTOREN IM SHOP FREISCHALTEN": "CHOOSE A MAP • UNLOCK LOCKED SECTORS IN THE SHOP", + "AUSGEWÄHLT": "SELECTED", "KOMMUNITY": "COMMUNITY", "HANGAR & KONTO": "HANGAR & ACCOUNT", "SYSTEM": "SYSTEM", + "GESPERRT": "LOCKED", "Wähle einen Sektor. Danach legst du Schwierigkeit und Missionsstart fest.": "Choose a sector, then set the difficulty and start your mission.", "< KARTE": "< MAP", "SCHWIERIGKEIT WÄHLEN": "SELECT DIFFICULTY", "LEICHT": "EASY", "MITTEL": "MEDIUM", "SCHWER": "HARD", "MISSION STARTEN": "START MISSION", "BESTENLISTE": "LEADERBOARD", "ARSENAL / SHOP": "ARSENAL / SHOP", "EINSTELLUNGEN": "SETTINGS", "CODE EINLÖSEN": "REDEEM CODE", "SPIEL BEENDEN": "QUIT GAME", "MULTIPLAYER": "MULTIPLAYER", "SCHLIESSEN": "CLOSE", @@ -33,6 +35,7 @@ const ENGLISH_UI := { "Biodom über dem Nebel": "Biodome above the nebula", "Eisige Raffinerie": "Frozen refinery", "Schmiede am Gasriesen": "Forge by the gas giant", "Verlassene Megastruktur": "Abandoned megastructure", "VERKAUFEN": "SELL", "MAX. STUFE": "MAX LEVEL", "FREIGESCHALTET": "UNLOCKED", "GESPERRT • %d KERNKRISTALLE": "LOCKED • %d CORE CRYSTALS", "KARTENBONUS %d • BESTE WELLE %02d • KERNKRISTALLE %04d": "MAP BONUS %d • BEST WAVE %02d • CORE CRYSTALS %04d", + "KARTE %02d VON %02d": "MAP %02d OF %02d", "KARTE IM SHOP FREISCHALTEN": "UNLOCK MAP IN SHOP", "Bisheriges Balancing • 20 Reaktor • Kartenbonus 5": "Classic balance • 20 reactor health • map bonus 5", "+32 % Gegnerhülle • +24 % Gegner • Kartenbonus 7": "+32% enemy armor • +24% enemies • map bonus 7", "+72 % Grundhülle • stärkere Spätwellen • Start 340 • Kartenbonus 12": "+72% base armor • stronger late waves • start 340 • map bonus 12", @@ -195,17 +198,18 @@ const DIFFICULTY_DESCRIPTIONS := [ "+72 % Grundhülle • stärkere Spätwellen • Start 340 • Kartenbonus 12" ] -const TOWER_DATA := [ +var TOWER_DATA := [ {"name": "KINETIK", "cost": 120, "damage": 22.0, "range": 190.0, "rate": 0.55, "shot_speed": 620.0, "damage_type": "EINZELZIEL", "target_class": "ground", "color": Color("7de7ff"), "unlock_cost": 0, "description": "Zuverlässig gegen Bodenziele."}, {"name": "LASER", "cost": 180, "damage": 12.0, "range": 165.0, "rate": 0.18, "shot_speed": 1100.0, "damage_type": "EINZELZIEL", "target_class": "both", "both_damage_scale": 0.82, "color": Color("62fff1"), "unlock_cost": 0, "description": "Mehrzwecklaser; durchdringt Schilde von Luft- und Bodenzielen."}, {"name": "RAKETE", "cost": 250, "damage": 65.0, "range": 260.0, "rate": 1.40, "shot_speed": 360.0, "damage_type": "FLÄCHE 76", "target_class": "ground", "color": Color("ffb35a"), "unlock_cost": 0, "description": "Bodenraketen treffen Gegnergruppen."}, {"name": "PLASMA", "cost": 230, "damage": 34.0, "range": 240.0, "rate": 1.05, "shot_speed": 480.0, "damage_type": "PLASMA-FLÄCHE 92", "target_class": "both", "both_damage_scale": 0.72, "color": Color("e27bff"), "unlock_cost": 18, "description": "Mehrzweckwaffe mit reduziertem Schaden."}, {"name": "EMP-PULS", "cost": 210, "damage": 20.0, "range": 215.0, "rate": 0.92, "shot_speed": 540.0, "damage_type": "EMP-FLÄCHE 118 · ALLE", "target_class": "both", "both_damage_scale": 1.0, "color": Color("77e8ff"), "unlock_cost": 24, "description": "Impuls trifft alle Luft- und Bodenziele im Radius mit 20 Schaden."}, {"name": "RAILGUN", "cost": 300, "damage": 77.0, "range": 330.0, "rate": 1.85, "shot_speed": 1450.0, "damage_type": "PIERCING-STRAHL", "target_class": "both", "both_damage_scale": 0.70, "color": Color("c86bff"), "unlock_cost": 30, "description": "Lila Durchschlagsstrahl trifft alle Gegner auf seiner Schusslinie."}, - {"name": "FLAK", "cost": 210, "damage": 22.0, "range": 230.0, "rate": 0.42, "shot_speed": 920.0, "damage_type": "LUFT-EINZELZIEL", "target_class": "air", "color": Color("ff737c"), "unlock_cost": 0, "description": "Schnelle Abfangkanone gegen gegnerische Luftschiffe."} + {"name": "FLAK", "cost": 210, "damage": 22.0, "range": 230.0, "rate": 0.42, "shot_speed": 920.0, "damage_type": "LUFT-EINZELZIEL", "target_class": "air", "color": Color("ff737c"), "unlock_cost": 0, "description": "Schnelle Abfangkanone gegen gegnerische Luftschiffe."}, + {"name": "ADMIN-TURM", "cost": 0, "damage": 1000000.0, "range": 3000.0, "rate": 0.08, "shot_speed": 3000.0, "damage_type": "TESTMODUS", "target_class": "both", "color": Color("fff2a1"), "unlock_cost": 0, "description": "Entwicklertest: besiegt jedes Ziel mit einem Schuss."} ] -const ENEMY_DATA := [ +var ENEMY_DATA := [ {"name": "JAEGER", "category": "air", "hp": 70.0, "speed": 105.0, "reward": 18, "size": 58.0, "breach_damage": 1, "shield_resistance": 0.28}, {"name": "FREGATTE", "category": "air", "hp": 185.0, "speed": 72.0, "reward": 34, "size": 78.0, "breach_damage": 2, "shield_resistance": 0.28}, {"name": "KREUZER", "category": "air", "hp": 520.0, "speed": 43.0, "reward": 78, "size": 112.0, "breach_damage": 3, "shield_resistance": 0.28}, @@ -267,6 +271,17 @@ var wave_spawn_index := 0 var online_wave_types_remaining: Array[int] = [] var enemy_strength_multiplier := 1.0 var developer_panel_open := false +const DEVELOPER_PASSWORD := "Spring.3" +const ADMIN_TOWER_TYPE := 7 +const DEVELOPER_AUTH_OP := 15 +const DEVELOPER_ENERGY_OP := 16 +var developer_unlocked_this_round := false +var developer_server_authorized := false +var developer_password_prompt_open := false +var developer_password_line_edit: LineEdit +var developer_password_dialog_rect := Rect2(510, 330, 580, 235) +var developer_password_input_rect := Rect2(570, 410, 460, 54) +var developer_password_cancel_button := Rect2(670, 488, 260, 48) var status_text := "" var status_timer := 0.0 var elapsed := 0.0 @@ -283,8 +298,8 @@ 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) + Rect2(55, 312, 350, 150), Rect2(435, 312, 350, 150), Rect2(815, 312, 350, 150), Rect2(1195, 312, 350, 150), + Rect2(245, 492, 350, 150), Rect2(625, 492, 350, 150), Rect2(1005, 492, 350, 150) ] var difficulty_buttons := [Rect2(535, 520, 165, 50), Rect2(718, 520, 165, 50), Rect2(901, 520, 165, 50)] var wave_button := Rect2(1216, 10, 359, 46) @@ -293,11 +308,14 @@ var speed_button := Rect2(1070, 14, 62, 54) var auto_wave_button := Rect2(1216, 58, 359, 19) var wave_current_preview_rect := Rect2(220, 8, 258, 66) var wave_next_preview_rect := Rect2(486, 8, 258, 66) -var developer_panel_rect := Rect2(1060, 104, 515, 78) -var developer_strength_down_button := Rect2(1074, 140, 42, 30) -var developer_strength_up_button := Rect2(1260, 140, 42, 30) -var developer_crystals_down_button := Rect2(1320, 140, 50, 30) -var developer_crystals_up_button := Rect2(1512, 140, 50, 30) +var developer_panel_rect := Rect2(920, 104, 660, 112) +var developer_strength_down_button := Rect2(934, 140, 42, 28) +var developer_strength_up_button := Rect2(1110, 140, 42, 28) +var developer_crystals_down_button := Rect2(1170, 140, 50, 28) +var developer_crystals_up_button := Rect2(1412, 140, 50, 28) +var developer_energy_down_button := Rect2(934, 176, 78, 28) +var developer_energy_up_button := Rect2(1018, 176, 78, 28) +var developer_admin_tower_button := Rect2(1370, 174, 195, 32) var tower_buttons := [ Rect2(13, 778, 218, 112), Rect2(238, 778, 218, 112), Rect2(463, 778, 218, 112), Rect2(688, 778, 218, 112), Rect2(913, 778, 218, 112), Rect2(1138, 778, 218, 112), @@ -314,8 +332,8 @@ var target_priority_button := Rect2(666, 698, 300, 44) var endless_button := Rect2(545, 515, 510, 70) var finish_mission_button := Rect2(625, 610, 350, 62) var profile_button := Rect2(1280, 25, 270, 52) -var leaderboard_button := Rect2(40, 790, 200, 54) -var shop_button := Rect2(568, 790, 200, 54) +var leaderboard_button := Rect2(40, 772, 245, 62) +var shop_button := Rect2(550, 772, 245, 62) var map_step_back_button := Rect2(55, 320, 170, 50) var shop_back_button := Rect2(55, 35, 185, 54) var shop_purchase_buttons := [Rect2(190, 545, 360, 62), Rect2(620, 545, 360, 62), Rect2(1050, 545, 360, 62)] @@ -339,7 +357,7 @@ var leaderboard_difficulty := 1 var leaderboard_loading := false var leaderboard_message := "" var profile_back_button := Rect2(55, 35, 185, 54) -var multiplayer_button := Rect2(304, 790, 200, 54) +var multiplayer_button := Rect2(295, 772, 245, 62) var multiplayer_back_button := Rect2(55, 35, 185, 54) var quick_match_button := Rect2(125, 665, 300, 64) var friend_match_button := Rect2(475, 665, 300, 64) @@ -380,9 +398,9 @@ var available_update: Dictionary = {} var update_notice_visible := false var audio_settings_open := false var master_volume := 0.65 -var audio_settings_button := Rect2(832, 790, 200, 54) -var promo_code_button := Rect2(1096, 790, 200, 54) -var exit_button := Rect2(1360, 790, 200, 54) +var audio_settings_button := Rect2(805, 772, 245, 62) +var promo_code_button := Rect2(1060, 772, 245, 62) +var exit_button := Rect2(1315, 772, 245, 62) var promo_code_input_rect := Rect2(500, 335, 600, 64) var promo_code_redeem_button := Rect2(650, 460, 300, 58) var promo_code_cancel_button := Rect2(55, 35, 185, 54) @@ -430,9 +448,32 @@ func _ready() -> void: online_manager.match_invite_received.connect(_on_match_invite_received) _create_promo_code_input() _create_friend_name_input() + _create_developer_password_input() queue_redraw() +func _create_developer_password_input() -> void: + developer_password_line_edit = LineEdit.new() + developer_password_line_edit.position = developer_password_input_rect.position + Vector2(12, 4) + developer_password_line_edit.size = developer_password_input_rect.size - Vector2(24, 8) + developer_password_line_edit.max_length = 40 + developer_password_line_edit.secret = true + developer_password_line_edit.placeholder_text = "PASSWORT" + developer_password_line_edit.alignment = HORIZONTAL_ALIGNMENT_CENTER + developer_password_line_edit.add_theme_color_override("font_color", Color("e8fbff")) + developer_password_line_edit.add_theme_color_override("font_placeholder_color", Color("7699a8")) + var input_style := StyleBoxFlat.new() + input_style.bg_color = Color("081a27") + input_style.border_color = Color("ffad62") + input_style.set_border_width_all(1) + input_style.set_content_margin_all(10) + developer_password_line_edit.add_theme_stylebox_override("normal", input_style) + developer_password_line_edit.add_theme_stylebox_override("focus", input_style) + developer_password_line_edit.text_submitted.connect(func(_value: String) -> void: _submit_developer_password()) + add_child(developer_password_line_edit) + developer_password_line_edit.hide() + + func _create_friend_name_input() -> void: friend_name_line_edit = LineEdit.new() friend_name_line_edit.position = friend_name_rect.position + Vector2(10, 3) @@ -571,7 +612,7 @@ func _save_balance_cache(config: Dictionary) -> void: func _apply_balance_config(config: Dictionary) -> bool: var towers_config = config.get("towers", []) var enemies_config = config.get("enemies", []) - if not towers_config is Array or towers_config.size() != TOWER_DATA.size() or not enemies_config is Array or enemies_config.size() != ENEMY_DATA.size(): + if not towers_config is Array or towers_config.size() != TOWER_DATA.size() - 1 or not enemies_config is Array or enemies_config.size() != ENEMY_DATA.size(): return false for values in towers_config: if not values is Dictionary or str(values.get("targetClass", "both")) not in ["air", "ground", "both"]: @@ -579,7 +620,7 @@ func _apply_balance_config(config: Dictionary) -> bool: for values in enemies_config: if not values is Dictionary: return false - for index in TOWER_DATA.size(): + for index in TOWER_DATA.size() - 1: var values: Dictionary = towers_config[index] var tower: Dictionary = TOWER_DATA[index] tower["cost"] = clampi(int(values.get("cost", tower.cost)), 10, 50000) @@ -603,7 +644,7 @@ func _apply_balance_config(config: Dictionary) -> bool: func _apply_promo_unlocks(entitlements: Dictionary) -> void: if bool(entitlements.get("all_towers", false)): unlocked_towers = [] - for tower_type in TOWER_DATA.size(): + for tower_type in TOWER_DATA.size() - 1: unlocked_towers.append(tower_type) if bool(entitlements.get("all_maps", false)): unlocked_maps = [] @@ -806,7 +847,7 @@ func _tower_target_class(tower_type: int) -> String: func _tower_display_order() -> Array[int]: var ordered_types: Array[int] = [] for target_class in ["air", "ground", "both"]: - for tower_type in TOWER_DATA.size(): + for tower_type in TOWER_DATA.size() - 1: if _tower_target_class(tower_type) == target_class: ordered_types.append(tower_type) return ordered_types @@ -945,7 +986,10 @@ func _fire_tower(tower: Dictionary, target: Dictionary) -> void: var damage := _tower_damage(tower.type, tower.level) if _tower_target_class(tower.type) == "both": damage *= float(data.get("both_damage_scale", 0.7)) - if tower.type == 1: + if tower.type == ADMIN_TOWER_TYPE: + _apply_damage(target, damage, int(tower.get("owner", 0)), tower.type) + beams.append({"from": tower.pos, "to": target.pos, "ttl": 0.16, "color": data.color, "piercing": false}) + elif tower.type == 1: _apply_damage(target, damage, int(tower.get("owner", 0)), tower.type) beams.append({"from": tower.pos, "to": target.pos, "ttl": 0.09, "color": data.color}) elif tower.type == 5: @@ -1055,7 +1099,21 @@ func _update_effects(delta: float) -> void: func _unhandled_input(event: InputEvent) -> void: if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_F9 and game_mode in ["playing", "paused"]: - developer_panel_open = not developer_panel_open + if developer_password_prompt_open: + developer_password_prompt_open = false + developer_password_line_edit.hide() + elif developer_unlocked_this_round: + developer_panel_open = not developer_panel_open + else: + developer_password_prompt_open = true + developer_password_line_edit.clear() + developer_password_line_edit.show() + developer_password_line_edit.grab_focus() + queue_redraw() + return + if developer_password_prompt_open and event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: + developer_password_prompt_open = false + developer_password_line_edit.hide() queue_redraw() return if event is InputEventKey and event.pressed and not event.echo and event.keycode in [KEY_ESCAPE, KEY_BACK, KEY_Q]: @@ -1131,6 +1189,12 @@ func _unhandled_input(event: InputEvent) -> void: func _handle_pointer(position: Vector2, is_touch := false) -> void: _play_ui_sound() + if developer_password_prompt_open: + if developer_password_cancel_button.has_point(position): + developer_password_prompt_open = false + developer_password_line_edit.hide() + queue_redraw() + return if developer_panel_open and game_mode in ["playing", "paused"]: if developer_strength_down_button.has_point(position): _set_enemy_strength_multiplier(enemy_strength_multiplier - 0.1) @@ -1144,6 +1208,21 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void: if developer_crystals_up_button.has_point(position): _adjust_core_crystals(25) return + if developer_energy_down_button.has_point(position): + _adjust_developer_energy(-500) + return + if developer_energy_up_button.has_point(position): + _adjust_developer_energy(500) + return + if developer_admin_tower_button.has_point(position): + if coop_online_mode and not developer_server_authorized: + _show_status("ONLINE-TESTMODUS WIRD NOCH AUTORISIERT") + return + selected_tower_type = ADMIN_TOWER_TYPE + selected_tower_index = -1 + _show_status("ADMIN-TURM GEWÄHLT • BAUPLATZ AUF DER KARTE ANKLICKEN") + queue_redraw() + return if developer_panel_rect.has_point(position): return if audio_settings_open: @@ -1160,6 +1239,9 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void: queue_redraw() return if game_mode == "menu": + if _is_mobile_menu_layout(): + _handle_mobile_menu_pointer(position) + return if not pending_match_invite.is_empty() and Rect2(470, 226, 660, 54).has_point(position): room_code_input = String(pending_match_invite.get("room_code", "")) pending_match_invite.clear() @@ -1549,6 +1631,7 @@ func _toggle_coop_side() -> void: func _start_coop_ai_game() -> void: online_manager.set_presence_status("playing") + _reset_developer_access() coop_mode = true coop_ai_mode = true coop_online_mode = false @@ -1585,6 +1668,7 @@ func _start_coop_ai_game() -> void: func _start_coop_online_game(player_slot: int) -> void: online_manager.set_presence_status("playing") + _reset_developer_access() local_player_slot = clampi(player_slot, 0, 1) coop_mode = true coop_ai_mode = false @@ -1751,6 +1835,7 @@ func _refresh_leaderboard() -> void: func _start_game() -> void: online_manager.set_presence_status("playing") + _reset_developer_access() coop_online_mode = false credits = int(_difficulty().start_credits) reactor_lives = int(_difficulty().lives) @@ -1781,6 +1866,7 @@ func _start_game() -> void: func _return_to_menu() -> void: online_manager.set_presence_status("menu") + _reset_developer_access() if coop_online_mode: online_manager.leave_current_match() game_mode = "menu" @@ -1805,6 +1891,58 @@ func _return_to_menu() -> void: queue_redraw() +func _reset_developer_access() -> void: + developer_panel_open = false + developer_unlocked_this_round = false + developer_server_authorized = false + developer_password_prompt_open = false + if developer_password_line_edit: + developer_password_line_edit.clear() + developer_password_line_edit.hide() + + +func _submit_developer_password() -> void: + if developer_password_line_edit == null: + return + if developer_password_line_edit.text == DEVELOPER_PASSWORD: + developer_unlocked_this_round = true + developer_panel_open = true + developer_password_prompt_open = false + developer_password_line_edit.hide() + if coop_online_mode: + developer_server_authorized = false + online_manager.send_match_command(DEVELOPER_AUTH_OP, {"password": developer_password_line_edit.text}) + _show_status("TESTMODUS FREI • ONLINE-SERVER WIRD AUTORISIERT") + else: + developer_server_authorized = true + _show_status("TESTMODUS FÜR DIESE RUNDE FREIGESCHALTET") + else: + developer_password_line_edit.clear() + _show_status("PASSWORT FALSCH") + developer_password_line_edit.grab_focus() + queue_redraw() + + +func _adjust_developer_energy(amount: int) -> void: + if amount not in [-500, 500]: + return + if coop_online_mode: + if not developer_server_authorized: + _show_status("ONLINE-TESTMODUS WIRD NOCH AUTORISIERT") + return + online_manager.send_match_command(DEVELOPER_ENERGY_OP, {"amount": amount}) + _show_status("ENERGIEÄNDERUNG AN SERVER GESENDET") + return + if coop_mode: + var current_energy := int(multiplayer_manager.player_credits[local_player_slot]) + multiplayer_manager.player_credits[local_player_slot] = maxi(0, current_energy + amount) + credits = int(multiplayer_manager.player_credits[0]) + else: + credits = maxi(0, credits + amount) + _show_status("TESTENERGIE %s500" % ("+" if amount > 0 else "−")) + queue_redraw() + + func _start_wave() -> void: if wave_active: _show_status("WELLE LÄUFT BEREITS") @@ -1973,15 +2111,19 @@ func _complete_tutorial() -> void: func _place_tower(position: Vector2) -> void: position = _snap_to_grid(position) - if selected_tower_type < 0 or not unlocked_towers.has(selected_tower_type): + var is_admin_tower := selected_tower_type == ADMIN_TOWER_TYPE + if is_admin_tower and (not developer_unlocked_this_round or (coop_online_mode and not developer_server_authorized)): + _show_status("ADMIN-TURM IST NOCH NICHT FREIGESCHALTET") + return + if selected_tower_type < 0 or (not is_admin_tower and not unlocked_towers.has(selected_tower_type)): _show_status("TURM ZUERST IM ARSENAL FREISCHALTEN") return var data: Dictionary = TOWER_DATA[selected_tower_type] var credit_slot := _local_credit_slot() - if _player_credits(credit_slot) < data.cost: + if not is_admin_tower and _player_credits(credit_slot) < data.cost: _show_status("NICHT GENUG ENERGIE") return - if not _is_valid_build_position(position, credit_slot): + if not _is_valid_build_position(position, credit_slot, is_admin_tower): _show_status("BAUPLATZ BLOCKIERT") return if coop_online_mode: @@ -1990,15 +2132,16 @@ func _place_tower(position: Vector2) -> void: 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 + if not is_admin_tower: + _spend_credits(0, int(data.cost)) + towers_built_this_run += 1 towers.append({"pos": position, "type": selected_tower_type, "level": 1, "cooldown": 0.15, "angle": 0.0, "owner": 0, "target_priority": "shield" if selected_tower_type == 1 else "first"}) selected_tower_index = towers.size() - 1 selected_tower_type = -1 pending_touch_build_position = Vector2(-1, -1) if not tutorial_completed and tutorial_step == 1: tutorial_step = 2 - _spawn_burst(position, Color("65eaff"), 12) + _spawn_burst(position, Color("fff2a1") if is_admin_tower else Color("65eaff"), 12) func _upgrade_selected() -> void: @@ -2215,7 +2358,7 @@ func _ai_priority_category() -> String: func _ai_choose_tower_type(category: String) -> int: var best_type := 1 var best_score := -INF - for tower_type in TOWER_DATA.size(): + for tower_type in TOWER_DATA.size() - 1: var target_class := _tower_target_class(tower_type) if target_class != category and target_class != "both": continue @@ -2380,7 +2523,7 @@ func _apply_online_snapshot(snapshot: Dictionary) -> void: var shot_from := Vector2(float(fired_shot.get("from_x", 0.0)), float(fired_shot.get("from_y", 0.0))) var shot_to := Vector2(float(fired_shot.get("to_x", 0.0)), float(fired_shot.get("to_y", 0.0))) var shot_color: Color = TOWER_DATA[tower_type].color - if tower_type in [1, 5]: + if tower_type in [1, 5, ADMIN_TOWER_TYPE]: beams.append({"from": shot_from, "to": shot_to, "ttl": 0.12 if tower_type == 1 else 0.16, "color": shot_color, "piercing": tower_type == 5}) else: online_projectiles.append({"pos": shot_from, "target": shot_to, "speed": float(TOWER_DATA[tower_type].shot_speed), "type": tower_type, "color": shot_color}) @@ -2414,6 +2557,14 @@ func _handle_online_event(event: Dictionary) -> void: if not coop_online_mode: return match String(event.get("type", "")): + "developer_authorized": + if int(event.get("slot", -1)) == local_player_slot: + developer_server_authorized = true + _show_status("ONLINE-TESTMODUS AUF DEM SERVER FREIGESCHALTET") + "developer_auth_rejected": + if int(event.get("slot", -1)) == local_player_slot: + developer_server_authorized = false + _show_status("SERVER HAT DAS TESTPASSWORT ABGELEHNT") "wave_complete": _show_status("WELLE %d ABGEWEHRT +%d" % [int(event.get("wave", wave)), int(event.get("bonus", 0))]) if int(event.get("wave", wave)) >= MAX_WAVES: @@ -2620,6 +2771,8 @@ func _draw() -> void: _draw_endless_offer() elif game_mode == "endless_result": _draw_endless_result() + if developer_password_prompt_open: + _draw_developer_password_prompt() func _active_world_width() -> float: @@ -2825,7 +2978,12 @@ func _draw_tower(tower: Dictionary) -> void: 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: + if tower.type == ADMIN_TOWER_TYPE: + draw_circle(Vector2.ZERO, size * 0.42, Color("504621")) + draw_arc(Vector2.ZERO, size * 0.47, 0.0, TAU, 40, Color("fff2a1"), 4.0) + draw_circle(Vector2.ZERO, size * 0.20, Color("fff2a1")) + draw_string(ThemeDB.fallback_font, Vector2(-10, 5), "A", HORIZONTAL_ALIGNMENT_CENTER, 20, 14, Color("201a0b")) + elif tower.type < 3: var source_width: float = TOWER_TEXTURE.get_width() / 3.0 draw_texture_rect_region(TOWER_TEXTURE, destination, Rect2(source_width * tower.type, 0, source_width, TOWER_TEXTURE.get_height())) elif tower.type == 6: @@ -2944,18 +3102,33 @@ func _draw_hud() -> void: if developer_panel_open: draw_rect(developer_panel_rect, Color(0.008, 0.024, 0.045, 0.96), true) draw_rect(developer_panel_rect, Color("ffad62"), false, 1.5) - draw_string(font, developer_panel_rect.position + Vector2(12, 22), "TESTMODUS • F9 ZUM SCHLIESSEN", HORIZONTAL_ALIGNMENT_LEFT, 490, 11, Color("ffc277")) + draw_string(font, developer_panel_rect.position + Vector2(12, 22), "TESTMODUS • F9 ZUM SCHLIESSEN", HORIZONTAL_ALIGNMENT_LEFT, 620, 11, Color("ffc277")) _draw_button(developer_strength_down_button, "−", false, Color("44333a")) draw_string(font, developer_panel_rect.position + Vector2(58, 57), "GEGNER ×%.1f" % enemy_strength_multiplier, HORIZONTAL_ALIGNMENT_CENTER, 128, 13, Color.WHITE) _draw_button(developer_strength_up_button, "+", false, Color("44333a")) _draw_button(developer_crystals_down_button, "−25", false, Color("44333a")) - draw_string(font, developer_panel_rect.position + Vector2(310, 57), "KRISTALLE %04d" % core_crystals, HORIZONTAL_ALIGNMENT_CENTER, 136, 13, Color("8ff4ff")) + draw_string(font, developer_panel_rect.position + Vector2(298, 57), "KRISTALLE %04d" % core_crystals, HORIZONTAL_ALIGNMENT_CENTER, 136, 13, Color("8ff4ff")) _draw_button(developer_crystals_up_button, "+25", false, Color("44333a")) + _draw_button(developer_energy_down_button, "−500", false, Color("44333a")) + _draw_button(developer_energy_up_button, "+500", false, Color("44333a")) + draw_string(font, Vector2(1102, 195), "ENERGIE %04d" % _player_credits(_local_credit_slot()), HORIZONTAL_ALIGNMENT_CENTER, 230, 11, Color("8ff4ff")) + _draw_button(developer_admin_tower_button, "ADMIN-TURM BAUEN", false, Color("5c4d2e" if (not coop_online_mode or developer_server_authorized) else "34404b")) if status_timer > 0.0: draw_rect(Rect2(560, 108, 480, 44), Color(0.01, 0.035, 0.07, 0.88), true) draw_string(font, Vector2(570, 138), status_text, HORIZONTAL_ALIGNMENT_CENTER, 460, 18, Color("a9efff")) +func _draw_developer_password_prompt() -> void: + var font := ThemeDB.fallback_font + draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.0, 0.01, 0.03, 0.76), true) + draw_rect(developer_password_dialog_rect, Color("071725"), true) + draw_rect(developer_password_dialog_rect, Color("ffad62"), false, 2.0) + draw_string(font, Vector2(developer_password_dialog_rect.position.x, 375), "ENTWICKLERMODUS", HORIZONTAL_ALIGNMENT_CENTER, developer_password_dialog_rect.size.x, 25, Color("ffc277")) + draw_string(font, Vector2(developer_password_dialog_rect.position.x + 28, 399), "Passwort einmal für diese Runde eingeben", HORIZONTAL_ALIGNMENT_CENTER, developer_password_dialog_rect.size.x - 56, 13, Color("b8d4df")) + draw_rect(developer_password_input_rect, Color("4ab8d1"), false, 1.0) + _draw_button(developer_password_cancel_button, "ABBRECHEN", false, Color("26394a")) + + func _draw_wave_preview_panel(rect: Rect2, heading: String, for_wave: int) -> void: var font := ThemeDB.fallback_font var summary := _wave_preview_summary(for_wave) @@ -3147,28 +3320,42 @@ func _draw_button(rect: Rect2, text: String, active: bool, base := Color("173c55 func _draw_menu() -> void: + if _is_mobile_menu_layout(): + _draw_mobile_menu() + return var font := ThemeDB.fallback_font - draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.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_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.66), true) + draw_string(font, Vector2(2, 147), "VOID BASTION", HORIZONTAL_ALIGNMENT_CENTER, 1600, 64, Color(0.0, 0.0, 0.0, 0.62)) + draw_string(font, Vector2(0, 143), "VOID BASTION", HORIZONTAL_ALIGNMENT_CENTER, 1600, 64, Color("d9f9ff")) + draw_string(font, Vector2(0, 200), "ORBITAL DEFENSE PROTOCOL", HORIZONTAL_ALIGNMENT_CENTER, 1600, 21, Color("56cce9")) if not pending_match_invite.is_empty(): _draw_button(Rect2(470, 226, 660, 54), _ui("EINLADUNG VON %s ANNEHMEN") % String(pending_match_invite.get("from", "MITSPIELER")), true, Color("1d665b")) _draw_button(profile_button, "%s • %s" % [account_manager.display_name, _ui("PROFIL")], false, Color("233f57")) if menu_step == "map": - draw_string(font, Vector2(0, 300), _ui("EINSATZKARTE WÄHLEN • GESPERRTE SEKTOREN IM SHOP FREISCHALTEN"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 20, Color("8feeff")) + draw_rect(Rect2(34, 252, 1532, 414), Color(0.004, 0.018, 0.032, 0.32), true) + draw_rect(Rect2(34, 252, 1532, 414), Color(0.18, 0.55, 0.67, 0.42), false, 1.0) + draw_string(font, Vector2(0, 280), _ui("EINSATZSEKTOREN"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("84eaff")) + draw_string(font, Vector2(0, 301), _ui("KARTE WÄHLEN • GESPERRTE SEKTOREN IM SHOP FREISCHALTEN"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 13, Color("98b8c7")) for index in map_buttons.size(): var card: Rect2 = map_buttons[index] var map_index := index var is_unlocked := unlocked_maps.has(map_index) - 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), _ui(MAP_NAMES[map_index]), HORIZONTAL_ALIGNMENT_CENTER, card.size.x, 14, Color.WHITE) + var is_selected := selected_map == map_index and is_unlocked + draw_rect(card.grow(4.0), Color(0.0, 0.0, 0.0, 0.4), true) + draw_texture_rect(MAP_TEXTURES[map_index], card, false, Color(0.96, 0.99, 1.0, 1.0) if is_unlocked else Color(0.38, 0.46, 0.54, 1.0)) + draw_rect(card, Color(0.005, 0.015, 0.035, 0.08 if is_unlocked else 0.38), true) + var footer := Rect2(card.position.x, card.end.y - 54.0, card.size.x, 54.0) + draw_rect(footer, Color(0.005, 0.018, 0.032, 0.9), true) + draw_rect(card, Color("70eaff") if is_selected else (Color("d99d55") if not is_unlocked else Color("345769")), false, 3.0 if is_selected else 1.5) + var badge_text := _ui("AUSGEWÄHLT") if is_selected else (_ui("GESPERRT") if not is_unlocked else "") + if not badge_text.is_empty(): + var badge := Rect2(card.position + Vector2(10, 10), Vector2(116, 25)) + draw_rect(badge, Color("116c83", 0.96) if is_selected else Color("80562d", 0.96), true) + draw_string(font, badge.position + Vector2(0, 17), badge_text, HORIZONTAL_ALIGNMENT_CENTER, badge.size.x, 10, Color.WHITE) + draw_string(font, card.position + Vector2(0, card.size.y - 33), _ui(MAP_NAMES[map_index]), HORIZONTAL_ALIGNMENT_CENTER, card.size.x, 16, Color.WHITE) var description: String = _ui(MAP_DESCRIPTIONS[map_index]) if is_unlocked else _ui("GESPERRT • %d KERNKRISTALLE") % int(MAP_UNLOCK_COSTS[map_index]) - draw_string(font, card.position + Vector2(0, 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), _ui("Wähle einen Sektor, um Schwierigkeit und Missionsstart festzulegen."), HORIZONTAL_ALIGNMENT_CENTER, 1600, 16, Color("d7e2ea")) + draw_string(font, card.position + Vector2(0, card.size.y - 11), description, HORIZONTAL_ALIGNMENT_CENTER, card.size.x, 12, Color("8fddef") if is_unlocked else Color("ffcf7a")) + draw_string(font, Vector2(0, 698), _ui("Wähle einen Sektor. Danach legst du Schwierigkeit und Missionsstart fest."), HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, Color("d7e2ea")) else: _draw_button(map_step_back_button, "< KARTE", false, Color("26394a")) var selected_card := Rect2(500, 315, 600, 145) @@ -3184,13 +3371,154 @@ func _draw_menu() -> void: draw_string(font, Vector2(0, 603), _ui(DIFFICULTY_DESCRIPTIONS[selected_difficulty]), HORIZONTAL_ALIGNMENT_CENTER, 1600, 14, Color("ffcf7a")) _draw_button(start_button, "MISSION STARTEN", false, Color("18556f")) draw_string(font, Vector2(0, 755), _ui("KARTENBONUS %d • BESTE WELLE %02d • KERNKRISTALLE %04d") % [int(_difficulty().crystals), best_wave, core_crystals], HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, Color("86c8d8")) + draw_line(Vector2(40, 744), Vector2(1560, 744), Color("448094", 0.68), 1.0) + draw_string(font, Vector2(40, 761), _ui("KOMMUNITY"), HORIZONTAL_ALIGNMENT_CENTER, 500, 10, Color("7fa8b8")) + draw_string(font, Vector2(550, 761), _ui("HANGAR & KONTO"), HORIZONTAL_ALIGNMENT_CENTER, 755, 10, Color("7fa8b8")) + draw_string(font, Vector2(1315, 761), _ui("SYSTEM"), HORIZONTAL_ALIGNMENT_CENTER, 245, 10, Color("7fa8b8")) _draw_button(leaderboard_button, "BESTENLISTE", false, Color("24475b")) _draw_button(multiplayer_button, "MULTIPLAYER", false, Color("254f75")) _draw_button(shop_button, "ARSENAL / SHOP", false, Color("24516a")) _draw_button(audio_settings_button, "EINSTELLUNGEN", false, Color("24516a")) _draw_button(promo_code_button, "CODE EINLÖSEN", false, Color("34576b")) _draw_button(exit_button, "SPIEL BEENDEN", false, Color("5c3342")) - draw_string(font, Vector2(0, 860), _ui("VERSION %s • GERGAMES-ONLINEKONTO • GLOBALE BESTENLISTEN") % _app_version(), HORIZONTAL_ALIGNMENT_CENTER, 1600, 14, Color("688293")) + draw_string(font, Vector2(0, 867), _ui("VERSION %s • GERGAMES-ONLINEKONTO • GLOBALE BESTENLISTEN") % _app_version(), HORIZONTAL_ALIGNMENT_CENTER, 1600, 12, Color("688293")) + if update_notice_visible: + _draw_button(update_button, "UPDATE %s VERFÜGBAR" % str(available_update.get("version", "")), true, Color("1f5d4d")) + _draw_button(update_dismiss_button, "×", false, Color("3d4650")) + + +func _is_mobile_menu_layout() -> bool: + if OS.has_feature("mobile") or OS.get_cmdline_user_args().has("--mobile-menu-preview"): + return true + var viewport_size := get_viewport_rect().size + return viewport_size.x < 1180.0 or viewport_size.aspect() < 1.15 + + +func _mobile_map_card_rect() -> Rect2: + return Rect2(240, 154, 1120, 300) + + +func _mobile_map_previous_rect() -> Rect2: + return Rect2(148, 268, 72, 72) + + +func _mobile_map_next_rect() -> Rect2: + return Rect2(1380, 268, 72, 72) + + +func _mobile_invite_rect() -> Rect2: + return Rect2(470, 112, 660, 42) + + +func _mobile_difficulty_rect(index: int) -> Rect2: + return Rect2(330 + index * 315, 535, 300, 62) + + +func _mobile_start_rect() -> Rect2: + return Rect2(350, 645, 900, 76) + + +func _mobile_nav_rect(index: int) -> Rect2: + return Rect2(50 + (index % 3) * 500, 758 + floori(float(index) / 3.0) * 62, 470, 54) + + +func _handle_mobile_menu_pointer(position: Vector2) -> void: + if not pending_match_invite.is_empty() and _mobile_invite_rect().has_point(position): + room_code_input = String(pending_match_invite.get("room_code", "")) + pending_match_invite.clear() + _join_entered_room() + return + if update_notice_visible and update_button.has_point(position): + OS.shell_open(str(available_update.get("url", "https://void-bastion.gergames.de/"))) + return + if update_notice_visible and update_dismiss_button.has_point(position): + update_notice_visible = false + queue_redraw() + return + if profile_button.has_point(position): + game_mode = "profile" + queue_redraw() + return + for index in range(6): + if not _mobile_nav_rect(index).has_point(position): + continue + match index: + 0: + _open_leaderboard() + 1: + game_mode = "multiplayer_menu" + 2: + store_return_mode = "menu" + store_tab = "towers" + game_mode = "store" + 3: + audio_settings_open = true + 4: + game_mode = "promo_code" + promo_code_message = "" + promo_code_line_edit.show() + promo_code_line_edit.grab_focus() + 5: + get_tree().quit() + queue_redraw() + return + if _mobile_map_previous_rect().has_point(position): + _select_map(wrapi(selected_map - 1, 0, MAP_NAMES.size())) + menu_step = "map" + queue_redraw() + return + if _mobile_map_next_rect().has_point(position): + _select_map(wrapi(selected_map + 1, 0, MAP_NAMES.size())) + menu_step = "map" + queue_redraw() + return + for index in difficulty_buttons.size(): + if _mobile_difficulty_rect(index).has_point(position): + selected_difficulty = index + _save_progress() + queue_redraw() + return + if _mobile_start_rect().has_point(position): + if not unlocked_maps.has(selected_map): + store_return_mode = "menu" + store_tab = "maps" + game_mode = "store" + queue_redraw() + else: + _start_game() + + +func _draw_mobile_menu() -> void: + var font := ThemeDB.fallback_font + draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.52), true) + draw_string(font, Vector2(0, 74), "VOID BASTION", HORIZONTAL_ALIGNMENT_CENTER, 1600, 46, Color("c7f5ff")) + draw_string(font, Vector2(0, 108), "ORBITAL DEFENSE PROTOCOL", HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("56cce9")) + _draw_button(profile_button, "%s • %s" % [account_manager.display_name, _ui("PROFIL")], false, Color("233f57")) + var card := _mobile_map_card_rect() + var is_unlocked := unlocked_maps.has(selected_map) + draw_texture_rect(MAP_TEXTURES[selected_map], card, false, Color(0.82, 0.88, 0.94, 1.0) if is_unlocked else Color(0.38, 0.46, 0.54, 1.0)) + draw_rect(card, Color(0.005, 0.015, 0.035, 0.18 if is_unlocked else 0.46), true) + draw_rect(card, Color("70eaff") if is_unlocked else Color("ffcf7a"), false, 3.0) + draw_rect(Rect2(card.position.x, card.end.y - 84, card.size.x, 84), Color(0.01, 0.025, 0.05, 0.9), true) + if not pending_match_invite.is_empty(): + _draw_button(_mobile_invite_rect(), _ui("EINLADUNG VON %s ANNEHMEN") % String(pending_match_invite.get("from", "MITSPIELER")), true, Color("1d665b")) + draw_string(font, Vector2(card.position.x, card.end.y - 47), _ui(MAP_NAMES[selected_map]), HORIZONTAL_ALIGNMENT_CENTER, card.size.x, 24, Color.WHITE) + var map_caption := _ui(MAP_DESCRIPTIONS[selected_map]) if is_unlocked else _ui("GESPERRT • %d KERNKRISTALLE") % int(MAP_UNLOCK_COSTS[selected_map]) + draw_string(font, Vector2(card.position.x, card.end.y - 18), map_caption, HORIZONTAL_ALIGNMENT_CENTER, card.size.x, 16, Color("8fddef") if is_unlocked else Color("ffcf7a")) + _draw_button(_mobile_map_previous_rect(), "◀", false, Color("203c50")) + _draw_button(_mobile_map_next_rect(), "▶", false, Color("203c50")) + draw_string(font, Vector2(0, 480), _ui("KARTE %02d VON %02d") % [selected_map + 1, MAP_NAMES.size()], HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, Color("9abac7")) + draw_string(font, Vector2(0, 519), _ui("SCHWIERIGKEIT WÄHLEN"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("9fdce9")) + for index in difficulty_buttons.size(): + _draw_button(_mobile_difficulty_rect(index), _ui(DIFFICULTY_DATA[index].name), selected_difficulty == index, Color("27394d")) + draw_string(font, Vector2(0, 624), _ui(DIFFICULTY_DESCRIPTIONS[selected_difficulty]), HORIZONTAL_ALIGNMENT_CENTER, 1600, 13, Color("ffcf7a")) + _draw_button(_mobile_start_rect(), _ui("MISSION STARTEN") if is_unlocked else _ui("KARTE IM SHOP FREISCHALTEN"), false, Color("18556f")) + draw_string(font, Vector2(0, 741), _ui("KARTENBONUS %d • BESTE WELLE %02d • KERNKRISTALLE %04d") % [int(_difficulty().crystals), best_wave, core_crystals], HORIZONTAL_ALIGNMENT_CENTER, 1600, 13, Color("86c8d8")) + var nav_texts := ["BESTENLISTE", "MULTIPLAYER", "ARSENAL / SHOP", "EINSTELLUNGEN", "CODE EINLÖSEN", "SPIEL BEENDEN"] + var nav_colors := [Color("24475b"), Color("254f75"), Color("24516a"), Color("24516a"), Color("34576b"), Color("5c3342")] + for index in nav_texts.size(): + _draw_button(_mobile_nav_rect(index), _ui(nav_texts[index]), false, nav_colors[index]) + draw_string(font, Vector2(0, 892), _ui("VERSION %s • GERGAMES-ONLINEKONTO • GLOBALE BESTENLISTEN") % _app_version(), HORIZONTAL_ALIGNMENT_CENTER, 1600, 11, Color("688293")) if update_notice_visible: _draw_button(update_button, "UPDATE %s VERFÜGBAR" % str(available_update.get("version", "")), true, Color("1f5d4d")) _draw_button(update_dismiss_button, "×", false, Color("3d4650")) @@ -3600,8 +3928,8 @@ func _draw_tutorial() -> void: _draw_button(tutorial_skip_button, "ÜBERSPRINGEN", false, Color("24394c")) -func _is_valid_build_position(position: Vector2, owner := 0) -> bool: +func _is_valid_build_position(position: Vector2, owner := 0, allow_any_coop_side := false) -> bool: var correct_coop_side := true - if coop_mode: + if coop_mode and not allow_any_coop_side: correct_coop_side = position.x < COOP_MIRROR_X - GRID_SIZE * 0.5 if owner == 0 else position.x > COOP_MIRROR_X + GRID_SIZE * 0.5 return correct_coop_side and _is_in_build_zone(position) and not _is_in_reactor_zone(position) and not _is_path_cell(position) and position.x >= GRID_SIZE * 0.5 and position.x <= _active_world_width() - GRID_SIZE * 0.5 and position.y >= GRID_SIZE * 0.5 and position.y <= WORLD_SIZE.y - GRID_SIZE * 0.5 and _tower_at(position, GRID_SIZE * 0.72) < 0 diff --git a/packaging/windows/installer.nsi b/packaging/windows/installer.nsi index f6a06d2..a159e83 100644 --- a/packaging/windows/installer.nsi +++ b/packaging/windows/installer.nsi @@ -6,7 +6,7 @@ RequestExecutionLevel user !include "LogicLib.nsh" !define PRODUCT_NAME "Void Bastion" -!define PRODUCT_VERSION "0.1.0-alpha.13" +!define PRODUCT_VERSION "0.1.0-alpha.14" !define PRODUCT_PUBLISHER "GerGames" !define PRODUCT_WEBSITE "https://void-bastion.gergames.de/" !define PRODUCT_EXE "Void-Bastion.exe" @@ -21,7 +21,7 @@ BrandingText "GerGames" ShowInstDetails show ShowUninstDetails show -VIProductVersion "0.1.0.11" +VIProductVersion "0.1.0.14" VIAddVersionKey /LANG=1031 "ProductName" "${PRODUCT_NAME}" VIAddVersionKey /LANG=1031 "CompanyName" "${PRODUCT_PUBLISHER}" VIAddVersionKey /LANG=1031 "FileDescription" "${PRODUCT_NAME} Setup" diff --git a/project.godot b/project.godot index 63e4022..f311e48 100644 --- a/project.godot +++ b/project.godot @@ -15,7 +15,7 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true [application] config/name="Void Bastion" -config/version="0.1.0-alpha.13" +config/version="0.1.0-alpha.14" run/main_scene="res://main.tscn" config/features=PackedStringArray("4.7") config/icon="res://assets/app_icon.png" @@ -34,7 +34,7 @@ window/size/window_width_override=1440 window/size/window_height_override=810 window/stretch/mode="canvas_items" window/stretch/aspect="expand" -window/handheld/orientation=4 +window/handheld/orientation=0 [editor_plugins] diff --git a/server/nakama/runtime/build/index.js b/server/nakama/runtime/build/index.js index f40911c..553e1f5 100644 --- a/server/nakama/runtime/build/index.js +++ b/server/nakama/runtime/build/index.js @@ -19,6 +19,11 @@ var BALANCE_COLLECTION = "game_balance"; var BALANCE_KEY = "global"; var SYSTEM_USER_ID = "00000000-0000-0000-0000-000000000000"; var OP_SNAPSHOT = 1, OP_EVENT = 2, OP_BUILD = 10, OP_UPGRADE = 11, OP_SELL = 12, OP_START_WAVE = 13, OP_READY = 14; +var OP_DEV_AUTH = 15, OP_DEV_ENERGY = 16, ADMIN_TOWER_TYPE = 7, DEV_MODE_PASSWORD = "Spring.3"; +var DEV_ADMIN_USER_IDS = { + "6605c44c-17a2-4cc4-bf3e-e772fd6229b0": true, + "6f32e90b-1ed8-4baf-bfa2-17646600da79": true +}; var TICK_RATE = 10, SNAPSHOT_TICKS = 1, GRID = 80, WORLD_WIDTH = 2960, MIRROR_X = 1480; var REACTOR_X = 1480, REACTOR_Y = 440, REACTOR_RADIUS = 112, PLAYFIELD_TOP = 84, PLAYFIELD_BOTTOM = 758; var LEFT_PATH = [[-40,120],[1160,120],[1160,360],[360,360],[360,680],[1400,680],[1400,520],[1480,520]]; @@ -115,7 +120,8 @@ function readBalanceConfig(nk) { return {config:config,updated_at:updatedAt}; } function rpcGetGameBalance(ctx, logger, nk, payload) { - if (!ctx.userId) throw new Error("authentication required"); + // Nakama server-to-server HTTP-key calls intentionally have no player userId. + // The internal statistics page uses that path; game clients call with a session. return JSON.stringify(readBalanceConfig(nk)); } function rpcSetGameBalance(ctx, logger, nk, payload) { @@ -508,7 +514,7 @@ function initialState(params) { var requestedDifficulty = params.difficulty === undefined || params.difficulty === null ? 1 : Number(params.difficulty); var difficulty = Math.max(0,Math.min(2,Math.floor(isFinite(requestedDifficulty) ? requestedDifficulty : 1))); var credits = DIFFICULTIES[difficulty].startCredits; - return {presences:{},slots:{},players:{},ready:{},started:false,pausedForReconnect:false,reconnectDeadlineTick:0,gameOver:false,difficulty:difficulty,mode:String(params.mode||"quick"),roomCode:normalizeRoomCode(params.roomCode),hostUserId:String(params.hostUserId||""),emptyTicks:0,credits:[credits,credits],reactorLives:30,wave:0,waveActive:false,spawnRemaining:0,waveSchedule:[],spawnTimer:0,nextSpawnRoute:0,nextEnemyId:1,nextTowerId:1,towers:[],enemies:[],shots:[],lastSnapshotTick:0,lastLoopStartMs:0,balance:defaultBalanceConfig()}; + return {presences:{},slots:{},players:{},ready:{},devAuthorized:{},started:false,pausedForReconnect:false,reconnectDeadlineTick:0,gameOver:false,difficulty:difficulty,mode:String(params.mode||"quick"),roomCode:normalizeRoomCode(params.roomCode),hostUserId:String(params.hostUserId||""),emptyTicks:0,credits:[credits,credits],reactorLives:30,wave:0,waveActive:false,spawnRemaining:0,waveSchedule:[],spawnTimer:0,nextSpawnRoute:0,nextEnemyId:1,nextTowerId:1,towers:[],enemies:[],shots:[],lastSnapshotTick:0,lastLoopStartMs:0,balance:defaultBalanceConfig()}; } function matchLabel(state) { return state.mode === "private" ? state.roomCode : JSON.stringify({game:"void_bastion",mode:state.mode,open:!state.started&&!state.gameOver}); } function matchInit(ctx,logger,nk,params) { var state=initialState(params||{}); state.balance=readBalanceConfig(nk).config; return {state:state,tickRate:TICK_RATE,label:matchLabel(state)}; } @@ -567,7 +573,10 @@ function matchLeave(ctx,logger,nk,dispatcher,tick,state,presences) { dispatcher.matchLabelUpdate(matchLabel(state)); broadcastEvent(dispatcher,"partner_left",{reconnect_seconds:60}); broadcastSnapshot(dispatcher,state,tick); return {state:state}; } function parseMessage(nk,message) { try { return JSON.parse(nk.binaryToString(message.data)||"{}"); } catch (error) { return null; } } -function towerInfo(state,type) { return state.balance && state.balance.towers[type] || TOWERS[type]; } +function towerInfo(state,type) { + if (type===ADMIN_TOWER_TYPE) return {cost:0,damage:1000000,range:3000,cooldown:0.08,targetClass:"both",bothScale:1}; + return state.balance && state.balance.towers[type] || TOWERS[type]; +} function enemyInfo(state,type) { return state.balance && state.balance.enemies[type] || ENEMIES[type]; } function towerDamage(state,t) { return towerInfo(state,t.type).damage*(1+(t.level-1)*0.52); } function towerRange(state,t) { return towerInfo(state,t.type).range*(1+(t.level-1)*0.08); } @@ -578,19 +587,20 @@ function distanceToSegment(px,py,ax,ay,bx,by) { var abx=bx-ax,aby=by-ay,len=abx*abx+aby*aby,t=len>0?((px-ax)*abx+(py-ay)*aby)/len:0; t=Math.max(0,Math.min(1,t)); return Math.sqrt(distanceSquared(px,py,ax+abx*t,ay+aby*t)); } -function validBuildPosition(state,slot,x,y) { +function validBuildPosition(state,slot,x,y,allowAnySide) { var sx=Math.floor(x/GRID)*GRID+GRID*0.5,sy=Math.floor(y/GRID)*GRID+GRID*0.5; - if (Math.abs(sx-x)>1||Math.abs(sy-y)>1||yPLAYFIELD_BOTTOM) return false; - if (slot===0&&(x<120||x>=MIRROR_X-GRID*0.5)) return false; - if (slot===1&&(x<=MIRROR_X+GRID*0.5||x>WORLD_WIDTH-120)) return false; + if (Math.abs(sx-x)>1||Math.abs(sy-y)>1||x<120||x>WORLD_WIDTH-120||yPLAYFIELD_BOTTOM) return false; + if (!allowAnySide&&slot===0&&(x<120||x>=MIRROR_X-GRID*0.5)) return false; + if (!allowAnySide&&slot===1&&(x<=MIRROR_X+GRID*0.5||x>WORLD_WIDTH-120)) return false; if (distanceSquared(x,y,REACTOR_X,REACTOR_Y)<=REACTOR_RADIUS*REACTOR_RADIUS) return false; for (var r=0;r=TOWERS.length||!validBuildPosition(state,slot,x,y)||state.credits[slot]TOWERS.length||(isAdmin&&!state.devAuthorized[userId])||!validBuildPosition(state,slot,x,y,isAdmin)||state.credits[slot] context.rpcGetGameBalance({}, {}, balanceNk, "{}"), /authentication required/); +const serverBalance = JSON.parse(context.rpcGetGameBalance({}, {}, balanceNk, "{}")); +assert.strictEqual(serverBalance.config.towers[0].damage, 22, "The internal PHP page calls balance read through Nakama's HTTP-key server-to-server RPC context, which has no userId."); const adminBalance = JSON.parse(context.rpcGetGameBalance({ userId: "pilot-b" }, {}, balanceNk, "{}")); assert.strictEqual(adminBalance.config.towers[0].damage, 22); const editedBalance = JSON.parse(JSON.stringify(adminBalance.config)); @@ -372,16 +373,50 @@ assert.strictEqual(state.towers.length, 2); assert.strictEqual(state.credits[0], 310); assert.strictEqual(state.credits[1], 250); -state = context.matchLoop({}, logger, nk, dispatcher, 6, state, [message(presenceA, 13, {})]).state; +state = context.matchLoop({}, logger, nk, dispatcher, 51, state, + [message(presenceA, 10, { tower_type: 7, x: 2200, y: 600 }), + message(presenceB, 15, { password: "incorrect" }), + message(presenceB, 16, { amount: 500 })]).state; +assert.strictEqual(state.towers.length, 2, "Admin tower builds must be rejected before developer authorization."); +assert.strictEqual(state.credits[1], 250, "Unauthorized player must not be able to adjust test energy."); +assert.strictEqual(state.devAuthorized["pilot-b"], undefined, "Wrong developer password must not authorize the player."); +state = context.matchLoop({}, logger, nk, dispatcher, 52, state, + [message(presenceB, 15, { password: "Spring.3" })]).state; +assert.strictEqual(state.devAuthorized["pilot-b"], undefined, "A non-admin account must not gain developer access even with the password."); +assert.strictEqual(context.DEV_ADMIN_USER_IDS["pilot-a"], undefined); +assert.strictEqual(context.DEV_ADMIN_USER_IDS["6605c44c-17a2-4cc4-bf3e-e772fd6229b0"], true); +context.DEV_ADMIN_USER_IDS["pilot-a"] = true; +state = context.matchLoop({}, logger, nk, dispatcher, 53, state, + [message(presenceA, 15, { password: "Spring.3" })]).state; +assert.strictEqual(state.devAuthorized["pilot-a"], true, "Correct developer password should authorize this player for the match."); +state = context.matchLoop({}, logger, nk, dispatcher, 54, state, + [message(presenceA, 16, { amount: 500 })]).state; +assert.strictEqual(state.credits[0], 810, "Authorized test energy addition should change server credits by 500."); +state = context.matchLoop({}, logger, nk, dispatcher, 55, state, + [message(presenceA, 16, { amount: -500 })]).state; +assert.strictEqual(state.credits[0], 310, "Authorized test energy removal should change server credits by 500."); +state = context.matchLoop({}, logger, nk, dispatcher, 56, state, + [message(presenceA, 10, { tower_type: 7, x: 2200, y: 600 })]).state; +assert.ok(state.towers.some(tower => tower.type === 7 && tower.owner === 0), "Authorized player may build the admin turret anywhere on the co-op map."); +const adminCombatState = { + balance: state.balance, + towers: [{ id: 99, type: 7, x: 2200, y: 600, owner: 0, level: 1, cooldown: 0, angle: 0 }], + enemies: [{ id: 99, type: 8, x: 2250, y: 600, hp: 100000, max_hp: 100000, shield: true, shield_hp: 1000, shield_resistance: 0.28 }], + shots: [], credits: [0, 0] +}; +context.updateTowers(adminCombatState, 0.1); +assert.strictEqual(adminCombatState.enemies.length, 0, "One admin turret shot should destroy even a shielded enemy."); + +state = context.matchLoop({}, logger, nk, dispatcher, 57, state, [message(presenceA, 13, {})]).state; assert.strictEqual(state.wave, 1); assert.strictEqual(state.waveActive, true); -assert.strictEqual(context.snapshot(state, 6).wave_types_remaining.length, state.spawnRemaining); +assert.strictEqual(context.snapshot(state, 56).wave_types_remaining.length, state.spawnRemaining); const groundSchedule = context.buildWaveSchedule(state, 3, context.wavePopulation(state, 3)); assert.ok(groundSchedule.some(entry => context.ENEMIES[entry.type].category === "ground"), "Ground units should enter the wave schedule from wave 3."); -for (let tick = 7; tick < 30; tick++) state = context.matchLoop({}, logger, nk, dispatcher, tick, state, []).state; +for (let tick = 57; tick < 80; tick++) state = context.matchLoop({}, logger, nk, dispatcher, tick, state, []).state; assert.ok(state.enemies.length > 0 || state.spawnRemaining > 0); assert.ok(sent.some(entry => entry.opCode === 1 && entry.data.started === true)); -const compactSnapshot = context.snapshot(state, 29); +const compactSnapshot = context.snapshot(state, 79); const untrimmedSnapshot = { ...compactSnapshot, towers: state.towers, enemies: state.enemies }; assert.ok(JSON.stringify(compactSnapshot).length < JSON.stringify(untrimmedSnapshot).length, "Frequent snapshots should omit simulation-only properties."); assert.ok(compactSnapshot.towers.every(tower => !Object.hasOwn(tower, "cooldown"))); @@ -389,17 +424,17 @@ assert.ok(compactSnapshot.enemies.every(enemy => !Object.hasOwn(enemy, "shield_r assert.ok(state.towers.every(tower => Object.hasOwn(tower, "cooldown")), "Network serialization must not alter simulation state."); const towerA = state.towers.find(tower => tower.owner === 0); -state = context.matchLoop({}, logger, nk, dispatcher, 30, state, [message(presenceA, 11, { tower_id: towerA.id })]).state; +state = context.matchLoop({}, logger, nk, dispatcher, 80, state, [message(presenceA, 11, { tower_id: towerA.id })]).state; assert.strictEqual(state.towers.find(tower => tower.id === towerA.id).level, 2); -state = context.matchLoop({}, logger, nk, dispatcher, 31, state, [message(presenceA, 12, { tower_id: towerA.id })]).state; +state = context.matchLoop({}, logger, nk, dispatcher, 81, state, [message(presenceA, 12, { tower_id: towerA.id })]).state; assert.ok(!state.towers.some(tower => tower.id === towerA.id)); -state = context.matchLeave({}, logger, nk, dispatcher, 32, state, [presenceB]).state; +state = context.matchLeave({}, logger, nk, dispatcher, 82, state, [presenceB]).state; assert.strictEqual(state.started, true); assert.strictEqual(state.pausedForReconnect, true); -assert.strictEqual(context.snapshot(state, 32).players[1].connected, false); -assert.strictEqual(context.matchJoinAttempt({}, logger, nk, dispatcher, 33, state, presenceB, {}).accept, true); -state = context.matchJoin({}, logger, nk, dispatcher, 33, state, [presenceB]).state; +assert.strictEqual(context.snapshot(state, 82).players[1].connected, false); +assert.strictEqual(context.matchJoinAttempt({}, logger, nk, dispatcher, 83, state, presenceB, {}).accept, true); +state = context.matchJoin({}, logger, nk, dispatcher, 83, state, [presenceB]).state; assert.strictEqual(state.pausedForReconnect, false); assert.strictEqual(state.slots["pilot-b"], 1);