extends "res://core/ui/panels/editor_panel.gd" ## Tab „Server“ (Etappe 3 „Server-Verwaltung“): Client der Match-Server-Admin-API. ## Mehrere Server speichern, verbinden, Status, Einstellungen, Spieler, Maps, ## Rotation, Logs und „Aktuelle Map auf den Server pushen“ – alles per Klick. ## Logik: core/server/ (AdminClient, ServerStore, AdminTexte, MapPush). var store: ServerStore var store_path := ServerStore.DEFAULT_PATH # Tests lenken um var client: AdminClient var current_id := "" var last_notice := "" # letzte Meldung (Tests) var connected := false var restart_wait_s := 120.0 # so lange wartet „Server neu starten“ (Tests verkürzen) var restart_poll_s := 2.0 var _sel: OptionButton var _conn_label: Label var _notice: Label var _status_label: Label var _live_box: VBoxContainer # alles, was einen verbundenen Server braucht var _push_name: LineEdit var _push_rot: CheckBox var _push_result: Label var _settings_box: VBoxContainer var _settings_editors: Dictionary = {} # key -> Control var _settings_loaded: Dictionary = {} var _settings_spec: Dictionary = {} var _settings_result: Label var _players_box: VBoxContainer var _bans_box: VBoxContainer var _ban_minutes: OptionButton var _maps_box: VBoxContainer var _rotation_checks: Dictionary = {} # map id -> CheckBox var _log_filter: LineEdit var _log_text: TextEdit var _confirm: ConfirmationDialog var _confirm_action: Callable var _edit_dialog: ConfirmationDialog var _edit_fields: Dictionary = {} var _edit_id := "" const BAN_DAUER := [[10, "10 Minuten"], [60, "1 Stunde"], [1440, "1 Tag"], [10080, "1 Woche"], [0, "Dauerhaft"]] static func spec() -> Dictionary: return {"titel": "Server", "gruppe": "rechts", "order": 50} func panel_title() -> String: return "Server" func build() -> void: store = ServerStore.new(store_path) store.load_store() client = AdminClient.new() client.name = "AdminClient" add_child(client) add_child(_header_label("Server verwalten")) add_child(_muted_label("Verbinde den Editor mit deinem Match-Server (Admin-API), um Maps hochzuladen und den Server zu steuern.")) var hint := _muted_label(AdminTexte.TUNNEL_HINWEIS) hint.visible = false var hint_btn := Button.new() hint_btn.text = "❓ Wie erreiche ich meinen Server sicher?" hint_btn.flat = true hint_btn.pressed.connect(func() -> void: hint.visible = not hint.visible) add_child(hint_btn) add_child(hint) _sel = OptionButton.new() _sel.custom_minimum_size = UIScale.v2(0, 36) _sel.item_selected.connect(func(_i: int) -> void: _on_selected()) add_child(_sel) var row := HBoxContainer.new() add_child(row) row.add_child(_btn("➕ Neu", _on_add)) row.add_child(_btn("✏ Bearbeiten", _on_edit)) row.add_child(_btn("🗑 Entfernen", _on_remove)) var conn_row := HBoxContainer.new() add_child(conn_row) conn_row.add_child(_btn("🔌 Verbinden", func() -> void: connect_selected())) _conn_label = Label.new() _conn_label.text = "Nicht verbunden" _conn_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _conn_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL conn_row.add_child(_conn_label) _notice = _muted_label("") add_child(_notice) _live_box = VBoxContainer.new() _live_box.add_theme_constant_override("separation", UIScale.px(8)) _live_box.visible = false add_child(_live_box) _build_live() _build_dialogs() _refresh_selector() wrap_in_scroll() func _btn(text: String, cb: Callable) -> Button: var b := Button.new() b.text = text b.custom_minimum_size = UIScale.v2(0, 36) b.pressed.connect(cb) return b func _section(title: String) -> void: _live_box.add_child(_separator()) _live_box.add_child(_header_label(title)) func _build_live() -> void: _section("Übersicht") _status_label = Label.new() _status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _live_box.add_child(_status_label) _live_box.add_child(_btn("🔄 Alles aktualisieren", func() -> void: refresh_all())) _section("Map auf Server pushen") _live_box.add_child(_muted_label("Prüft die Map im Editor und lädt sie hoch. Der Server prüft sie noch einmal.")) _push_name = LineEdit.new() _push_name.placeholder_text = "Dateiname (leer = Map-Name)" _live_box.add_child(_push_name) _push_rot = CheckBox.new() _push_rot.text = "In die Rotation aufnehmen" _live_box.add_child(_push_rot) var pb := _btn("⬆ Aktuelle Map hochladen", func() -> void: push_current_map()) pb.custom_minimum_size = UIScale.v2(0, 44) _live_box.add_child(pb) _push_result = Label.new() _push_result.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _live_box.add_child(_push_result) _section("Einstellungen") _settings_box = VBoxContainer.new() _live_box.add_child(_settings_box) var srow := HBoxContainer.new() _live_box.add_child(srow) srow.add_child(_btn("💾 Speichern", func() -> void: save_settings())) srow.add_child(_btn("↩ Zurücksetzen", func() -> void: _fill_settings_editors())) _settings_result = _muted_label("") _live_box.add_child(_settings_result) _section("Spieler") _players_box = VBoxContainer.new() _live_box.add_child(_players_box) var brow := HBoxContainer.new() _live_box.add_child(brow) brow.add_child(_muted_label("Sperrdauer:")) _ban_minutes = OptionButton.new() for d in BAN_DAUER: _ban_minutes.add_item(d[1]) _ban_minutes.select(1) brow.add_child(_ban_minutes) _live_box.add_child(_muted_label("Gesperrt:")) _bans_box = VBoxContainer.new() _live_box.add_child(_bans_box) _section("Match & Maps") _live_box.add_child(_btn("🔁 Match neu starten", func() -> void: _ask("Match auf dem Server neu starten? Alle Spieler beginnen die Runde von vorn.", _do_restart))) _maps_box = VBoxContainer.new() _live_box.add_child(_maps_box) _live_box.add_child(_btn("💾 Rotation speichern", func() -> void: save_rotation())) _section("Server") _live_box.add_child(_btn("⏻ Server neu starten", func() -> void: _ask("Server komplett neu starten? Alle Spieler werden getrennt und die laufende Runde endet.", func() -> void: await restart_server()))) _section("Server-Log") _log_filter = LineEdit.new() _log_filter.placeholder_text = "Filter (z. B. Admin, Join, FEHLER)" _live_box.add_child(_log_filter) _live_box.add_child(_btn("📜 Log laden", func() -> void: load_logs())) _log_text = TextEdit.new() _log_text.editable = false _log_text.custom_minimum_size = UIScale.v2(0, 220) _log_text.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY _live_box.add_child(_log_text) func _build_dialogs() -> void: _confirm = ConfirmationDialog.new() _confirm.ok_button_text = "Ja" _confirm.cancel_button_text = "Abbrechen" _confirm.confirmed.connect(func() -> void: if _confirm_action.is_valid(): _confirm_action.call()) add_child(_confirm) _edit_dialog = ConfirmationDialog.new() _edit_dialog.title = "Server" _edit_dialog.ok_button_text = "Speichern" _edit_dialog.cancel_button_text = "Abbrechen" var box := VBoxContainer.new() _edit_dialog.add_child(box) for f in [["name", "Name (frei wählbar)", "Mein Server"], ["host", "Adresse (IP oder Name)", "127.0.0.1"], ["port", "Port der Admin-API", str(ServerStore.DEFAULT_PORT)], ["token", "Admin-Token", ""]]: box.add_child(_field_label(f[1])) var le := LineEdit.new() le.placeholder_text = f[2] le.custom_minimum_size = UIScale.v2(320, 32) if f[0] == "token": le.secret = true box.add_child(le) _edit_fields[f[0]] = le var tls := CheckBox.new() tls.text = "Sichere Verbindung (https) – nur mit Reverse-Proxy" box.add_child(tls) _edit_fields["tls"] = tls var show := CheckBox.new() show.text = "Token anzeigen" show.toggled.connect(func(on: bool) -> void: (_edit_fields["token"] as LineEdit).secret = not on) box.add_child(show) var err := _muted_label("") box.add_child(err) _edit_fields["_err"] = err box.add_child(_btn("🔌 Verbindung testen", func() -> void: _test_dialog_entry())) _edit_dialog.confirmed.connect(_on_edit_confirmed) add_child(_edit_dialog) # ------------------------------------------------------------------ Liste -- func _refresh_selector(select_id: String = "") -> void: _sel.clear() for e in store.servers: _sel.add_item("%s (%s:%d)" % [e["name"], e["host"], e["port"]]) _sel.set_item_metadata(_sel.item_count - 1, e["id"]) if _sel.item_count == 0: _sel.add_item("– noch kein Server gespeichert –") _sel.disabled = true current_id = "" return _sel.disabled = false var idx := 0 for i in _sel.item_count: if str(_sel.get_item_metadata(i)) == (select_id if select_id != "" else current_id): idx = i _sel.select(idx) _on_selected() func _on_selected() -> void: if _sel.item_count == 0 or _sel.disabled: return var id := str(_sel.get_item_metadata(_sel.selected)) if id != current_id: _set_connected(false, "Nicht verbunden") current_id = id func _on_add() -> void: _open_edit({}) func _on_edit() -> void: if current_id != "": _open_edit(store.get_entry(current_id)) func _open_edit(e: Dictionary) -> void: _edit_id = str(e.get("id", "")) _edit_dialog.title = "Server bearbeiten" if _edit_id != "" else "Server hinzufügen" (_edit_fields["name"] as LineEdit).text = str(e.get("name", "")) (_edit_fields["host"] as LineEdit).text = str(e.get("host", "")) (_edit_fields["port"] as LineEdit).text = str(e.get("port", ServerStore.DEFAULT_PORT)) (_edit_fields["token"] as LineEdit).text = str(e.get("token", "")) (_edit_fields["tls"] as CheckBox).button_pressed = bool(e.get("tls", false)) (_edit_fields["_err"] as Label).text = "" _edit_dialog.popup_centered(UIScale.v2i(380, 420)) func _entry_from_dialog() -> Dictionary: return { "id": _edit_id, "name": (_edit_fields["name"] as LineEdit).text, "host": (_edit_fields["host"] as LineEdit).text.strip_edges(), "port": int((_edit_fields["port"] as LineEdit).text), "token": (_edit_fields["token"] as LineEdit).text, "tls": (_edit_fields["tls"] as CheckBox).button_pressed} func _on_edit_confirmed() -> void: var id := store.upsert(_entry_from_dialog()) if id == "": _notify(store.last_error) return _refresh_selector(id) func _test_dialog_entry() -> void: var e := _entry_from_dialog() var err := AdminClient.check_address(str(e["host"]), int(e["port"])) var lbl: Label = _edit_fields["_err"] if err != "": lbl.text = err return lbl.text = "Teste …" var probe := AdminClient.new() add_child(probe) probe.use(e) var res: Dictionary = await probe.call_api("GET", "/ping") if bool(res["ok"]): var auth: Dictionary = await probe.call_api("GET", "/status") lbl.text = "✅ Server gefunden. " + ("Token passt." if bool(auth["ok"]) else AdminTexte.erklaere(auth)) else: lbl.text = "❌ " + AdminTexte.erklaere(res) probe.queue_free() func _on_remove() -> void: if current_id == "": return var e := store.public_view(current_id) _ask("„%s“ aus der Liste entfernen? (Der Server selbst bleibt unverändert.)" % str(e["name"]), func() -> void: store.remove(current_id) current_id = "" _set_connected(false, "Nicht verbunden") _refresh_selector()) # ---------------------------------------------------------------- Verbinden -- func connect_selected() -> bool: if current_id == "": _notify("Bitte erst einen Server hinzufügen (➕ Neu).") return false client.use(store.get_entry(current_id)) _conn_label.text = "Verbinde …" var ping: Dictionary = await client.call_api("GET", "/ping") if not bool(ping["ok"]): _set_connected(false, "❌ " + AdminTexte.erklaere(ping)) return false var st: Dictionary = await client.call_api("GET", "/status") if not bool(st["ok"]): _set_connected(false, "❌ " + AdminTexte.erklaere(st)) return false _set_connected(true, "✅ Verbunden") await refresh_all() return true func _set_connected(on: bool, text: String) -> void: connected = on _conn_label.text = text _live_box.visible = on ## Ruft die API auf und meldet Fehler verständlich. null = Fehler (schon gemeldet). func _api(method: String, path: String, body: Variant = null, query: Dictionary = {}) -> Variant: var res: Dictionary = await client.call_api(method, path, body, query) if bool(res["ok"]): return res["body"] var text := AdminTexte.erklaere(res) if int(res.get("status", 0)) in [0, 401, 429]: _set_connected(false, "❌ " + text.split("\n")[0]) _notify(text) return null func _notify(text: String) -> void: last_notice = text _notice.text = text func refresh_all() -> void: await refresh_status() await refresh_settings() await refresh_players() await refresh_maps() # ------------------------------------------------------------------ Status -- func refresh_status() -> void: var s: Variant = await _api("GET", "/status") if s == null: return var p: Dictionary = s["players"] var h := int(s["uptime_s"]) / 3600 var m := (int(s["uptime_s"]) % 3600) / 60 _status_label.text = "%s\nMap: %s · Modus: %s · Phase: %s\nSpieler: %d von %d (+ %d Bots) · Restzeit %d s\nLäuft seit %d h %d min · %s · Passwort: %s · Master: %s" % [ s["server_name"], s["map"]["name"], str(s["mode"]).to_upper(), s["phase"], p["humans"], p["max"], p["bots"], int(s["time_remaining_s"]), h, m, "öffentlich" if s["public"] else "privat", "ja" if s["password_set"] else "nein", s["master"]] # ------------------------------------------------------------ Einstellungen -- func refresh_settings() -> void: var r: Variant = await _api("GET", "/settings") if r == null: return _settings_spec = r["spec"] _settings_loaded = r["settings"] for c in _settings_box.get_children(): c.queue_free() _settings_editors.clear() for key in _settings_spec.keys(): var sp: Dictionary = _settings_spec[key] var lbl := _field_label("%s (%s)" % [sp["label"], AdminTexte.wirkung(str(sp["effect"]))]) _settings_box.add_child(lbl) var ed: Control match str(sp["type"]): "bool": ed = CheckBox.new() "int", "float": var sb := SpinBox.new() sb.min_value = float(sp["min"]) sb.max_value = float(sp["max"]) sb.step = 1.0 if str(sp["type"]) == "int" else 0.5 ed = sb "enum": var ob := OptionButton.new() for v in sp["values"]: ob.add_item(str(v)) ed = ob _: var le := LineEdit.new() le.max_length = int(sp.get("max_len", 64)) if key == "password": le.secret = true le.placeholder_text = "(unverändert)" ed = le _settings_box.add_child(ed) _settings_editors[key] = ed _fill_settings_editors() func _fill_settings_editors() -> void: for key in _settings_editors.keys(): var ed: Control = _settings_editors[key] var sp: Dictionary = _settings_spec[key] if key == "password": (ed as LineEdit).text = "" (ed as LineEdit).placeholder_text = "(gesetzt – leer lassen = unverändert)" if bool(_settings_loaded.get("password_set", false)) else "(kein Passwort)" continue var v: Variant = _settings_loaded.get(key) match str(sp["type"]): "bool": (ed as CheckBox).button_pressed = bool(v) "int", "float": (ed as SpinBox).value = float(v) "enum": (ed as OptionButton).select(maxi(0, (sp["values"] as Array).find(v))) _: (ed as LineEdit).text = str(v) _settings_result.text = "" ## Nur geänderte Werte (Passwort nur, wenn etwas eingetippt wurde). func changed_settings() -> Dictionary: var out := {} for key in _settings_editors.keys(): var ed: Control = _settings_editors[key] var sp: Dictionary = _settings_spec[key] var v: Variant match str(sp["type"]): "bool": v = (ed as CheckBox).button_pressed "int": v = int((ed as SpinBox).value) "float": v = (ed as SpinBox).value "enum": v = (sp["values"] as Array)[(ed as OptionButton).selected] _: v = (ed as LineEdit).text if key == "password": if str(v) != "": out[key] = v continue if v != _settings_loaded.get(key): out[key] = v return out func save_settings() -> void: var ch := changed_settings() if ch.is_empty(): _settings_result.text = "Nichts geändert." return var r: Variant = await _api("PATCH", "/settings", ch) if r == null: return var lines: PackedStringArray = [] if not (r["applied_now"] as Array).is_empty(): lines.append("✅ Sofort aktiv: " + ", ".join(_labels(r["applied_now"]))) if not (r["applies_next_map"] as Array).is_empty(): lines.append("⏭ Ab der nächsten Map: " + ", ".join(_labels(r["applies_next_map"]))) if not (r["needs_restart"] as Array).is_empty(): lines.append("🔁 Erst nach Server-Neustart: " + ", ".join(_labels(r["needs_restart"]))) for w in r.get("warnings", []): lines.append("⚠ " + str(w)) _settings_result.text = "\n".join(lines) last_notice = _settings_result.text _settings_loaded = r["settings"] _fill_settings_editors() _settings_result.text = "\n".join(lines) func _labels(keys: Array) -> Array: return keys.map(func(k: Variant) -> String: return str((_settings_spec.get(k, {}) as Dictionary).get("label", k))) # ----------------------------------------------------------------- Spieler -- func refresh_players() -> void: var r: Variant = await _api("GET", "/players") var b: Variant = await _api("GET", "/bans") if r == null or b == null: return for c in _players_box.get_children(): c.queue_free() if (r["players"] as Array).is_empty(): _players_box.add_child(_muted_label("Gerade ist niemand auf dem Server.")) for p in r["players"]: var row := HBoxContainer.new() var lbl := Label.new() lbl.text = "%s (Team %s · %d/%d · %d ms)" % [p["name"], "Blau" if int(p["team"]) == 0 else "Rot", p["kills"], p["deaths"], p["ping_ms"]] lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL lbl.clip_text = true row.add_child(lbl) var pid := int(p["peer_id"]) var pname := str(p["name"]) row.add_child(_btn("Kick", func() -> void: _ask("„%s“ vom Server werfen?" % pname, func() -> void: await kick(pid)))) row.add_child(_btn("Sperren", func() -> void: _ask("„%s“ sperren (%s)?" % [pname, BAN_DAUER[_ban_minutes.selected][1]], func() -> void: await ban(pid)))) _players_box.add_child(row) for c in _bans_box.get_children(): c.queue_free() if (b["bans"] as Array).is_empty(): _bans_box.add_child(_muted_label("Niemand gesperrt.")) for ban_e in b["bans"]: var row2 := HBoxContainer.new() var l2 := Label.new() var until := int(ban_e.get("until", 0)) l2.text = "%s – %s" % [str(ban_e.get("name", "?")), "dauerhaft" if until == 0 else "bis " + Time.get_datetime_string_from_unix_time(until, true)] l2.size_flags_horizontal = Control.SIZE_EXPAND_FILL l2.clip_text = true row2.add_child(l2) var bid := str(ban_e["id"]) row2.add_child(_btn("Aufheben", func() -> void: await unban(bid))) _bans_box.add_child(row2) func kick(peer_id: int) -> void: if await _api("POST", "/players/%d/kick" % peer_id, {}) != null: _notify("Spieler wurde vom Server geworfen.") await refresh_players() func ban(peer_id: int, minutes: int = -1) -> void: if minutes < 0: minutes = int(BAN_DAUER[_ban_minutes.selected][0]) if await _api("POST", "/players/%d/ban" % peer_id, {"minutes": minutes}) != null: _notify("Spieler gesperrt (%s)." % AdminTexte.sperrdauer_text(minutes)) await refresh_players() func unban(ban_id: String) -> void: if await _api("DELETE", "/bans/" + ban_id) != null: _notify("Sperre aufgehoben.") await refresh_players() # -------------------------------------------------------------- Match, Maps -- func _do_restart() -> void: if await _api("POST", "/match/restart", {}) != null: _notify("Match wird neu gestartet.") await refresh_status() ## Ganzen Server neu starten und warten, bis er wieder antwortet; dann neu verbinden. func restart_server() -> bool: var id := current_id if await _api("POST", "/restart", {}) == null: return false _set_connected(false, "Server startet neu…") _notify("Server startet neu…") var res: Dictionary = await _wait_for_new_server(id) if current_id != id: return false # inzwischen anderen Server gewählt if not bool(res["ok"]): _set_connected(false, "❌ " + str(res["text"])) _notify(str(res["text"])) return false var ok: bool = await connect_selected() if ok: _notify("✅ Server wurde neu gestartet.") return ok ## Der alte Prozess antwortet noch kurz: Erst eine Laufzeit, die kleiner ist als die ## Wartezeit bisher, beweist den neuen Prozess. func _wait_for_new_server(id: String) -> Dictionary: var start := Time.get_ticks_msec() while current_id == id: var waited := (Time.get_ticks_msec() - start) / 1000.0 if waited >= restart_wait_s: return {"ok": false, "text": AdminTexte.neustart_zeitueberschreitung(int(restart_wait_s))} var st: Dictionary = await client.call_api("GET", "/status") if bool(st["ok"]): if float(st["body"].get("uptime_s", INF)) <= (Time.get_ticks_msec() - start) / 1000.0: return {"ok": true, "text": ""} elif int(st.get("status", 0)) in [401, 429]: return {"ok": false, "text": AdminTexte.erklaere(st)} await get_tree().create_timer(restart_poll_s).timeout return {"ok": false, "text": ""} func refresh_maps() -> void: var r: Variant = await _api("GET", "/maps") if r == null: return for c in _maps_box.get_children(): c.queue_free() _rotation_checks.clear() for m in r["maps"]: var row := HBoxContainer.new() var cb := CheckBox.new() cb.button_pressed = bool(m["in_rotation"]) cb.tooltip_text = "In der Rotation" row.add_child(cb) _rotation_checks[str(m["id"])] = cb var lbl := Label.new() lbl.text = "%s%s%s" % [m.get("name", m["file"]), " ▶ läuft" if m["active"] else "", " (eingebaut)" if m["source"] == "builtin" else ""] lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL lbl.clip_text = true row.add_child(lbl) var mid := str(m["id"]) var mname := str(m.get("name", m["file"])) row.add_child(_btn("Laden", func() -> void: _ask("„%s“ jetzt auf dem Server laden? Die laufende Runde endet." % mname, func() -> void: await load_map(mid)))) if m["source"] == "upload": var slug := mid.get_file().get_basename() row.add_child(_btn("🗑", func() -> void: _ask("„%s“ vom Server LÖSCHEN? Das kann nicht rückgängig gemacht werden." % mname, func() -> void: await delete_map(slug)))) _maps_box.add_child(row) func load_map(map_id: String) -> void: if await _api("POST", "/match/map", {"map": map_id}) != null: _notify("Map wird geladen.") await refresh_status() func delete_map(slug: String) -> void: if await _api("DELETE", "/maps/" + slug) != null: _notify("Map gelöscht.") await refresh_maps() func save_rotation() -> void: var ids: Array = [] for id in _rotation_checks.keys(): if (_rotation_checks[id] as CheckBox).button_pressed: ids.append(id) if ids.is_empty(): _notify("Die Rotation braucht mindestens eine Map.") return if await _api("PUT", "/rotation", {"maps": ids}) != null: _notify("Rotation gespeichert (%d Maps)." % ids.size()) await refresh_maps() # -------------------------------------------------------------------- Push -- func push_current_map(overwrite: bool = false) -> Dictionary: _push_result.text = "Prüfe und lade hoch …" var res: Dictionary = await MapPush.push(client, main.doc, { "name": _push_name.text, "overwrite": overwrite, "add_to_rotation": _push_rot.button_pressed}) _push_result.text = ("✅ " if bool(res["ok"]) else "❌ ") + str(res["text"]) last_notice = _push_result.text if bool(res["exists"]): _ask("Auf dem Server gibt es schon eine Map mit diesem Namen. Ersetzen?", func() -> void: await push_current_map(true)) elif bool(res["ok"]): await refresh_maps() return res # -------------------------------------------------------------------- Logs -- func load_logs() -> void: var q := {"lines": 300} if _log_filter.text.strip_edges() != "": q["filter"] = _log_filter.text.strip_edges() var r: Variant = await _api("GET", "/logs", null, q) if r == null: return _log_text.text = "\n".join(r["lines"]) _log_text.scroll_vertical = _log_text.get_line_count() func _ask(text: String, action: Callable) -> void: _confirm.dialog_text = text _confirm_action = action _confirm.popup_centered(UIScale.v2i(380, 140))