From c8a41c07947c948f7f5c1a983db0186a56891a0c Mon Sep 17 00:00:00 2001 From: "Hermes (scopedvr-dev)" Date: Mon, 19 Oct 2026 19:21:45 +0000 Subject: [PATCH] =?UTF-8?q?UI-Gr=C3=B6=C3=9Fe:=20zentrale=20Skalierung=20K?= =?UTF-8?q?lein/Mittel/Gro=C3=9F=20(t=5F297221b5)=20=E2=80=94=20Top-Bar-Me?= =?UTF-8?q?n=C3=BC,=20sofort=20wirksam=20(Rebuild),=20gespeichert=20in=20u?= =?UTF-8?q?ser://scopedvr=5Feditor.cfg=20[ui];=20Fenster=20startet=20maxim?= =?UTF-8?q?iert=20+=20merkt=20Lage;=20Panel-Min-Ma=C3=9Fe/Schrift=20an=20e?= =?UTF-8?q?inen=20Faktor=20gekoppelt;=20Layout-=C3=9Cberlauf-Fixes=20(Hilf?= =?UTF-8?q?e-Hinweis=20Umbruch+Grow-up,=20Statuszeile/Top-Bar-Ellipsis,=20?= =?UTF-8?q?linkes=20Panel=20scrollbar)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ui/main.gd | 397 ++++++++++++++++++++++++++++-------- src/ui/new_map_dialog.gd | 12 +- src/ui/resize_map_dialog.gd | 14 +- src/ui/ui_scale.gd | 120 +++++++++++ src/ui/ui_scale.gd.uid | 1 + tests/test_ui_scale.gd | 219 ++++++++++++++++++++ tests/test_ui_scale.gd.uid | 1 + 7 files changed, 666 insertions(+), 98 deletions(-) create mode 100644 src/ui/ui_scale.gd create mode 100644 src/ui/ui_scale.gd.uid create mode 100644 tests/test_ui_scale.gd create mode 100644 tests/test_ui_scale.gd.uid diff --git a/src/ui/main.gd b/src/ui/main.gd index 156e667..7274043 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -23,6 +23,8 @@ const ShortcutPolicy := preload("res://src/editor/shortcut_policy.gd") const MoveMath := preload("res://src/editor/move_math.gd") # Kurzhilfe-Overlay Karte 3/3 (t_11b5c25b): Gesamte Tastenliste als BBCode. const HelpText := preload("res://src/ui/help_text.gd") +# Zentrale UI-Skalierung (Karte t_297221b5): EIN Faktor für Schrift/Panels/Zeilen. +const UIScale := preload("res://src/ui/ui_scale.gd") var manifest: Dictionary = {} var doc: Dictionary = {} # das Map-Dictionary (1:1 svmap-Format) @@ -140,6 +142,7 @@ var help_overlay: PanelContainer # H-Kurzhilfe (nicht-blockierend) var help_overlay_title: Label var help_overlay_text: RichTextLabel var tool_label: Label # permanenter Werkzeugmodus + Flug-Hint +var size_menu: MenuButton # „UI-Größe“ (Karte t_297221b5): Klein/Mittel/Groß # Editor-Selbstupdate (Karte t_d95f087a): Start-Check + Banner + „Jetzt updaten“ var update_banner: PanelContainer # Leiste „🔄 Update verfügbar …“ (versteckt) @@ -170,6 +173,11 @@ func _ready() -> void: manifest = _load_manifest() _build_ui() _update_tool_label() + # Fenster-Zustand (Karte t_297221b5): Default MAXIMIERT (kein Vollbild); + # wer auf Fenstergröße umschaltet, bekommt die gemerkte Lage wieder. + # Close-Request selbst behandeln, damit die Lage VOR dem Quit gemerkt wird. + get_tree().set_auto_accept_quit(false) + _apply_window_state() _start_with_new_map() _maybe_start_demo_mode() # Selbst-Update (Karte t_d95f087a): STILLER Start-Check im Hintergrund — @@ -190,18 +198,18 @@ func _load_manifest() -> Dictionary: func _build_ui() -> void: var theme := Theme.new() - theme.default_font_size = 16 + theme.default_font_size = UIScale.font_size() self.theme = theme var vbox := VBoxContainer.new() vbox.set_anchors_preset(Control.PRESET_FULL_RECT) - vbox.add_theme_constant_override("separation", 4) + vbox.add_theme_constant_override("separation", UIScale.px(4)) add_child(vbox) # --- Top-Bar: Menü + Rückgängig/Wiederholen --- var top := PanelContainer.new() var top_h := HBoxContainer.new() - top_h.add_theme_constant_override("separation", 10) + top_h.add_theme_constant_override("separation", UIScale.px(10)) top.add_child(top_h) vbox.add_child(top) _update_banner_build(vbox) @@ -209,7 +217,7 @@ func _build_ui() -> void: var file_menu := MenuButton.new() file_menu.text = "Datei" file_menu.flat = false - file_menu.custom_minimum_size = Vector2(90, 40) + file_menu.custom_minimum_size = UIScale.v2(90, 40) var popup := file_menu.get_popup() popup.add_item("Neue Map…", 0) popup.add_item("Öffnen…", 1) @@ -227,14 +235,14 @@ func _build_ui() -> void: undo_btn = Button.new() undo_btn.text = "↩ Rückgängig" - undo_btn.custom_minimum_size = Vector2(140, 40) + undo_btn.custom_minimum_size = UIScale.v2(140, 40) undo_btn.tooltip_text = "Letzte Aktion zurücknehmen (Strg+Z)" undo_btn.pressed.connect(_do_undo) top_h.add_child(undo_btn) redo_btn = Button.new() redo_btn.text = "↪ Wiederholen" - redo_btn.custom_minimum_size = Vector2(150, 40) + redo_btn.custom_minimum_size = UIScale.v2(150, 40) redo_btn.tooltip_text = "Zurückgenommene Aktion wiederherstellen (Strg+Y)" redo_btn.pressed.connect(_do_redo) top_h.add_child(redo_btn) @@ -246,7 +254,7 @@ func _build_ui() -> void: grid_btn.text = "⊞ Gitter" grid_btn.toggle_mode = true grid_btn.button_pressed = false - grid_btn.custom_minimum_size = Vector2(120, 40) + grid_btn.custom_minimum_size = UIScale.v2(120, 40) grid_btn.tooltip_text = "Maßstabsgitter (100-m-Raster) im Viewport ein-/ausblenden. Über sichtbarem Terrain ist es standardmäßig aus." grid_btn.toggled.connect(_on_grid_toggled) top_h.add_child(grid_btn) @@ -259,7 +267,7 @@ func _build_ui() -> void: contours_btn.text = "≈ Höhenlinien" contours_btn.toggle_mode = true contours_btn.button_pressed = false - contours_btn.custom_minimum_size = Vector2(150, 40) + contours_btn.custom_minimum_size = UIScale.v2(150, 40) contours_btn.tooltip_text = "Höhenlinien (1 m, alle 5 m kräftiger) FIXIERT an/aus. Ohne Fixierung erscheinen sie nur im Terrain-Werkzeug (F1). Taste L schaltet ebenfalls. Bei flachem Terrain (< 2 m) sind sie automatisch aus." contours_btn.toggled.connect(_on_contours_toggled) top_h.add_child(contours_btn) @@ -267,7 +275,7 @@ func _build_ui() -> void: # Map-Fertig-Check (Karte t_c4162c5d): Klartext-Liste VOR dem Export. check_btn = Button.new() check_btn.text = "✅ Karte prüfen" - check_btn.custom_minimum_size = Vector2(170, 40) + check_btn.custom_minimum_size = UIScale.v2(170, 40) check_btn.tooltip_text = "Prüft, ob die Map bereit für den Export ist — sagen wir VORHER, was fehlt" check_btn.pressed.connect(_on_check_map) top_h.add_child(check_btn) @@ -275,14 +283,38 @@ func _build_ui() -> void: # Karte testen (Karte t_b84af40f): Map SOFORT als Spiel zum Durchlaufen. test_btn = Button.new() test_btn.text = "▶ Karte testen" - test_btn.custom_minimum_size = Vector2(190, 40) + test_btn.custom_minimum_size = UIScale.v2(190, 40) test_btn.tooltip_text = "Startet die Map SOFORT als Spiel zum Durchlaufen — eigenes Fenster, der Editor bleibt offen. ESC im Spiel = zurück zum Editor." test_btn.pressed.connect(_on_test_run) top_h.add_child(test_btn) + # UI-Größe (Karte t_297221b5): Klein/Mittel/Groß — Sly stellt das selbst um, + # es wirkt SOFORT (kompletter UI-Neuaufbau mit dem neuen Faktor) und bleibt + # in user://scopedvr_editor.cfg gespeichert. + size_menu = MenuButton.new() + size_menu.text = "UI-Größe" + size_menu.flat = false + size_menu.custom_minimum_size = UIScale.v2(120, 40) + size_menu.tooltip_text = "Größe der Oberfläche: Klein (wie bisher), Mittel (1,25-fach), Groß (1,5-fach). Wirkt sofort und bleibt gespeichert." + var size_popup := size_menu.get_popup() + for i in UIScale.step_count(): + size_popup.add_check_item(UIScale.step_label(i), i) + _sync_size_menu() + size_popup.index_pressed.connect(_on_ui_size_selected) + top_h.add_child(size_menu) + var title_label := Label.new() title_label.text = " %s" % APP_TITLE - title_label.add_theme_font_size_override("font_size", 20) + title_label.add_theme_font_size_override("font_size", UIScale.f(20)) + # Karte t_297221b5: bei großer Stufe darf der Titel EGALISIEREN statt die + # Top-Bar aus dem Fenster zu drücken (Mindestbreite der Schrift skaliert mit). + # Mindestbreite, damit er nicht KOMPLETT verschwindet (Ellipsis macht "Scop…"). + # Expand: bei Platz bekommt er seine VOLLSTÄNDIGE natürliche Breite (wie + # Vorher bei „Klein“), erst bei knappem Fenster egalisiert er mit "…". + title_label.custom_minimum_size = UIScale.v2(180, 0) + title_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + title_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + title_label.clip_text = true top_h.add_child(title_label) var spacer := Control.new() @@ -292,13 +324,19 @@ func _build_ui() -> void: file_label = Label.new() file_label.text = "" file_label.modulate = Color(1, 1, 1, 0.6) + # Karte t_297221b5: Map-Pfad darf bei großer Stufe egalisiert werden + # (Voller Name im Tooltip, sonst drückt die Zeile die Top-Bar aus dem Fenster). + # Expand: bekommt zusammen mit dem Spacer seinen Anteil, egalisiert mit "…". + file_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + file_label.clip_text = true + file_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL top_h.add_child(file_label) # --- Hauptbereich --- var split := HSplitContainer.new() split.size_flags_vertical = Control.SIZE_EXPAND_FILL split.size_flags_horizontal = Control.SIZE_EXPAND_FILL - split.split_offset = -320 + split.split_offset = UIScale.px(-320) vbox.add_child(split) split.add_child(_build_left_panel()) @@ -308,7 +346,7 @@ func _build_ui() -> void: # --- Statusleiste: Hilfs-Meldungen + Budget --- var status_panel := PanelContainer.new() var status_h := HBoxContainer.new() - status_h.add_theme_constant_override("separation", 16) + status_h.add_theme_constant_override("separation", UIScale.px(16)) status_panel.add_child(status_h) vbox.add_child(status_panel) @@ -321,15 +359,22 @@ func _build_ui() -> void: # in welchem Werkzeug er ist, plus Hint für den Flugmodus (Umschalt+F). tool_label = Label.new() tool_label.modulate = Color(0.55, 0.8, 1.0) + # Karte t_297221b5: Statuszeilen-Labels egalisiert (min-size-Druck bei 1.5×) + tool_label.clip_text = true + tool_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS status_h.add_child(tool_label) stats_objects = Label.new() stats_objects.modulate = Color(1, 1, 1, 0.75) + stats_objects.clip_text = true + stats_objects.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS status_h.add_child(stats_objects) # Terrain-Anteil + Editor-FPS (Perf-Zähler, Karte t_9aba04f1) stats_terrain = Label.new() stats_terrain.modulate = Color(1, 1, 1, 0.75) + stats_terrain.clip_text = true + stats_terrain.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS status_h.add_child(stats_terrain) var fps_timer := Timer.new() fps_timer.wait_time = 0.5 @@ -353,7 +398,7 @@ func _build_ui() -> void: open_dialog.access = FileDialog.ACCESS_FILESYSTEM open_dialog.title = "Map öffnen" open_dialog.filters = PackedStringArray(["*.svmap ; ScopedVR Map"]) - open_dialog.size = Vector2i(800, 560) + open_dialog.size = UIScale.v2i(800, 560) open_dialog.file_selected.connect(_on_open_selected) add_child(open_dialog) @@ -362,7 +407,7 @@ func _build_ui() -> void: save_dialog.access = FileDialog.ACCESS_FILESYSTEM save_dialog.title = "Map speichern unter" save_dialog.filters = PackedStringArray(["*.svmap ; ScopedVR Map"]) - save_dialog.size = Vector2i(800, 560) + save_dialog.size = UIScale.v2i(800, 560) save_dialog.file_selected.connect(_on_save_selected) add_child(save_dialog) @@ -371,7 +416,7 @@ func _build_ui() -> void: error_dialog.ok_button_text = "Alles klar" error_text = Label.new() error_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - error_text.custom_minimum_size = Vector2(560, 0) + error_text.custom_minimum_size = UIScale.v2(560, 0) error_dialog.add_child(error_text) add_child(error_dialog) @@ -381,7 +426,7 @@ func _build_ui() -> void: check_dialog.ok_button_text = "Danke!" check_text = Label.new() check_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - check_text.custom_minimum_size = Vector2(620, 0) + check_text.custom_minimum_size = UIScale.v2(620, 0) check_dialog.add_child(check_text) add_child(check_dialog) @@ -404,7 +449,7 @@ func _build_ui() -> void: overlay_style.border_color = Color(0.35, 0.55, 0.7) overlay_style.set_border_width_all(2) overlay_style.set_corner_radius_all(8) - overlay_style.set_content_margin_all(14) + overlay_style.set_content_margin_all(UIScale.px(14)) help_overlay.add_theme_stylebox_override("panel", overlay_style) # Zentriertes Panel statt Vollbild: der Editor bleibt rundherum bedienbar # (nicht-blockierend), das Panel ist groß genug für die ganze Tabelle. @@ -413,20 +458,20 @@ func _build_ui() -> void: help_overlay.set_anchors_and_offsets_preset(Control.PRESET_CENTER) help_overlay.grow_horizontal = Control.GROW_DIRECTION_BOTH help_overlay.grow_vertical = Control.GROW_DIRECTION_BOTH - help_overlay.custom_minimum_size = Vector2(880, 640) + help_overlay.custom_minimum_size = UIScale.v2(880, 640) help_overlay.visible = false var overlay_box := VBoxContainer.new() - overlay_box.add_theme_constant_override("separation", 6) + overlay_box.add_theme_constant_override("separation", UIScale.px(6)) help_overlay.add_child(overlay_box) help_overlay_title = Label.new() help_overlay_title.text = "Kurzhilfe — alle Tasten auf einen Blick (H oder Esc schließt)" - help_overlay_title.add_theme_font_size_override("font_size", 20) + help_overlay_title.add_theme_font_size_override("font_size", UIScale.f(20)) help_overlay_title.add_theme_color_override("font_color", Color(0.55, 0.8, 1.0)) overlay_box.add_child(help_overlay_title) help_overlay_text = RichTextLabel.new() help_overlay_text.bbcode_enabled = true help_overlay_text.size_flags_vertical = Control.SIZE_EXPAND_FILL - help_overlay_text.custom_minimum_size = Vector2(0, 0) + help_overlay_text.custom_minimum_size = UIScale.v2(0, 0) help_overlay_text.text = HelpText.text() overlay_box.add_child(help_overlay_text) add_child(help_overlay) @@ -434,10 +479,19 @@ func _build_ui() -> void: func _build_left_panel() -> PanelContainer: var panel := PanelContainer.new() - panel.custom_minimum_size = Vector2(280, 0) + panel.custom_minimum_size = UIScale.v2(280, 0) + # Karte t_297221b5: das GESAMTE linke Panel scrollt, wenn es bei großer + # UI-Stufe höher als das Fenster wird (wie die rechte Spalte es schon tut). + # Bei „Klein“ (alles passt) bleibt das Verhalten exakt wie vorher: der + # innere Inhalt Expand-FILL't, die Bibliothek nutzt den ganzen Platz. + var panel_scroll := ScrollContainer.new() + panel_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + panel.add_child(panel_scroll) var vbox := VBoxContainer.new() - vbox.add_theme_constant_override("separation", 6) - panel.add_child(vbox) + vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + vbox.size_flags_vertical = Control.SIZE_EXPAND_FILL + vbox.add_theme_constant_override("separation", UIScale.px(6)) + panel_scroll.add_child(vbox) vbox.add_child(_header_label("Objekt-Bibliothek")) vbox.add_child(_muted_label("Objekt anklicken, dann in die Karte klicken — der Modus bleibt an bis ESC (Rechte Maustaste = Flug).")) @@ -447,14 +501,14 @@ func _build_left_panel() -> PanelContainer: library_search = LineEdit.new() library_search.placeholder_text = "Suchen (Name oder ID)…" library_search.clear_button_enabled = true - library_search.custom_minimum_size = Vector2(0, 36) + library_search.custom_minimum_size = UIScale.v2(0, 36) library_search.text_changed.connect(_apply_library_filter) library_search.text_submitted.connect(func(_t: String) -> void: library_search.release_focus()) vbox.add_child(library_search) # „Alle auf" / „Alle zu" (t_59fc0f21): Klapp-Zustand aller Gruppen auf einmal. var group_btns := HBoxContainer.new() - group_btns.add_theme_constant_override("separation", 6) + group_btns.add_theme_constant_override("separation", UIScale.px(6)) library_open_all_btn = Button.new() library_open_all_btn.text = "Alle auf" library_open_all_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL @@ -471,12 +525,12 @@ func _build_left_panel() -> PanelContainer: var scroll := ScrollContainer.new() scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL - scroll.custom_minimum_size = Vector2(0, 280) + scroll.custom_minimum_size = UIScale.v2(0, 280) scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED vbox.add_child(scroll) library_box = VBoxContainer.new() library_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL - library_box.add_theme_constant_override("separation", 4) + library_box.add_theme_constant_override("separation", UIScale.px(4)) scroll.add_child(library_box) _fill_library() @@ -485,14 +539,14 @@ func _build_left_panel() -> PanelContainer: var snap_btn := Button.new() snap_btn.text = "⚡ Alles auf den Boden setzen" - snap_btn.custom_minimum_size = Vector2(0, 44) + snap_btn.custom_minimum_size = UIScale.v2(0, 44) snap_btn.tooltip_text = "Setzt ALLE Objekte auf die Terrain-Höhe — z. B. nach dem Terrain-Formen, wenn etwas in der Luft hängt" snap_btn.pressed.connect(_on_snap_to_ground) vbox.add_child(snap_btn) var grid_check := CheckButton.new() grid_check.text = "Raster-Snap (1 m)" - grid_check.custom_minimum_size = Vector2(0, 40) + grid_check.custom_minimum_size = UIScale.v2(0, 40) grid_check.tooltip_text = "Neue und verschobene Objekte rasten auf ein 1-Meter-Raster ein" grid_check.toggled.connect(_on_grid_snap_changed) vbox.add_child(grid_check) @@ -503,7 +557,7 @@ func _build_left_panel() -> PanelContainer: content_tree = Tree.new() content_tree.size_flags_vertical = Control.SIZE_EXPAND_FILL - content_tree.custom_minimum_size = Vector2(0, 160) + content_tree.custom_minimum_size = UIScale.v2(0, 160) content_tree.allow_reselect = true content_tree.select_mode = Tree.SELECT_MULTI # Strg+Klick im Baum = Mehrfachauswahl (t_c46d31c2) content_tree.hide_root = true @@ -537,19 +591,19 @@ func _fill_library() -> void: header.toggle_mode = true header.button_pressed = is_open header.alignment = HORIZONTAL_ALIGNMENT_LEFT - header.custom_minimum_size = Vector2(0, 34) + header.custom_minimum_size = UIScale.v2(0, 34) header.tooltip_text = "Gruppe „%s“ auf-/zuklappen" % g_label - header.add_theme_font_size_override("font_size", 18) + header.add_theme_font_size_override("font_size", UIScale.f(18)) header.add_theme_font_override("font", _library_header_font()) library_box.add_child(header) var group_box := VBoxContainer.new() - group_box.add_theme_constant_override("separation", 4) + group_box.add_theme_constant_override("separation", UIScale.px(4)) group_box.visible = is_open var grid := GridContainer.new() grid.columns = 2 grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL - grid.add_theme_constant_override("h_separation", 6) - grid.add_theme_constant_override("v_separation", 6) + grid.add_theme_constant_override("h_separation", UIScale.px(6)) + grid.add_theme_constant_override("v_separation", UIScale.px(6)) group_box.add_child(grid) library_box.add_child(group_box) header.toggled.connect(_on_library_group_toggled.bind(g_key, g_label, g_ids.size(), header, group_box)) @@ -659,7 +713,7 @@ func _add_library_button(oid: String, parent: Container) -> void: btn.toggle_mode = true btn.button_pressed = false btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL - btn.custom_minimum_size = Vector2(0, 126) + btn.custom_minimum_size = UIScale.v2(0, 126) btn.tooltip_text = "%s · %d Polys · %s" % [name_txt, poly, oid] btn.pressed.connect(_on_library_pressed.bind(oid)) var tex := LibraryBrowser.load_thumb(oid) @@ -667,18 +721,18 @@ func _add_library_button(oid: String, parent: Container) -> void: # Kein Thumbnail → Text-Zeile wie früher (Fehlertoleranz, t_3a998ae4). btn.text = "%s · %d Polys" % [name_txt, poly] btn.alignment = HORIZONTAL_ALIGNMENT_LEFT - btn.custom_minimum_size = Vector2(0, 44) + btn.custom_minimum_size = UIScale.v2(0, 44) else: # Kachel (t_59fc0f21): 96-px-Vorschaubild oben, Name klein darunter. # Kinder ignorieren die Maus, damit die Klicks am Button ankommen. var tile := VBoxContainer.new() tile.mouse_filter = Control.MOUSE_FILTER_IGNORE tile.set_anchors_preset(Control.PRESET_FULL_RECT) - tile.add_theme_constant_override("separation", 2) + tile.add_theme_constant_override("separation", UIScale.px(2)) btn.add_child(tile) var tr := TextureRect.new() tr.texture = tex - tr.custom_minimum_size = Vector2(LibraryBrowser.THUMB_DISPLAY_PX, LibraryBrowser.THUMB_DISPLAY_PX) + tr.custom_minimum_size = UIScale.v2(LibraryBrowser.THUMB_DISPLAY_PX, LibraryBrowser.THUMB_DISPLAY_PX) tr.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED tr.expand_mode = TextureRect.EXPAND_IGNORE_SIZE tr.size_flags_horizontal = Control.SIZE_SHRINK_CENTER @@ -686,7 +740,7 @@ func _add_library_button(oid: String, parent: Container) -> void: tile.add_child(tr) var lbl := Label.new() lbl.text = name_txt - lbl.add_theme_font_size_override("font_size", 11) + lbl.add_theme_font_size_override("font_size", UIScale.f(11)) lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER lbl.clip_text = true lbl.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS @@ -752,10 +806,20 @@ func _build_center_viewport() -> SubViewportContainer: help_label = Label.new() help_label.text = "Linksklick: auswählen & ziehen · Umschalt+Klick: Auswahl erweitern · Rahmen aufziehen: mehrere wählen · Strg+G: gruppieren\nR / Mausrad: drehen · Strg+D: duplizieren · Bild↑/↓: anheben/absenken · Spiegeln rechts · Entf: löschen · Strg+Z: zurück" help_label.set_anchors_preset(Control.PRESET_BOTTOM_WIDE) - help_label.offset_left = 12 - help_label.offset_bottom = -8 - help_label.offset_top = -46 + help_label.offset_left = UIScale.px(12) + help_label.offset_bottom = UIScale.px(-8) + help_label.offset_top = UIScale.px(-46) help_label.modulate = Color(1, 1, 1, 0.65) + # Karte t_297221b5: OHNE Umbruch würde die Mindestbreite des langen Texts + # bei Stufe „Groß“ die 3D-Ansicht breiter zwingen als das Fenster ist — + # die rechte Spalte rutschte aus dem Bildschirm. Mit Umbruch bleibt die + # Mindestbreite klein, der Hinweis legt sich bei Bedarf in mehr Zeilen. + help_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + # … und wenn der umgebrochene Text höher wird als der reservierte Streifen, + # wächst er nach OBEN (in die 3D-Ansicht) statt nach unten über die + # Statuszeile (grow-Richtung = das Godot-Mittel gegen Mindestgrößen-Überlauf + # bei Anker-Controls). + help_label.grow_vertical = Control.GROW_DIRECTION_BEGIN container.add_child(help_label) # Rahmen-Ausziehen (Karte t_f6f29d86): halbtransparentes Rechteck über @@ -792,13 +856,13 @@ func _draw_rubberband() -> void: func _build_right_panel() -> PanelContainer: var panel := PanelContainer.new() - panel.custom_minimum_size = Vector2(320, 0) + panel.custom_minimum_size = UIScale.v2(320, 0) var scroll := ScrollContainer.new() scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED panel.add_child(scroll) var vbox := VBoxContainer.new() vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL - vbox.add_theme_constant_override("separation", 8) + vbox.add_theme_constant_override("separation", UIScale.px(8)) scroll.add_child(vbox) # --- Auswahl (Anfassen/Greifen) --- @@ -808,24 +872,24 @@ func _build_right_panel() -> PanelContainer: sel_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART vbox.add_child(sel_label) var sel_btns := HBoxContainer.new() - sel_btns.add_theme_constant_override("separation", 8) + sel_btns.add_theme_constant_override("separation", UIScale.px(8)) vbox.add_child(sel_btns) sel_rotate_btn = Button.new() sel_rotate_btn.text = "Drehen (R)" - sel_rotate_btn.custom_minimum_size = Vector2(140, 40) + sel_rotate_btn.custom_minimum_size = UIScale.v2(140, 40) sel_rotate_btn.tooltip_text = "Ausgewähltes Objekt 15° drehen (auch: Mausrad)" sel_rotate_btn.pressed.connect(func() -> void: interaction.rotate_selected(15.0)) sel_btns.add_child(sel_rotate_btn) sel_delete_btn = Button.new() sel_delete_btn.text = "Löschen (Entf)" - sel_delete_btn.custom_minimum_size = Vector2(140, 40) + sel_delete_btn.custom_minimum_size = UIScale.v2(140, 40) sel_delete_btn.tooltip_text = "Ausgewähltes Objekt löschen (Strg+Z holt es zurück)" sel_delete_btn.pressed.connect(interaction.delete_selected) sel_btns.add_child(sel_delete_btn) # Objekt-Ops (Karte t_7567a06f): Duplizieren · Spiegeln · Anheben/Absenken. var dup_row := HBoxContainer.new() - dup_row.add_theme_constant_override("separation", 8) + dup_row.add_theme_constant_override("separation", UIScale.px(8)) vbox.add_child(dup_row) sel_dup_btn = _sel_op_button("⧉ Duplizieren (Strg+D)", "Legt eine Kopie daneben — leicht versetzt, direkt angewählt. Strg+D geht auch.", @@ -833,7 +897,7 @@ func _build_right_panel() -> PanelContainer: dup_row.add_child(sel_dup_btn) var mirror_row := HBoxContainer.new() - mirror_row.add_theme_constant_override("separation", 8) + mirror_row.add_theme_constant_override("separation", UIScale.px(8)) vbox.add_child(mirror_row) sel_mirror_x_btn = _sel_op_button("⇄ Links↔Rechts", "Spiegelt das Objekt an seiner Stelle (links↔rechts, X-Achse) — die Blickrichtung wird mitgespiegelt.", @@ -845,7 +909,7 @@ func _build_right_panel() -> PanelContainer: mirror_row.add_child(sel_mirror_z_btn) var lift_row := HBoxContainer.new() - lift_row.add_theme_constant_override("separation", 8) + lift_row.add_theme_constant_override("separation", UIScale.px(8)) vbox.add_child(lift_row) sel_raise_btn = _sel_op_button("▲ Anheben (Bild↑)", "Objekt 0,25 m anheben — es schwebt dann, auch wenn der Boden darunter anders ist. Bild↑ geht auch.", @@ -858,17 +922,17 @@ func _build_right_panel() -> PanelContainer: # Gruppen-Buttons (Karte t_f6f29d86): Mehrfachauswahl → Gruppe var group_btns := HBoxContainer.new() - group_btns.add_theme_constant_override("separation", 8) + group_btns.add_theme_constant_override("separation", UIScale.px(8)) vbox.add_child(group_btns) sel_group_btn = Button.new() sel_group_btn.text = "🔗 Gruppieren" - sel_group_btn.custom_minimum_size = Vector2(150, 40) + sel_group_btn.custom_minimum_size = UIScale.v2(150, 40) sel_group_btn.tooltip_text = "Ausgewählte Objekte zu einer Gruppe verbinden (Strg+G) — sie ziehen und drehen dann gemeinsam" sel_group_btn.pressed.connect(func() -> void: interaction.group_selection()) group_btns.add_child(sel_group_btn) sel_ungroup_btn = Button.new() sel_ungroup_btn.text = "⛓ Gruppe lösen" - sel_ungroup_btn.custom_minimum_size = Vector2(150, 40) + sel_ungroup_btn.custom_minimum_size = UIScale.v2(150, 40) sel_ungroup_btn.tooltip_text = "Die Gruppe der Auswahl wieder auflösen (Strg+Umschalt+G) — Objekte bleiben, nur die Bindung löst sich" sel_ungroup_btn.pressed.connect(func() -> void: interaction.ungroup_selection()) group_btns.add_child(sel_ungroup_btn) @@ -917,20 +981,20 @@ func _build_right_panel() -> PanelContainer: # --- Terrain-Werkzeug (Karte t_9aba04f1): großer deutscher Button --- terrain_edit_btn = Button.new() terrain_edit_btn.text = "⛰ Terrain formen" - terrain_edit_btn.custom_minimum_size = Vector2(0, 48) + terrain_edit_btn.custom_minimum_size = UIScale.v2(0, 48) terrain_edit_btn.toggle_mode = true terrain_edit_btn.tooltip_text = "Boden heben, senken und glätten — direkt in der Karte malen" terrain_edit_btn.pressed.connect(_on_terrain_btn_pressed) vbox.add_child(terrain_edit_btn) terrain_panel = VBoxContainer.new() - terrain_panel.add_theme_constant_override("separation", 6) + terrain_panel.add_theme_constant_override("separation", UIScale.px(6)) terrain_panel.visible = false vbox.add_child(terrain_panel) terrain_panel.add_child(_field_label("Pinsel-Werkzeug:")) var mode_row := HBoxContainer.new() - mode_row.add_theme_constant_override("separation", 6) + mode_row.add_theme_constant_override("separation", UIScale.px(6)) terrain_panel.add_child(mode_row) var mode_group := ButtonGroup.new() terrain_mode_raise = _terrain_mode_button("Heben ⬆", mode_group) @@ -949,19 +1013,19 @@ func _build_right_panel() -> PanelContainer: # Material-Palette (Karte t_603ec1a8): nur sichtbar im Malen-Modus. terrain_palette = VBoxContainer.new() - terrain_palette.add_theme_constant_override("separation", 4) + terrain_palette.add_theme_constant_override("separation", UIScale.px(4)) terrain_palette.visible = false terrain_panel.add_child(terrain_palette) terrain_palette.add_child(_field_label("Material:")) var mat_row := HBoxContainer.new() - mat_row.add_theme_constant_override("separation", 6) + mat_row.add_theme_constant_override("separation", UIScale.px(6)) terrain_palette.add_child(mat_row) var mat_group := ButtonGroup.new() var mat_labels := ["🌿 Gras", "🏖 Sand", "🪨 Fels"] for mat_id in SvmapIO.MATERIAL_IDS.size(): var mat_btn := Button.new() mat_btn.text = str(mat_labels[mat_id]) - mat_btn.custom_minimum_size = Vector2(0, 40) + mat_btn.custom_minimum_size = UIScale.v2(0, 40) mat_btn.toggle_mode = true mat_btn.button_group = mat_group mat_btn.tooltip_text = "Malen: %s" % str(SvmapIO.MATERIAL_IDS[mat_id]) @@ -974,7 +1038,7 @@ func _build_right_panel() -> PanelContainer: # Pinselgrößen-Presets (Karte t_603ec1a8): drei große Buttons + freier Slider. terrain_panel.add_child(_field_label("Pinselgröße:")) var size_row := HBoxContainer.new() - size_row.add_theme_constant_override("separation", 6) + size_row.add_theme_constant_override("separation", UIScale.px(6)) terrain_panel.add_child(size_row) var size_group := ButtonGroup.new() terrain_size_btn_small = _terrain_size_button("Klein\n(4 m)", size_group, 4.0) @@ -995,7 +1059,7 @@ func _build_right_panel() -> PanelContainer: terrain_radius_slider.max_value = 64.0 terrain_radius_slider.step = 1.0 terrain_radius_slider.value = 12.0 - terrain_radius_slider.custom_minimum_size = Vector2(0, 32) + terrain_radius_slider.custom_minimum_size = UIScale.v2(0, 32) terrain_radius_slider.value_changed.connect(_on_terrain_radius_changed) terrain_panel.add_child(terrain_radius_slider) @@ -1006,7 +1070,7 @@ func _build_right_panel() -> PanelContainer: terrain_strength_slider.max_value = 20.0 terrain_strength_slider.step = 0.5 terrain_strength_slider.value = 8.0 - terrain_strength_slider.custom_minimum_size = Vector2(0, 32) + terrain_strength_slider.custom_minimum_size = UIScale.v2(0, 32) terrain_strength_slider.value_changed.connect(_on_terrain_strength_changed) terrain_panel.add_child(terrain_strength_slider) @@ -1020,27 +1084,27 @@ func _build_right_panel() -> PanelContainer: spawn_status = Label.new() spawn_status.text = "" spawn_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - spawn_status.custom_minimum_size = Vector2(0, 44) + spawn_status.custom_minimum_size = UIScale.v2(0, 44) vbox.add_child(spawn_status) vbox.add_child(_muted_label("Der Check sagt VOR dem Export, was der Server-Loader verlangen wird (mindestens 4 Zonen pro Team, bekannte Objekt-IDs).")) # --- Spawn-Zonen-Werkzeug: großer Button + Bedien-Panel --- spawn_edit_btn = Button.new() spawn_edit_btn.text = "🚩 Spawn-Zonen malen" - spawn_edit_btn.custom_minimum_size = Vector2(0, 48) + spawn_edit_btn.custom_minimum_size = UIScale.v2(0, 48) spawn_edit_btn.toggle_mode = true spawn_edit_btn.tooltip_text = "Startzonen für Team Blau und Rot auf den Boden malen (TDM)" spawn_edit_btn.pressed.connect(_on_spawn_btn_pressed) vbox.add_child(spawn_edit_btn) spawn_panel = VBoxContainer.new() - spawn_panel.add_theme_constant_override("separation", 6) + spawn_panel.add_theme_constant_override("separation", UIScale.px(6)) spawn_panel.visible = false vbox.add_child(spawn_panel) spawn_panel.add_child(_field_label("Team:")) var team_row := HBoxContainer.new() - team_row.add_theme_constant_override("separation", 6) + team_row.add_theme_constant_override("separation", UIScale.px(6)) spawn_panel.add_child(team_row) var team_group := ButtonGroup.new() spawn_team_blue = _spawn_team_button("🔵 Team Blau", team_group, 0) @@ -1056,7 +1120,7 @@ func _build_right_panel() -> PanelContainer: spawn_radius_slider.max_value = SpawnOps.MAX_ZONE_RADIUS spawn_radius_slider.step = 1.0 spawn_radius_slider.value = SpawnOps.DEFAULT_ZONE_RADIUS - spawn_radius_slider.custom_minimum_size = Vector2(0, 32) + spawn_radius_slider.custom_minimum_size = UIScale.v2(0, 32) spawn_radius_slider.value_changed.connect(_on_spawn_radius_changed) spawn_panel.add_child(spawn_radius_slider) @@ -1066,7 +1130,7 @@ func _build_right_panel() -> PanelContainer: spawn_panel.add_child(spawn_sel_label) spawn_delete_btn = Button.new() spawn_delete_btn.text = "Zone löschen (Entf)" - spawn_delete_btn.custom_minimum_size = Vector2(0, 40) + spawn_delete_btn.custom_minimum_size = UIScale.v2(0, 40) spawn_delete_btn.tooltip_text = "Die ausgewählte Spawn-Zone löschen (Strg+Z holt sie zurück)" spawn_delete_btn.pressed.connect(_on_spawn_delete_pressed) spawn_panel.add_child(spawn_delete_btn) @@ -1079,22 +1143,22 @@ func _build_right_panel() -> PanelContainer: vbox.add_child(_separator()) build_edit_btn = Button.new() build_edit_btn.text = "🏠 Bauen (Häuser)" - build_edit_btn.custom_minimum_size = Vector2(0, 48) + build_edit_btn.custom_minimum_size = UIScale.v2(0, 48) build_edit_btn.toggle_mode = true build_edit_btn.tooltip_text = "Fundamente, Wände, Dächer, Treppen und Türen bauen — im 2-Meter-Raster, wie Bauklötze" build_edit_btn.pressed.connect(_on_build_btn_pressed) vbox.add_child(build_edit_btn) build_panel = VBoxContainer.new() - build_panel.add_theme_constant_override("separation", 6) + build_panel.add_theme_constant_override("separation", UIScale.px(6)) build_panel.visible = false vbox.add_child(build_panel) build_panel.add_child(_field_label("Bauteil:")) var build_grid := GridContainer.new() build_grid.columns = 2 - build_grid.add_theme_constant_override("h_separation", 6) - build_grid.add_theme_constant_override("v_separation", 6) + build_grid.add_theme_constant_override("h_separation", UIScale.px(6)) + build_grid.add_theme_constant_override("v_separation", UIScale.px(6)) build_panel.add_child(build_grid) var build_group := ButtonGroup.new() for kind_id in BuildOps.KINDS.keys(): @@ -1102,7 +1166,7 @@ func _build_right_panel() -> PanelContainer: kind_btn.text = str(BuildOps.KINDS[kind_id]["label"]) kind_btn.toggle_mode = true kind_btn.button_group = build_group - kind_btn.custom_minimum_size = Vector2(148, 44) + kind_btn.custom_minimum_size = UIScale.v2(148, 44) kind_btn.clip_text = true kind_btn.tooltip_text = "%s — %s" % [str(BuildOps.KINDS[kind_id]["label"]), str(BuildOps.KINDS[kind_id].get("info", ""))] kind_btn.pressed.connect(_on_build_kind_pressed.bind(str(kind_id))) @@ -1127,7 +1191,7 @@ func _build_right_panel() -> PanelContainer: func _sel_op_button(text: String, tip: String, action: Callable) -> Button: var btn := Button.new() btn.text = text - btn.custom_minimum_size = Vector2(140, 40) + btn.custom_minimum_size = UIScale.v2(140, 40) btn.tooltip_text = tip btn.pressed.connect(action) return btn @@ -1136,7 +1200,7 @@ func _sel_op_button(text: String, tip: String, action: Callable) -> Button: func _header_label(text: String) -> Label: var label := Label.new() label.text = text - label.add_theme_font_size_override("font_size", 19) + label.add_theme_font_size_override("font_size", UIScale.f(19)) label.add_theme_color_override("font_color", Color(0.55, 0.8, 1.0)) return label @@ -1181,8 +1245,8 @@ func _on_menu_action(id: int) -> void: _save() 3: # Speichern unter save_dialog.popup_centered() - 4: # Beenden - get_tree().quit() + 4: # Beenden (Fensterlage vorher merken, Karte t_297221b5) + _quit_editor() 5: # Karte testen _on_test_run() 6: # Kartengröße ändern (R3, t_c863b93b) @@ -1191,6 +1255,116 @@ func _on_menu_action(id: int) -> void: _on_update_check_menu() +# --------------------------------------------- UI-Größe + Fenster (t_297221b5) -- + +## Popup-Häkchen auf die aktuelle Stufe setzen. +func _sync_size_menu() -> void: + if size_menu == null or not is_instance_valid(size_menu): + return + var popup := size_menu.get_popup() + var cur := UIScale.step_index() + for i in popup.item_count: + popup.set_item_checked(i, i == cur) + + +## Neue Stufe gewählt: speichern, kompletten UI-Neuaufbau mit dem neuen +## Faktor, Statusmeldung. Wirkt SOFORT — kein Neustart nötig. +func _on_ui_size_selected(index: int) -> void: + if index < 0 or index >= UIScale.step_count(): + return + if index == UIScale.step_index(): + return + if _update_running: + _set_status("Während eines Updates kann die UI-Größe nicht umgestellt werden — gleich danach nochmal.", true) + _sync_size_menu() + return + UIScale.set_step_index(index) + _rebuild_ui_for_scale() + _set_status("UI-Größe: %s — sofort wirksam und gespeichert (bleibt auch nach Neustart)." % UIScale.step_label(index)) + + +## Kompletter UI-Neuaufbau mit dem aktuellen UIScale-Faktor. Die Map (doc) +## bleibt als Objekt UNBERÜHRT (Undo-History bleibt gültig); nur die Controls +## werden neu gebaut und der Zustand (Werkzeug, Gitter, Höhenlinien, Auswahl) +## wiederhergestellt. +func _rebuild_ui_for_scale() -> void: + var prev_mode := tool_mode + for child in get_children(): + remove_child(child) + child.queue_free() + _build_ui() + _update_tool_label() + _clear_edit_state() + _refresh_all() + _refresh_history_buttons() + # Nutzer-Wahl Gitter/Höhenlinien wiederherstellen (Neubau setzt Defaults). + grid_btn.set_pressed_no_signal(_grid_on) + _sync_grid_to_builder() + contours_btn.set_pressed_no_signal(_contours_on) + _sync_contours_to_builder() + # Werkzeug-Modus wiederherstellen (Neubau startet bei „objects“). + if prev_mode != "objects": + tool_mode = "objects" + _set_tool_mode(prev_mode) + # Update-Banner-Zustand wiederherstellen (Start-Check kannte das Ergebnis). + if str(_update_result.get("status")) == "update_available": + _show_update_banner(str(_update_result.get("local", "")), str(_update_result.get("remote", ""))) + _sync_size_menu() + + +## Fenster beim Start: maximiert (Default, kein Vollbild-Zwang) oder die +## gemerkte Fensterlage wiederherstellen. Headless-Läufe (Tests) haben kein +## Fenster und springen raus. +func _apply_window_state() -> void: + if DisplayServer.get_name() == "headless": + return + var cfg := ConfigFile.new() + var maximiert := true + var groesse := Vector2i(1600, 900) + var position := Vector2i() + var hat_geom := false + if cfg.load(UIScale.cfg_path()) == OK: + maximiert = bool(cfg.get_value(UIScale.WIN_SECTION, "maximiert", true)) + var g: Variant = cfg.get_value(UIScale.WIN_SECTION, "groesse", null) + if g is Array and (g as Array).size() == 2: + groesse = Vector2i(int((g as Array)[0]), int((g as Array)[1])) + hat_geom = true + var p: Variant = cfg.get_value(UIScale.WIN_SECTION, "position", null) + if p is Array and (p as Array).size() == 2: + position = Vector2i(int((p as Array)[0]), int((p as Array)[1])) + if maximiert: + DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MAXIMIZED) + else: + DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED) + if hat_geom: + DisplayServer.window_set_size(groesse) + DisplayServer.window_set_position(position) + + +## Fensterlage merken (beim Schließen): Modus + Größe + Position. +func _save_window_state() -> void: + if DisplayServer.get_name() == "headless": + return + var win := get_window() + var cfg := ConfigFile.new() + cfg.load(UIScale.cfg_path()) + cfg.set_value(UIScale.WIN_SECTION, "maximiert", win.mode == Window.MODE_MAXIMIZED) + cfg.set_value(UIScale.WIN_SECTION, "groesse", [win.size.x, win.size.y]) + cfg.set_value(UIScale.WIN_SECTION, "position", [win.position.x, win.position.y]) + cfg.save(UIScale.cfg_path()) + + +## Ein Ausgang für beide Beenden-Wege (Menü + Fenster-X). +func _quit_editor() -> void: + _save_window_state() + get_tree().quit() + + +func _notification(what: int) -> void: + if what == NOTIFICATION_WM_CLOSE_REQUEST: + _quit_editor() + + # ------------------------------------------------- Kartengröße ändern (R3) -- ## Dialog öffnen: aktueller Stand + Live-Preview (Verlust-Liste/Warnband) füllen. @@ -1265,21 +1439,21 @@ func _update_banner_build(parent: Container) -> void: style.bg_color = Color(0.36, 0.25, 0.06, 0.97) # gedämpftes Bernstein style.border_color = Color(0.95, 0.72, 0.15) style.set_border_width_all(2) - style.set_content_margin_all(8) + style.set_content_margin_all(UIScale.px(8)) style.set_corner_radius_all(6) update_banner.add_theme_stylebox_override("panel", style) var h := HBoxContainer.new() - h.add_theme_constant_override("separation", 14) + h.add_theme_constant_override("separation", UIScale.px(14)) update_banner.add_child(h) update_label = Label.new() - update_label.add_theme_font_size_override("font_size", 17) + update_label.add_theme_font_size_override("font_size", UIScale.f(17)) h.add_child(update_label) var spacer := Control.new() spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL h.add_child(spacer) update_btn = Button.new() update_btn.text = "🔄 Jetzt updaten" - update_btn.custom_minimum_size = Vector2(190, 40) + update_btn.custom_minimum_size = UIScale.v2(190, 40) update_btn.tooltip_text = "Lädt die neue Version (git pull), importiert sie und startet den Editor neu. Bei ungespeicherten Änderungen fragt der Editor vorher." update_btn.pressed.connect(_on_update_now) h.add_child(update_btn) @@ -1343,6 +1517,8 @@ func _updater_check_done(result: Dictionary, report: bool) -> void: func _show_update_banner(local_sha: String, remote_sha: String) -> void: + if update_banner == null or not is_instance_valid(update_banner): + return # UI wurde gerade neu aufgebaut (UI-Größe) — nichts anfassen update_label.text = EditorUpdater.banner_text(local_sha, remote_sha) update_banner.visible = true @@ -1405,6 +1581,8 @@ func _updater_run_thread() -> void: func _update_finished(ok: bool, title: String, details: Array) -> void: _update_running = false + if not is_instance_valid(update_btn) or not is_instance_valid(update_banner): + return # UI wurde zwischendurch neu aufgebaut (UI-Größe) — nichts anfassen update_btn.disabled = false _join(update_thread) update_thread = null @@ -1956,7 +2134,7 @@ func _terrain_mode_button(text: String, group: ButtonGroup) -> Button: btn.text = text btn.toggle_mode = true btn.button_group = group - btn.custom_minimum_size = Vector2(0, 40) + btn.custom_minimum_size = UIScale.v2(0, 40) return btn @@ -2130,7 +2308,7 @@ func _terrain_size_button(text: String, group: ButtonGroup, radius_m: float) -> btn.text = text btn.toggle_mode = true btn.button_group = group - btn.custom_minimum_size = Vector2(0, 48) + btn.custom_minimum_size = UIScale.v2(0, 48) btn.tooltip_text = "Pinselradius %d m" % int(radius_m) btn.toggled.connect(_on_terrain_size_preset.bind(radius_m)) return btn @@ -2241,7 +2419,7 @@ func _spawn_team_button(text: String, group: ButtonGroup, team: int) -> Button: btn.text = text btn.toggle_mode = true btn.button_group = group - btn.custom_minimum_size = Vector2(0, 40) + btn.custom_minimum_size = UIScale.v2(0, 40) btn.pressed.connect(_on_spawn_team_changed.bind(team)) return btn @@ -2719,10 +2897,15 @@ func _refresh_file_label() -> void: file_label.text = "„%s“ (noch nicht gespeichert)%s" % [map_name, " •" if dirty else ""] else: file_label.text = "„%s“ — %s%s" % [map_name, current_path.get_file(), " •" if dirty else ""] + # Karte t_297221b5: bei egalisiertem Label (große Stufe, schmales Fenster) + # steht der volle Name im Tooltip. + file_label.tooltip_text = file_label.text get_window().title = "%s — %s" % [APP_TITLE, map_name] func _set_status(text: String, is_error := false) -> void: + if status_label == null or not is_instance_valid(status_label): + return # UI wurde gerade neu aufgebaut (UI-Größe) — nichts anfassen status_label.text = text status_label.add_theme_color_override("font_color", Color(1.0, 0.6, 0.5) if is_error else Color(0.75, 0.95, 0.75)) @@ -2759,6 +2942,9 @@ func _maybe_start_demo_mode() -> void: if args.has("--bauen-demo"): _start_bauen_demo(args) return + if args.has("--ui-demo"): + _start_ui_demo(args) + return if not args.has("--ghost-demo"): return var oid := "crate_wood" @@ -2794,6 +2980,43 @@ func _maybe_start_demo_mode() -> void: camera.call("_apply") +# ----------------------------------------------------- UI-Größen-Demo-Screens -- + +## `godot ... -- --ui-demo [ordner] [--ui-demo-res BxH] [--ui-demo-quit]` +## (Karte t_297221b5): fährt ALLE UI-Größen-Stufen programmatisch durch — +## pro Stufe kompletter UI-Neuaufbau (derselbe Pfad wie der Menü-Klick) und +## ein Fenster-Screenshot. Beweist: umschaltbar im Lauf + nichts abgeschnitten. +func _start_ui_demo(args: PackedStringArray) -> void: + var out_dir := "/tmp/scopedvr_ui" + var idx := args.find("--ui-demo") + if idx >= 0 and idx + 1 < args.size() and not args[idx + 1].begins_with("--"): + out_dir = args[idx + 1] + DirAccess.make_dir_recursive_absolute(out_dir) + var res_idx := args.find("--ui-demo-res") + if res_idx >= 0 and res_idx + 1 < args.size(): + var parts := str(args[res_idx + 1]).split("x") + if parts.size() == 2: + # Fenstergröße NICHT zu früh setzen — direkt bei _ready wird der + # Wert von der Projekt-Einstellung überschrieben (xvfb-Befund); + # erst nach dem ersten Frame ist der Resize stabil. + await get_tree().process_frame + DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED) + DisplayServer.window_set_size(Vector2i(int(parts[0]), int(parts[1]))) + get_window().size = Vector2i(int(parts[0]), int(parts[1])) # root-Window setzt durch (xvfb-Befund) + for i in UIScale.step_count(): + UIScale.set_step_index(i) + _rebuild_ui_for_scale() + await get_tree().process_frame + await get_tree().process_frame + _set_status("UI-Größe: %s (%.2f-fach) — Theme-Schrift %d px, Beweis-Screenshot." % [ + UIScale.step_label(i), UIScale.factor(), UIScale.font_size()]) + await _demo_shot(out_dir + "/ui_%s.png" % UIScale.step_key(i)) + print("UI_DEMO: Stufe %s Faktor %.2f Schrift %d" % [UIScale.step_key(i), UIScale.factor(), UIScale.font_size()]) + print("UI_DEMO_OK: %d Stufen, PNGs in %s" % [UIScale.step_count(), out_dir]) + if args.has("--ui-demo-quit"): + get_tree().quit() # Batch-Smoke (xvfb): automatisch beenden nach den Shots + + # ------------------------------------------------ Terrain-Demo-Screenshots -- ## `godot ... -- --terrain-demo [ordner]` — Desktop-Smoke (Karte t_9aba04f1): diff --git a/src/ui/new_map_dialog.gd b/src/ui/new_map_dialog.gd index f1305ec..368f5e2 100644 --- a/src/ui/new_map_dialog.gd +++ b/src/ui/new_map_dialog.gd @@ -2,6 +2,8 @@ extends ConfirmationDialog ## „Neue Map“-Dialog: Name, Größe, Wetter-Preset — bewusst nur 3 Fragen, ## alles Weitere setzt vernünftige Defaults (Chef-Grundsatz: easy). +const UIScale := preload("res://src/ui/ui_scale.gd") # Karte t_297221b5 + var name_edit: LineEdit var size_option: OptionButton var weather_option: OptionButton @@ -16,19 +18,19 @@ func _init() -> void: title = "Neue Map" ok_button_text = "Map erstellen" cancel_button_text = "Abbrechen" - min_size = Vector2i(460, 0) + min_size = UIScale.v2i(460, 0) var vbox := VBoxContainer.new() vbox.anchors_preset = Control.PRESET_FULL_RECT vbox.set_anchors_preset(Control.PRESET_FULL_RECT) - vbox.add_theme_constant_override("separation", 12) + vbox.add_theme_constant_override("separation", UIScale.px(12)) add_child(vbox) vbox.add_child(_make_label("Wie soll die Map heißen?")) name_edit = LineEdit.new() name_edit.text = "Neue Map" name_edit.max_length = 64 - name_edit.custom_minimum_size = Vector2(0, 40) + name_edit.custom_minimum_size = UIScale.v2(0, 40) vbox.add_child(name_edit) vbox.add_child(_make_label("Wie groß soll das Gelände sein?")) @@ -36,14 +38,14 @@ func _init() -> void: for label in _size_labels: size_option.add_item(label) size_option.select(1) # Mittel als Default - size_option.custom_minimum_size = Vector2(0, 40) + size_option.custom_minimum_size = UIScale.v2(0, 40) vbox.add_child(size_option) vbox.add_child(_make_label("Welches Wetter soll die Map haben?")) weather_option = OptionButton.new() for label in _weather_labels: weather_option.add_item(label) - weather_option.custom_minimum_size = Vector2(0, 40) + weather_option.custom_minimum_size = UIScale.v2(0, 40) vbox.add_child(weather_option) var hint := _make_label("Die Map startet leer mit 4 Spawn-Punkten pro Team\n(Blau im Westen, Rot im Osten) — beides lässt sich später ändern.") diff --git a/src/ui/resize_map_dialog.gd b/src/ui/resize_map_dialog.gd index b852fd2..19c9fed 100644 --- a/src/ui/resize_map_dialog.gd +++ b/src/ui/resize_map_dialog.gd @@ -2,6 +2,8 @@ extends ConfirmationDialog ## „Kartengröße ändern“-Dialog (R3, Karte t_c863b93b — PLAN.md „Map-Größe ## ändern“): aktuelle Größe anzeigen, neue X/Z in Metern eingeben (Preset- ## Buttons + Freifeld), Live-Vorschau aus SvmapDocument.resize_preview (R2). + +const UIScale := preload("res://src/ui/ui_scale.gd") # Karte t_297221b5 ## ## Stil wie new_map_dialog.gd: alles per Code, Deutsch, Klartext-Warnungen. ## Der Dialog mutiert NICHTS — das Anwenden macht main.gd (_on_resize_confirmed) @@ -25,22 +27,22 @@ func _init() -> void: title = "Kartengröße ändern" ok_button_text = "Größe ändern" cancel_button_text = "Abbrechen" - min_size = Vector2i(520, 0) + min_size = UIScale.v2i(520, 0) var vbox := VBoxContainer.new() vbox.set_anchors_preset(Control.PRESET_FULL_RECT) - vbox.add_theme_constant_override("separation", 12) + vbox.add_theme_constant_override("separation", UIScale.px(12)) add_child(vbox) info_label = _make_label("") vbox.add_child(info_label) var preset_h := HBoxContainer.new() - preset_h.add_theme_constant_override("separation", 8) + preset_h.add_theme_constant_override("separation", UIScale.px(8)) for size in _PRESET_SIZES: var btn := Button.new() btn.text = "%d m" % size - btn.custom_minimum_size = Vector2(0, 40) + btn.custom_minimum_size = UIScale.v2(0, 40) btn.pressed.connect(_on_preset.bind(float(size))) preset_h.add_child(btn) vbox.add_child(preset_h) @@ -61,7 +63,7 @@ func _init() -> void: preview_label = _make_label("") preview_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - preview_label.custom_minimum_size = Vector2(0, 60) + preview_label.custom_minimum_size = UIScale.v2(0, 60) vbox.add_child(preview_label) drop_check = CheckBox.new() @@ -87,7 +89,7 @@ func _fill_spin(spin: SpinBox) -> void: spin.step = 1.0 spin.suffix = " m" spin.value = 1024.0 - spin.custom_minimum_size = Vector2(0, 40) + spin.custom_minimum_size = UIScale.v2(0, 40) ## Vom Editor vor dem Öffnen füllen: aktueller Stand + Live-Vorschau. diff --git a/src/ui/ui_scale.gd b/src/ui/ui_scale.gd new file mode 100644 index 0000000..f0a83ff --- /dev/null +++ b/src/ui/ui_scale.gd @@ -0,0 +1,120 @@ +class_name UIScale +extends RefCounted + +## Zentrale UI-Skalierung (Karte t_297221b5): EIN Faktor für den ganzen Editor. +## +## Stufen: Klein (1.0) / Mittel (1.25) / Groß (1.5). Der Faktor multipliziert +## alle Layout-Werte beim UI-Bau (Schrift, Zeilen-/Knopfhöhen, Panelbreiten, +## Thumbnail-Größe, Abstände) — bewusst als Faktor-Multiplikation und NICHT als +## stretch/canvas_items, damit (a) die 3D-Map-Ansicht pixelgenau unberührt bleibt, +## (b) nichts doppelt skaliert und (c) die Stufe vom Nutzer gewählt wird statt +## von der Fenstergröße abzuhängen. +## +## Die gewählte Stufe liegt in user://scopedvr_editor.cfg, Abschnitt [ui] +## (Key "groesse"). ConfigFile lädt+speichert die GANZE Datei — der Abschnitt +## [library] (Klapp-Zustand, Karte t_59fc0f21) bleibt dabei erhalten. +## +## Für Tests/Probes kann per Umgebungsvariable SCOPEDVR_UI_CFG eine andere +## Config-Datei erzwungen werden (hermetische Läufe ohne Nutzer-Config). + +const CFG_PATH := "user://scopedvr_editor.cfg" +const CFG_SECTION := "ui" +const CFG_KEY := "groesse" # "klein" | "mittel" | "gross" +const WIN_SECTION := "fenster" # Fenster-Zustand (maximiert/Größe/Position) + +## [key, Anzeige-Name, Faktor] +const STEPS: Array = [ + ["klein", "Klein", 1.0], + ["mittel", "Mittel", 1.25], + ["gross", "Groß", 1.5], +] +const BASE_FONT_SIZE := 16 # bisherige feste Schriftgröße (main.gd, Vorher) + +static var _factor: float = -1.0 # Cache; < 0 = noch nicht aus der Config gelesen + + +static func cfg_path() -> String: + var env := OS.get_environment("SCOPEDVR_UI_CFG") + if not env.is_empty(): + return env + return CFG_PATH + + +static func step_count() -> int: + return STEPS.size() + + +static func step_label(i: int) -> String: + return str(STEPS[clampi(i, 0, STEPS.size() - 1)][1]) + + +static func step_key(i: int) -> String: + return str(STEPS[clampi(i, 0, STEPS.size() - 1)][0]) + + +static func step_index_from_key(key: String) -> int: + for i in STEPS.size(): + if str(STEPS[i][0]) == key: + return i + return 0 # unbekannt/leer → Klein (Vorher-Verhalten) + + +## Aktuelle Stufe aus der Config (ohne Cache). +static func step_index() -> int: + var cfg := ConfigFile.new() + if cfg.load(cfg_path()) != OK: + return 0 # keine Config = Klein (altes Verhalten) + return step_index_from_key(str(cfg.get_value(CFG_SECTION, CFG_KEY, ""))) + + +## Faktor der aktuellen Stufe (cached — UI-Bau ruft das sehr oft). +static func factor() -> float: + if _factor < 0.0: + _factor = float(STEPS[step_index()][2]) + return _factor + + +## Stufe setzen UND sofort in die Config schreiben (Abschnitte wie [library] +## bleiben erhalten — ConfigFile liest/schreibt die ganze Datei). +static func set_step_index(i: int) -> void: + i = clampi(i, 0, STEPS.size() - 1) + var cfg := ConfigFile.new() + cfg.load(cfg_path()) # fehlt die Datei: leer anfangen, Abschnitte bleiben + cfg.set_value(CFG_SECTION, CFG_KEY, str(STEPS[i][0])) + var err := cfg.save(cfg_path()) + if err != OK: + push_warning("UIScale: Config nicht schreibbar (%s), Stufe nur für diese Sitzung." % err) + _factor = float(STEPS[i][2]) + + +## Cache verwerfen und neu lesen — simuliert den Neustart (für Tests/Probes). +static func reload() -> void: + _factor = -1.0 + factor() + + +# ------------------------------------------------------- Rechenhelfer (Bau) -- + +## Ganzzahliges Pixel-Maß (Höhen, Breiten, Abstände, Schriftgrößen). +static func px(v: float) -> int: + return int(round(v * factor())) + + +## Fließkommamaß (z. B. split_offset). +static func f(v: float) -> float: + return v * factor() + + +## custom_minimum_size = UIScale.v2(b, h) statt Vector2(b, h). +static func v2(x: float, y: float) -> Vector2: + return Vector2(px(x), px(y)) + + +## Dialog-/Fenstergrößen. +static func v2i(x: float, y: float) -> Vector2i: + return Vector2i(px(x), px(y)) + + +## Standardschriftgröße (bisher fest 16). +static func font_size() -> int: + return px(BASE_FONT_SIZE) diff --git a/src/ui/ui_scale.gd.uid b/src/ui/ui_scale.gd.uid new file mode 100644 index 0000000..beeeff8 --- /dev/null +++ b/src/ui/ui_scale.gd.uid @@ -0,0 +1 @@ +uid://d4d60533njpla diff --git a/tests/test_ui_scale.gd b/tests/test_ui_scale.gd new file mode 100644 index 0000000..c8b8349 --- /dev/null +++ b/tests/test_ui_scale.gd @@ -0,0 +1,219 @@ +extends SceneTree +## Editor-Test (headless): UI-Skalierung — Karte t_297221b5. +## Ausführen: godot --headless --path . -s res://tests/test_ui_scale.gd +## Exit 0 = alles grün. +## +## Geprüft wird: +## 1. Stufen-API: 3 Stufen (Klein 1.0 / Mittel 1.25 / Groß 1.5), Labels/Keys. +## 2. Rechenhelfer: px/f/v2/font_size multiplizieren korrekt (inkl. Runden). +## 3. Config-Roundtrip: Stufe übersteht reload() (= Neustart) UND der +## [library]-Abschnitt (Klapp-Zustand, Karte t_59fc0f21) bleibt erhalten. +## 4. ECHTE Main-UI (scenes/main.tscn): Theme-Schrift, Knopf-/Kachel-/ +## Thumbnail-Mindestmaße skalieren bei jeder Stufe — sofort nach dem +## Rebuild (derselbe Pfad wie der Menü-Klick „UI-Größe“). +## 5. Rebuild erhält den Map-Zustand (doc-Objekt bleibt, Map-Eigenschaften +## werden wieder eingefüllt, Objektzahl unverändert). +## 6. Ungültige Menü-Indices und headless-Fenster-Aufrufe sind harmlos. +## +## Die Suite ist hermetisch: SCOPEDVR_UI_CFG zeigt auf eine Temp-Config, +## damit weder die Nutzer-Config noch andere Suite-Läufe gestört werden. + +const UIScale := preload("res://src/ui/ui_scale.gd") + +var failures := 0 +var checks := 0 +var main: Control + + +func _initialize() -> void: + # Hermetische Config (Temp-Pfad im user://-Bereich): + OS.set_environment("SCOPEDVR_UI_CFG", "user://ui_scale_test.cfg") + _run_all.call_deferred() + + +func _check(cond: bool, label: String) -> void: + checks += 1 + if cond: + print(" ok: %s" % label) + else: + failures += 1 + print(" FAIL: %s" % label) + + +func _finish() -> void: + # Sauber hinterlassen: Test-Config löschen, Env-Umleitung aufheben. + var dir := DirAccess.open("user://") + if dir != null and dir.file_exists("ui_scale_test.cfg"): + dir.remove("ui_scale_test.cfg") + OS.set_environment("SCOPEDVR_UI_CFG", "") + print("CHECKS: %d FAILURES: %d" % [checks, failures]) + print("UI_SCALE_TEST_OK" if failures == 0 else "UI_SCALE_TEST_FAIL") + quit(1 if failures > 0 else 0) + + +func _run_all() -> void: + UIScale.set_step_index(0) + _test_steps_api() + _test_math() + _test_cfg_roundtrip() + await _test_real_ui_scaling() + await _test_state_survives_rebuild() + _test_headless_safety() + _finish() + + +func _test_steps_api() -> void: + print("Testgruppe: Stufen-API") + _check(UIScale.step_count() == 3, "Genau 3 Stufen") + _check(UIScale.step_label(0) == "Klein" and UIScale.step_label(1) == "Mittel" and UIScale.step_label(2) == "Groß", + "Labels Klein/Mittel/Groß") + UIScale.set_step_index(0) + _check(is_equal_approx(UIScale.factor(), 1.0), "Klein = Faktor 1.0") + UIScale.set_step_index(1) + _check(is_equal_approx(UIScale.factor(), 1.25), "Mittel = Faktor 1.25") + UIScale.set_step_index(2) + _check(is_equal_approx(UIScale.factor(), 1.5), "Groß = Faktor 1.5") + _check(UIScale.step_index_from_key("mittel") == 1, "Key 'mittel' → Index 1") + _check(UIScale.step_index_from_key("quatsch") == 0, "Unbekannter Key → Klein (0)") + _check(UIScale.step_index_from_key("") == 0, "Leerer Key → Klein (0)") + + +func _test_math() -> void: + print("Testgruppe: Rechenhelfer") + UIScale.set_step_index(0) + _check(UIScale.px(40) == 40 and UIScale.px(16) == 16, "Klein: px unverändert (1.0)") + _check(UIScale.font_size() == 16, "Klein: Standardschrift 16 (wie Vorher)") + UIScale.set_step_index(1) + _check(UIScale.px(40) == 50, "Mittel: 40 px → 50 px") + _check(UIScale.px(126) == 158, "Mittel: 126 px → 158 px (gerundet)") + _check(UIScale.px(280) == 350, "Mittel: Panel 280 px → 350 px") + _check(is_equal_approx(UIScale.f(-320.0), -400.0), "Mittel: split_offset -320 → -400") + _check(UIScale.font_size() == 20, "Mittel: Standardschrift 20") + var v := UIScale.v2(140, 40) + _check(v.x == 175 and v.y == 50, "Mittel: v2(140,40) → (175,50)") + UIScale.set_step_index(2) + _check(UIScale.px(40) == 60 and UIScale.font_size() == 24, "Groß: 40 px → 60 px, Schrift 24") + _check(UIScale.px(96) == 144, "Groß: Thumbnail 96 → 144") + + +func _test_cfg_roundtrip() -> void: + print("Testgruppe: Config-Roundtrip (Neustart-Festigkeit + [library]-Schutz)") + UIScale.set_step_index(1) # schreibt [ui] groesse=mittel + # [library]-Abschnitt anlegen (wie Karte t_59fc0f21 es tut): + var cfg := ConfigFile.new() + cfg.load(UIScale.cfg_path()) + cfg.set_value("library", "gebaeude", true) + cfg.save(UIScale.cfg_path()) + # „Neustart“: Cache verwerfen, frisch aus der Datei lesen. + UIScale.reload() + _check(UIScale.step_index() == 1, "Stufe Mittel übersteht reload() (= Neustart)") + var cfg2 := ConfigFile.new() + cfg2.load(UIScale.cfg_path()) + _check(bool(cfg2.get_value("library", "gebaeude", false)), "[library]-Abschnitt bleibt beim Schreiben erhalten") + _check(str(cfg2.get_value("ui", "groesse", "")) == "mittel", "[ui] groesse=mittel steht in der Datei") + + +func _test_real_ui_scaling() -> void: + print("Testgruppe: Echte Main-UI skaliert (Build + Rebuild)") + var scene: PackedScene = load("res://scenes/main.tscn") + _check(scene != null, "scenes/main.tscn lädt") + if scene == null: + return + UIScale.set_step_index(0) + main = scene.instantiate() + root.add_child(main) + await process_frame + await process_frame + _check(main != null and main.undo_btn != null, "Main-UI gebaut") + _check(int(main.theme.default_font_size) == 16, "Klein: Theme-Schrift 16") + _check(main.undo_btn.custom_minimum_size == Vector2(140, 40), "Klein: Undo-Knopf 140×40") + _check(main.redo_btn.custom_minimum_size == Vector2(150, 40), "Klein: Redo-Knopf 150×40") + _check(main.size_menu != null and main.size_menu.text == "UI-Größe", "UI-Größe-Menü in der Top-Bar") + var sp: PopupMenu = main.size_menu.get_popup() + _check(sp.item_count == 3, "Menü hat 3 Einträge") + _check(sp.is_item_checked(0), "Menü-Häkchen auf aktueller Stufe (Klein)") + + # Rebuild-Pfad (derselbe wie beim Menü-Klick) → Mittel: + UIScale.set_step_index(1) + main._rebuild_ui_for_scale() + await process_frame + await process_frame + _check(is_instance_valid(main.undo_btn), "Rebuild: Knöpfe neu gebaut") + _check(int(main.theme.default_font_size) == 20, "Mittel: Theme-Schrift 20 (sofort wirksam)") + _check(main.undo_btn.custom_minimum_size == Vector2(175, 50), "Mittel: Undo-Knopf 175×50") + _check(main.library_search.custom_minimum_size.y == 45, "Mittel: Suchfeld 36 → 45") + var tile := _find_tile_button(158) + _check(tile != null and tile.custom_minimum_size.y == 158, "Mittel: Bibliotheks-Kachel 126 → 158") + var thumb := _find_thumb_rect(120) + _check(thumb != null and thumb.custom_minimum_size.x == 120, "Mittel: Thumbnail 96 → 120") + _check(main.size_menu.get_popup().is_item_checked(1), "Menü-Häkchen wandert auf Mittel (neues Popup)") + + # Groß: + main._on_ui_size_selected(2) + await process_frame + await process_frame + _check(int(main.theme.default_font_size) == 24, "Groß: Theme-Schrift 24 (per Menü-Handler)") + _check(main.undo_btn.custom_minimum_size == Vector2(210, 60), "Groß: Undo-Knopf 210×60") + _check(main.status_label != null and is_instance_valid(main.status_label), "Statuszeile existiert (Groß)") + _check(main.content_tree != null and is_instance_valid(main.content_tree), "Inhalts-Baum existiert (Groß)") + + # Zurück auf Klein: wieder exakt die Vorher-Maße. + main._on_ui_size_selected(0) + await process_frame + await process_frame + _check(main.size_menu.get_popup().is_item_checked(0), "Menü-Häkchen wieder auf Klein") + _check(int(main.theme.default_font_size) == 16, "Zurück auf Klein: Schrift wieder 16") + _check(main.undo_btn.custom_minimum_size == Vector2(140, 40), "Zurück auf Klein: Undo-Knopf 140×40") + + +func _find_tile_button(y: int) -> Button: + # Bibliotheks-Kacheln: Buttons mit custom_minimum_size.y == Stufenhöhe + # unterhalb der Haupt-VBox suchen — der Rebuild baut sie jedes Mal neu. + var stack: Array = [main] + while not stack.is_empty(): + var node: Node = stack.pop_back() + if node is Button and node.custom_minimum_size.y == y and node.custom_minimum_size.x == 0: + return node + for c in node.get_children(): + stack.push_back(c) + return null + + +func _find_thumb_rect(x: int) -> TextureRect: + var stack: Array = [main] + while not stack.is_empty(): + var node: Node = stack.pop_back() + if node is TextureRect and node.custom_minimum_size.x == x: + return node + for c in node.get_children(): + stack.push_back(c) + return null + + +func _test_state_survives_rebuild() -> void: + print("Testgruppe: Rebuild erhält Map-Zustand") + if main == null: + return + var count_before := SvmapDocument.object_count(main.doc) + main.doc["info"]["name"] = "Skalierungs-Test" + main._rebuild_ui_for_scale() + await process_frame + await process_frame + _check(SvmapDocument.object_count(main.doc) == count_before, "Map-Dokument unverändert (Objektzahl)") + _check(str(main.prop_name.text) == "Skalierungs-Test", "Map-Eigenschaften nach Rebuild wieder eingefüllt") + _check(main.doc == main.history.doc, "Undo-History zeigt weiter auf dasselbe doc") + _check(main.camera != null and is_instance_valid(main.camera), "3D-Kamera nach Rebuild vorhanden") + _check(main.view_builder != null and is_instance_valid(main.view_builder), "Map-Ansicht nach Rebuild vorhanden") + + +func _test_headless_safety() -> void: + print("Testgruppe: Headless-Sicherheit") + # Headless: Fenster-Aufrufe dürfen nicht krachen (Guards springen an). + main._apply_window_state() + main._save_window_state() + _check(true, "Fenster-Aufrufe headless ohne Crash") + # Ungültige Menü-Indices harmlos: + main._on_ui_size_selected(-1) + main._on_ui_size_selected(99) + _check(true, "Ungültige Menü-Indices ohne Crash") + _check(UIScale.step_index() == 0, "Stufe bleibt Klein nach ungültigen Aufrufen") diff --git a/tests/test_ui_scale.gd.uid b/tests/test_ui_scale.gd.uid new file mode 100644 index 0000000..4ba9c5d --- /dev/null +++ b/tests/test_ui_scale.gd.uid @@ -0,0 +1 @@ +uid://cxpf27vu88wq1