diff --git a/README.md b/README.md index 940a669..60472b4 100644 --- a/README.md +++ b/README.md @@ -110,11 +110,19 @@ Der Stack ist auf dem vorgesehenen Unraid-Server eingerichtet. Nakamas Spiel-API Die öffentliche Spiel-API ist unter `api.void-bastion.gergames.de` erreichbar. DNS, HTTPS/WSS über den vorhandenen Nginx Proxy Manager und die Godot-Nakama-Clientanbindung sind eingerichtet. Website und Spielserver verwenden damit getrennte Adressen. +### Spielupdates + +Der Client prüft beim Start die öffentlichen Releases des Projekts. Ist eine neuere Version vorhanden, erscheint im Hauptmenü ein kompakter Update-Hinweis. Unter Windows öffnet er direkt das aktuelle Setup, unter Android künftig das aktuelle APK; fehlt ein passendes Paket, wird die öffentliche Release-Seite geöffnet. Ein Update wird niemals ungefragt installiert. + +### Kartenregel: Laufweg und Reaktor + +Jede Einzelspielerroute muss mit ihrem letzten Wegpunkt exakt im Mittelpunkt der primären Reaktorzone enden. `_validate_route_ends_at_reactor()` prüft diese Vorgabe beim Laden jeder Karte; der Smoke-Test prüft sie zusätzlich für alle vorhandenen Karten. Dadurch bleiben sichtbare Straße, Gegnerbewegung, Bausperre und Reaktoreinfahrt deckungsgleich. + ## Steuerung > **Online-Stand Alpha 0.1.0:** Der offizielle Nakama-Godot-Client, automatische Gastanmeldung, servergeprüfte öffentliche Spielernamen, Kontolöschung im Spiel, 36 autoritative Ranglisten, geprüfte Ergebnisübermittlung, Echtzeit-Sockets, serverautoritativer Zwei-Spieler-Koop, private Räume per Raumcode, eine Bereit-Lobby und Wiederverbindung sind eingebaut. Ranglisten lösen den aktuellen öffentlichen Profilnamen über die unveränderliche Spieler-ID auf. Die öffentliche API ist über `https://api.void-bastion.gergames.de` mit einem gültigen Let's-Encrypt-Zertifikat freigeschaltet. Details stehen in `ONLINE_SETUP.md`. -Die Schaltfläche **Bestenliste** im Hauptmenü zeigt die zwölf besten Piloten für die aktuell gewählte Karte und Schwierigkeit. Zwischen Mission und Endlosmodus kann direkt in der Ansicht umgeschaltet werden; der eigene Eintrag wird farblich hervorgehoben. +Die Schaltfläche **Bestenliste** im Hauptmenü zeigt die zwölf besten Piloten. Karte, Schwierigkeitsgrad sowie Mission oder Endlosmodus können direkt in der Ranglistenansicht gefiltert werden. Jede Zeile nennt Karte und Schwierigkeit sichtbar; der eigene Eintrag wird farblich hervorgehoben. - Turm unten auswählen und auf eine freie Plattformfläche klicken oder tippen. - Am PC folgt der Reichweitenkreis vor dem Bau dem Mauszeiger. Auf Mobilgeräten markiert der erste Tipp den Bauplatz samt Reichweite; der zweite Tipp auf dasselbe Feld bestätigt den Bau. diff --git a/main.gd b/main.gd index f38cef4..e5369c7 100644 --- a/main.gd +++ b/main.gd @@ -23,9 +23,9 @@ const MAP_TEXTURES := [ const MAP_NAMES := ["BASTION PRIME", "HELIOS-WERFT", "PYRAX-RAFFINERIE"] const MAP_DESCRIPTIONS := ["Dreifache Feuerlinie", "Langer Werft-Korridor", "Gefährlicher Asteroidenring"] 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(1640, 520)], - [Vector2(-40, 280), Vector2(1320, 280), Vector2(1320, 440), Vector2(280, 440), Vector2(280, 680), Vector2(1480, 680), Vector2(1480, 440), Vector2(1640, 440)], - [Vector2(-40, 120), Vector2(360, 120), Vector2(360, 600), Vector2(1320, 600), Vector2(1320, 280), Vector2(1640, 280)] + [Vector2(-40, 120), Vector2(1160, 120), Vector2(1160, 360), Vector2(360, 360), Vector2(360, 680), Vector2(1560, 680), Vector2(1560, 520), Vector2(1480, 520), Vector2(1480, 440)], + [Vector2(-40, 280), Vector2(1320, 280), Vector2(1320, 440), Vector2(280, 440), Vector2(280, 680), Vector2(1560, 680), Vector2(1560, 440), Vector2(1480, 440)], + [Vector2(-40, 120), Vector2(360, 120), Vector2(360, 600), Vector2(1320, 600), Vector2(1320, 280), Vector2(1560, 280), Vector2(1560, 360), Vector2(1480, 360)] ] const MAP_BUILD_ZONES := [ [Rect2(155, 95, 1380, 660)], @@ -33,16 +33,16 @@ const MAP_BUILD_ZONES := [ [Rect2(180, 110, 1270, 645)] ] const MAP_REACTOR_ZONES := [ - [{"center": Vector2(1470, 430), "radius": 190.0}], - [{"center": Vector2(1480, 430), "radius": 185.0}], + [{"center": Vector2(1480, 440), "radius": 190.0}], + [{"center": Vector2(1480, 440), "radius": 185.0}], [ - {"center": Vector2(1450, 355), "radius": 165.0}, + {"center": Vector2(1480, 360), "radius": 165.0}, {"center": Vector2(690, 385), "radius": 155.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)] +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 @@ -125,17 +125,25 @@ var upgrade_button := Rect2(1000, 814, 270, 64) var sell_button := Rect2(1290, 814, 285, 64) var tutorial_skip_button := Rect2(365, 205, 145, 30) var reward_ad_button := Rect2(1070, 690, 300, 54) -var tower_info_rect := Rect2(1135, 105, 440, 104) +var tower_info_rect := Rect2(1275, 103, 300, 76) var endless_button := Rect2(545, 515, 510, 70) var finish_mission_button := Rect2(625, 610, 350, 62) var profile_button := Rect2(1280, 25, 270, 52) var leaderboard_button := Rect2(250, 705, 350, 60) var leaderboard_back_button := Rect2(55, 35, 185, 54) -var leaderboard_mission_button := Rect2(510, 155, 270, 54) -var leaderboard_endless_button := Rect2(820, 155, 270, 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) @@ -160,11 +168,16 @@ 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 @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 func _ready() -> void: @@ -175,6 +188,7 @@ func _ready() -> void: 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() @@ -435,6 +449,13 @@ func _unhandled_input(event: InputEvent) -> void: func _handle_pointer(position: Vector2, is_touch := false) -> void: 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() @@ -467,6 +488,18 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void: _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": @@ -612,6 +645,7 @@ func _select_map(index: int) -> void: 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]: @@ -632,6 +666,19 @@ func _configure_coop_arena() -> void: _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: @@ -812,6 +859,8 @@ func _delete_profile_account() -> void: func _open_leaderboard() -> void: game_mode = "leaderboard" leaderboard_endless = false + leaderboard_map = selected_map + leaderboard_difficulty = selected_difficulty _refresh_leaderboard() @@ -827,7 +876,7 @@ func _refresh_leaderboard() -> void: leaderboard_message = "GERGAMES-SERVER WIRD VERBUNDEN – BITTE GLEICH ERNEUT LADEN" queue_redraw() return - leaderboard_rows = await online_manager.load_leaderboard(selected_map, selected_difficulty, leaderboard_endless, 12) + 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() @@ -977,6 +1026,12 @@ 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) @@ -1407,10 +1462,10 @@ func _draw_world() -> void: 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.09)) - draw_arc(snapped_position, preview_range, 0, TAU, 96, Color(placement_color, 0.82), 3.0) - draw_rect(Rect2(snapped_position - Vector2(35, 35), Vector2(70, 70)), placement_color, true) - draw_rect(Rect2(snapped_position - Vector2(35, 35), Vector2(70, 70)), Color(placement_color, 0.95), false, 3.0) + 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] @@ -1472,8 +1527,8 @@ func _draw_route() -> void: func _draw_build_grid() -> void: - var line_color := Color(0.24, 0.82, 1.0, 0.22 if selected_tower_type >= 0 else 0.10) - var fill_color := Color(0.08, 0.55, 0.72, 0.08 if selected_tower_type >= 0 else 0.025) + 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) @@ -1587,18 +1642,18 @@ func _draw_tower_info_panel() -> void: 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.94), true) - draw_rect(tower_info_rect, Color("4a9ab2"), false, 2.0) + 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(14, 26), heading, HORIZONTAL_ALIGNMENT_LEFT, 412, 17, Color("a9efff")) - draw_string(font, tower_info_rect.position + Vector2(14, 52), "SCH %.0f | %.2f/S | RW %.0f | %s" % [_tower_damage(type, level), _tower_shots_per_second(type, level), _tower_range(type, level), data.damage_type], HORIZONTAL_ALIGNMENT_LEFT, 412, 13, Color.WHITE) + 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(14, 80), "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, 412, 13, Color("84ffca")) + 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(14, 80), "MAXIMALE AUSBAUSTUFE ERREICHT", HORIZONTAL_ALIGNMENT_LEFT, 412, 13, Color("84ffca")) + 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(14, 80), "Reichweite wird vor dem Bau angezeigt.", HORIZONTAL_ALIGNMENT_LEFT, 412, 13, Color("84ffca")) + 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: @@ -1638,7 +1693,10 @@ func _draw_menu() -> void: _draw_button(leaderboard_button, "BESTENLISTE", false, Color("24475b")) _draw_button(multiplayer_button, "MULTIPLAYER", false, Color("254f75")) draw_string(font, Vector2(0, 792), "BESTE WELLE %02d • KERNKRISTALLE %04d • KARTENBONUS %d" % [best_wave, core_crystals, int(_difficulty().crystals)], HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("86c8d8")) - draw_string(font, Vector2(0, 860), "VERSION 0.23 • GERGAMES-ONLINEKONTO • GLOBALE BESTENLISTEN", HORIZONTAL_ALIGNMENT_CENTER, 1600, 14, Color("688293")) + 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_leaderboard() -> void: @@ -1647,28 +1705,37 @@ func _draw_leaderboard() -> void: _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_string(font, Vector2(0, 142), "%s • %s" % [MAP_NAMES[selected_map], DIFFICULTY_DATA[selected_difficulty].name], HORIZONTAL_ALIGNMENT_CENTER, 1600, 16, Color("66dff3")) + _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(235, 235, 1130, 525) + 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(275, 277), "RANG", HORIZONTAL_ALIGNMENT_LEFT, 100, 14, Color("91a8bc")) - draw_string(font, Vector2(410, 277), "PILOT", HORIZONTAL_ALIGNMENT_LEFT, 480, 14, Color("91a8bc")) - draw_string(font, Vector2(950, 277), "WELLE", HORIZONTAL_ALIGNMENT_CENTER, 130, 14, Color("91a8bc")) - draw_string(font, Vector2(1125, 277), "PUNKTE", HORIZONTAL_ALIGNMENT_RIGHT, 190, 14, Color("91a8bc")) - draw_line(Vector2(270, 293), Vector2(1330, 293), Color("315c70"), 1.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 := 331.0 + index * 34.0 + var y := 370.0 + index * 32.0 if index % 2 == 0: - draw_rect(Rect2(260, y - 24, 1080, 31), Color(0.03, 0.09, 0.14, 0.64), true) + 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(280, y), "%02d" % int(row.get("rank", index + 1)), HORIZONTAL_ALIGNMENT_LEFT, 90, 16, Color("8ff4ff")) - draw_string(font, Vector2(410, y), str(row.get("username", "PILOT")).to_upper(), HORIZONTAL_ALIGNMENT_LEFT, 480, 16, owner_color) - draw_string(font, Vector2(950, y), "%02d" % int(row.get("wave", 0)), HORIZONTAL_ALIGNMENT_CENTER, 130, 16, Color("ffcf7a")) - draw_string(font, Vector2(1125, y), "%09d" % int(row.get("score", 0)), HORIZONTAL_ALIGNMENT_RIGHT, 190, 16, Color("8fffea")) + 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")) diff --git a/main.tscn b/main.tscn index cf477cf..0172153 100644 --- a/main.tscn +++ b/main.tscn @@ -1,10 +1,11 @@ -[gd_scene load_steps=6 format=3] +[gd_scene load_steps=7 format=3] [ext_resource path="res://main.gd" type="Script" id="1"] [ext_resource path="res://ads_manager.gd" type="Script" id="2"] [ext_resource path="res://account_manager.gd" type="Script" id="3"] [ext_resource path="res://multiplayer_manager.gd" type="Script" id="4"] [ext_resource path="res://online_manager.gd" type="Script" id="5"] +[ext_resource path="res://update_manager.gd" type="Script" id="6"] [node name="VoidBastion" type="Node2D"] script = ExtResource("1") @@ -20,3 +21,6 @@ script = ExtResource("4") [node name="OnlineManager" type="Node" parent="."] script = ExtResource("5") + +[node name="UpdateManager" type="Node" parent="."] +script = ExtResource("6") diff --git a/online_manager.gd b/online_manager.gd index d6b65b3..3006f2d 100644 --- a/online_manager.gd +++ b/online_manager.gd @@ -215,7 +215,10 @@ func load_leaderboard(map_index: int, difficulty_index: int, endless: bool, limi "username": str(current_names.get(record.owner_id, record.username)), "score": int(record.score), "wave": int(record.subscore), - "owner_id": record.owner_id + "owner_id": record.owner_id, + "map": map_index, + "difficulty": difficulty_index, + "mode": "endless" if endless else "mission" }) leaderboard_loaded.emit(leaderboard_id, rows) return rows diff --git a/tests/smoke_test.gd b/tests/smoke_test.gd index 9b96c79..7565f0a 100644 --- a/tests/smoke_test.gd +++ b/tests/smoke_test.gd @@ -79,6 +79,12 @@ func _run() -> void: game.call("_return_to_menu") game.call("_handle_pointer", Vector2(400, 735), false) assert(game.get("game_mode") == "leaderboard", "Die globale Bestenliste muss direkt aus dem Hauptmenü erreichbar sein.") + var leaderboard_map_before: int = game.get("leaderboard_map") + game.call("_handle_pointer", Vector2(750, 175), false) + assert(game.get("leaderboard_map") == wrapi(leaderboard_map_before + 1, 0, 3), "Die Rangliste muss direkt nach Karte filterbar sein.") + var leaderboard_difficulty_before: int = game.get("leaderboard_difficulty") + game.call("_handle_pointer", Vector2(1225, 175), false) + assert(game.get("leaderboard_difficulty") == wrapi(leaderboard_difficulty_before + 1, 0, 3), "Die Rangliste muss direkt nach Schwierigkeitsgrad filterbar sein.") game.call("_handle_pointer", Vector2(140, 60), false) assert(game.get("game_mode") == "menu", "Die Bestenliste benötigt einen funktionierenden Zurück-Button.") assert(not account_manager.player_id.is_empty(), "Beim ersten Start muss automatisch ein stabiles Gastprofil angelegt werden.") @@ -107,8 +113,19 @@ func _run() -> void: ads_manager.last_interstitial_time = 800 assert(not ads_manager.is_interstitial_due(1000), "Die 15-Minuten-Sperre zwischen Vollbildanzeigen muss greifen.") ads_manager.last_interstitial_time = 0 - assert(game.get("path_points").size() == 8, "Bastion Prime sollte aus sieben klaren Rasterstrecken bestehen.") + var update_manager = game.get_node("UpdateManager") + assert(update_manager.call("_is_newer_version", "0.1.0-alpha.3", "0.1.0-alpha.2"), "Eine neuere Alpha-Version muss als Update erkannt werden.") + assert(update_manager.call("_is_newer_version", "0.1.0", "0.1.0-alpha.2"), "Eine stabile Version muss neuer als ihre Alpha-Version sein.") + assert(not update_manager.call("_is_newer_version", "0.1.0-alpha.2", "0.1.0-alpha.2"), "Die installierte Version darf nicht als Update angeboten werden.") + var announced_update := {} + update_manager.update_available.connect(func(info: Dictionary): announced_update.merge(info, true)) + var fake_release_body := JSON.stringify([{"tag_name": "v0.1.0-alpha.3", "name": "Alpha.3", "draft": false, "assets": [{"name": "Void-Bastion-Setup-x64.exe", "browser_download_url": "https://git.dominicspringer.de/public-test.exe"}]}]).to_utf8_buffer() + update_manager.call("_on_request_completed", HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), fake_release_body) + assert(announced_update.get("version") == "0.1.0-alpha.3", "Die Update-Prüfung muss eine neuere öffentliche Version ankündigen.") + assert(str(announced_update.get("url", "")).begins_with("https://git.dominicspringer.de/"), "Der Update-Hinweis darf nur einen öffentlichen Download-Link öffnen.") + assert(game.get("path_points").size() == 9, "Bastion Prime sollte mit einer eigenen Einfahrt im Reaktor enden.") _assert_grid_aligned_path(game.get("path_points"), "Bastion Prime") + assert(game.call("_validate_route_ends_at_reactor", game.get("path_points"), 0), "Bastion Prime muss exakt im sichtbaren Reaktor enden.") assert(_path_length(game.get("path_points")) >= 4000.0, "Die Bastion-Route sollte den sichtbaren Bauraum ausnutzen.") assert(game.get("camera_offset") == Vector2.ZERO, "Die ganze Karte muss ohne Verschieben sichtbar bleiben.") assert(game.get("path_cells").size() > 45, "Straße, Gegnerlinie und Bausperre müssen dieselbe lange Rasterzellenkette verwenden.") @@ -121,8 +138,9 @@ func _run() -> void: assert(game.call("_is_valid_build_position", Vector2(200, 680)), "Außerhalb der Wegzellen soll jede geeignete Plattformzelle bebaubar sein.") game.call("_select_map", 1) assert(game.get("selected_map") == 1, "Die zweite Karte sollte auswählbar sein.") - assert(game.get("path_points").size() == 8, "Die Helios-Werft sollte aus sieben klaren Rasterstrecken bestehen.") + assert(game.get("path_points").size() == 8, "Die Helios-Werft sollte direkt im sichtbaren Reaktor enden.") _assert_grid_aligned_path(game.get("path_points"), "Helios-Werft") + assert(game.call("_validate_route_ends_at_reactor", game.get("path_points"), 1), "Die Helios-Route muss exakt im Reaktormittelpunkt enden.") assert(_path_length(game.get("path_points")) >= 4400.0, "Die Werft-Route sollte mehrere Feuerlinien bilden.") assert(not game.call("_is_valid_build_position", Vector2(600, 280)), "Auf der oberen Helios-Fahrbahn darf nicht gebaut werden.") assert(game.call("_is_valid_build_position", Vector2(600, 360)), "Auf Helios muss zwischen der oberen und mittleren Fahrbahn gebaut werden können.") @@ -136,8 +154,9 @@ func _run() -> void: assert(not game.call("_is_valid_build_position", Vector2(1480, 440)), "Auf dem Bastion-Reaktor darf nicht gebaut werden.") assert(not game.call("_is_valid_build_position", Vector2(40, 680)), "Im freien Weltraum darf kein Bauplatz liegen.") game.call("_select_map", 2) - assert(game.get("path_points").size() == 6, "Pyrax sollte aus fünf klaren Rasterstrecken bestehen.") + assert(game.get("path_points").size() == 8, "Pyrax sollte eine klar sichtbare Reaktoreinfahrt besitzen.") _assert_grid_aligned_path(game.get("path_points"), "Pyrax-Raffinerie") + assert(game.call("_validate_route_ends_at_reactor", game.get("path_points"), 2), "Die Pyrax-Route muss exakt im Reaktormittelpunkt enden.") assert(_path_length(game.get("path_points")) >= 2480.0, "Die Pyrax-Route sollte den Bauraum weiträumig erschließen.") assert(game.call("_is_in_reactor_zone", Vector2(1440, 360)), "Der Pyrax-Hauptreaktor muss als Sperrzone erkannt werden.") assert(game.call("_is_in_reactor_zone", Vector2(680, 360)), "Auch die offene Pyrax-Reaktorkammer muss gesperrt sein.") diff --git a/update_manager.gd b/update_manager.gd new file mode 100644 index 0000000..c4ef7c4 --- /dev/null +++ b/update_manager.gd @@ -0,0 +1,94 @@ +class_name UpdateManager +extends Node + +signal update_available(info: Dictionary) + +const RELEASES_API := "https://git.dominicspringer.de/api/v1/repos/Emporex/Void-Bastion-Releases/releases" +const PUBLIC_RELEASE_BASE := "https://git.dominicspringer.de/Emporex/Void-Bastion-Releases/releases/tag/" + +var _request: HTTPRequest + + +func _ready() -> void: + if DisplayServer.get_name() == "headless" or Engine.is_editor_hint(): + return + _request = HTTPRequest.new() + add_child(_request) + _request.request_completed.connect(_on_request_completed) + call_deferred("check_for_update") + + +func check_for_update() -> void: + if _request == null or _request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: + return + _request.request(RELEASES_API, ["Accept: application/json", "User-Agent: Void-Bastion/%s" % _current_version()]) + + +func _on_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + return + var releases = JSON.parse_string(body.get_string_from_utf8()) + if not releases is Array: + return + for release in releases: + if not release is Dictionary or bool(release.get("draft", false)): + continue + var version := str(release.get("tag_name", "")).trim_prefix("v") + if version.is_empty() or not _is_newer_version(version, _current_version()): + return + var download_url := PUBLIC_RELEASE_BASE + "v" + version + var wanted_extension := ".apk" if OS.get_name() == "Android" else ".exe" + for asset in release.get("assets", []): + if asset is Dictionary and str(asset.get("name", "")).to_lower().ends_with(wanted_extension): + download_url = str(asset.get("browser_download_url", download_url)) + break + update_available.emit({ + "version": version, + "name": str(release.get("name", version)), + "url": download_url + }) + return + + +func _current_version() -> String: + return str(ProjectSettings.get_setting("application/config/version", "0.0.0")) + + +func _is_newer_version(remote: String, local: String) -> bool: + var remote_parts := _version_parts(remote) + var local_parts := _version_parts(local) + for index in 3: + if remote_parts[index] != local_parts[index]: + return remote_parts[index] > local_parts[index] + var remote_suffix: String = remote_parts[3] + var local_suffix: String = local_parts[3] + if remote_suffix.is_empty(): + return not local_suffix.is_empty() + if local_suffix.is_empty(): + return false + return _prerelease_weight(remote_suffix) > _prerelease_weight(local_suffix) + + +func _version_parts(value: String) -> Array: + var normalized := value.trim_prefix("v").strip_edges().to_lower() + var sections := normalized.split("-", true, 1) + var numbers := sections[0].split(".") + return [ + int(numbers[0]) if numbers.size() > 0 else 0, + int(numbers[1]) if numbers.size() > 1 else 0, + int(numbers[2]) if numbers.size() > 2 else 0, + sections[1] if sections.size() > 1 else "" + ] + + +func _prerelease_weight(value: String) -> int: + var stage := 0 + if value.begins_with("beta"): + stage = 100000 + elif value.begins_with("rc"): + stage = 200000 + var sequence := 0 + for part in value.split("."): + if part.is_valid_int(): + sequence = int(part) + return stage + sequence