UI-Größe: zentrale Skalierung Klein/Mittel/Groß (t_297221b5) — Top-Bar-Menü, sofort wirksam (Rebuild), gespeichert in user://scopedvr_editor.cfg [ui]; Fenster startet maximiert + merkt Lage; Panel-Min-Maße/Schrift an einen Faktor gekoppelt; Layout-Überlauf-Fixes (Hilfe-Hinweis Umbruch+Grow-up, Statuszeile/Top-Bar-Ellipsis, linkes Panel scrollbar)

This commit is contained in:
Hermes (scopedvr-dev) 2026-10-19 19:21:45 +00:00
parent f4748af371
commit c8a41c0794
7 changed files with 666 additions and 98 deletions

View file

@ -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):

View file

@ -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.")

View file

@ -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.

120
src/ui/ui_scale.gd Normal file
View file

@ -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)

1
src/ui/ui_scale.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://d4d60533njpla

219
tests/test_ui_scale.gd Normal file
View file

@ -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")

View file

@ -0,0 +1 @@
uid://cxpf27vu88wq1