t_eaccbfed: U1 UI-Fundament (Operator-Push) — main.gd 3677->2479 Zeilen, src/ui/panels/ (11 Module + registry), Tabs rechts+links, Assets unten, theme/editor_theme.tres, tests/test_ui_layout.gd (63 checks). Worker meldete fertig ohne Push; Arbeit aus dem Scratch-Ordner gerettet.

This commit is contained in:
scopedvr-operator 2026-10-20 09:11:00 +00:00
parent a5d8f7f79d
commit 69ffe23a71
27 changed files with 2341 additions and 1611 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,278 @@
extends "res://src/ui/panels/editor_panel.gd"
## Objekt-Bibliothek / Assets-Bereich (aus main.gd ausgezogen, Karte t_eaccbfed
## U1 — Logik unverändert, Karten t_3a998ae4/t_59fc0f21): Suche, Klapp-Gruppen,
## Kacheln. Der Bereich wohnt jetzt UNTEN unter der 3D-Ansicht (Unity-
## Projekt-Fenster-Platz); Klick verhält sich noch wie vorher (U5 ändert das).
const LIBRARY_CFG_SECTION := "library" # Abschnitt in user://scopedvr_editor.cfg
var library_box: VBoxContainer
var library_buttons: Dictionary = {} # id → Button
## Bibliothek-Gruppen (t_59fc0f21): Klapp-Zustand + Zeilen für Suche/Alle auf/zu.
var library_search: LineEdit
var library_open_all_btn: Button
var library_close_all_btn: Button
var _library_group_open: Dictionary = {} # Gruppen-Key → auf/zu
var _library_group_rows: Array = [] # {key, header, box, ids}
var _library_bold_font: Font = null
static func spec() -> Dictionary:
return {"titel": "Objekt-Bibliothek", "gruppe": "unten", "order": 10}
func panel_title() -> String:
return "Objekt-Bibliothek"
## Bau (aus der alten main.gd/_build_left_panel übernommen, Logik identisch):
## Suche → „Alle auf/zu“ → Kachel-Liste (library_box) → _fill_library().
## Der Bereich wohnt jetzt UNTEN (Registry-Gruppe "unten"); Klick verhält sich
## noch wie vorher (U5 ändert das Verhalten).
func build() -> void:
add_child(_header_label("Objekt-Bibliothek"))
add_child(_muted_label("Objekt anklicken, dann in die Karte klicken — der Modus bleibt an bis ESC (Rechte Maustaste = Flug)."))
# Suche (t_59fc0f21): tippen filtert live nach Name/ID — Gruppen mit
# Treffern klappen auf, leere Gruppen verschwinden.
library_search = LineEdit.new()
library_search.placeholder_text = "Suchen (Name oder ID)…"
library_search.clear_button_enabled = true
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())
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", 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
library_open_all_btn.tooltip_text = "Klappt ALLE Gruppen auf"
library_open_all_btn.pressed.connect(_on_library_expand_all)
group_btns.add_child(library_open_all_btn)
library_close_all_btn = Button.new()
library_close_all_btn.text = "Alle zu"
library_close_all_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
library_close_all_btn.tooltip_text = "Klappt ALLE Gruppen zu"
library_close_all_btn.pressed.connect(_on_library_collapse_all)
group_btns.add_child(library_close_all_btn)
add_child(group_btns)
var scroll := ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.custom_minimum_size = UIScale.v2(0, 280)
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
add_child(scroll)
library_box = VBoxContainer.new()
library_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
library_box.add_theme_constant_override("separation", UIScale.px(4))
scroll.add_child(library_box)
_fill_library()
func _fill_library() -> void:
library_buttons.clear()
_library_group_rows.clear()
_library_group_open = _library_load_open_states()
# Gruppen-Browser (Karte t_3a998ae4, Überarbeitung t_59fc0f21):
# - Klapp-Überschriften je Gruppe (fett, 18 px, Name + Anzahl),
# - STANDARD EINGEKLAPPT — nur die erste Gruppe ist offen (Übersicht!),
# - Klapp-Zustand je Gruppe wird in user://scopedvr_editor.cfg gemerkt,
# - Objekte als Kacheln mit 96-px-Vorschaubild (2 Spalten je Gruppe).
var first_open: String = ""
for group in LibraryBrowser.groups_in_order(main.manifest):
var g_key: String = group["key"]
var g_label: String = group["label"]
var g_ids: Array = group["ids"]
if g_ids.is_empty():
continue
if first_open.is_empty():
first_open = g_key
var is_open: bool = _library_group_open.get(g_key, g_key == first_open)
_library_group_open[g_key] = is_open
var header := Button.new()
header.text = _library_header_text(is_open, g_label, g_ids.size())
header.toggle_mode = true
header.button_pressed = is_open
header.alignment = HORIZONTAL_ALIGNMENT_LEFT
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", 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", 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", 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))
for oid in g_ids:
_add_library_button(str(oid), grid)
_library_group_rows.append({"key": g_key, "header": header, "box": group_box, "ids": g_ids, "label": g_label, "count": g_ids.size()})
_apply_library_filter(library_search.text)
func _library_header_text(on: bool, label: String, count: int) -> String:
return "%s %s (%d)" % ["▾" if on else "▸", label, count]
func _on_library_group_toggled(on: bool, g_key: String, g_label: String, count: int, header: Button, group_box: VBoxContainer) -> void:
group_box.visible = on
header.text = _library_header_text(on, g_label, count)
_library_group_open[g_key] = on
_library_save_open_state(g_key, on)
func _on_library_expand_all() -> void:
for row in _library_group_rows:
(row["header"] as Button).button_pressed = true
func _on_library_collapse_all() -> void:
for row in _library_group_rows:
(row["header"] as Button).button_pressed = false
func _library_header_font() -> Font:
if _library_bold_font == null:
var sf := SystemFont.new()
sf.font_names = PackedStringArray(["Noto Sans", "DejaVu Sans", "Ubuntu", "sans-serif"])
sf.font_weight = 700
_library_bold_font = sf
return _library_bold_font
func _library_load_open_states() -> Dictionary:
var cfg := ConfigFile.new()
cfg.load("user://scopedvr_editor.cfg")
var out := {}
if cfg.has_section(LIBRARY_CFG_SECTION):
for key in cfg.get_section_keys(LIBRARY_CFG_SECTION):
out[str(key)] = bool(cfg.get_value(LIBRARY_CFG_SECTION, str(key), true))
return out
func _library_save_open_state(g_key: String, on: bool) -> void:
var cfg := ConfigFile.new()
cfg.load("user://scopedvr_editor.cfg")
cfg.set_value(LIBRARY_CFG_SECTION, g_key, on)
cfg.save("user://scopedvr_editor.cfg")
## Live-Suche (t_59fc0f21): leer = alles normal sichtbar (Klapp-Zustand wie
## gemerkt); Text = nur Treffer (Name de/en, ID — case-insensitive), Gruppen
## mit Treffern bleiben offen, leere verschwinden.
func _apply_library_filter(query: String) -> void:
var q := query.strip_edges().to_lower()
var searching := not q.is_empty()
for row in _library_group_rows:
var ids: Array = row["ids"]
var any := false
for oid in ids:
var btn := library_buttons.get(str(oid)) as Button
if btn == null:
continue
var vis := true
if searching:
vis = _library_oid_matches(str(oid), q)
btn.visible = vis
any = any or vis
var header := row["header"] as Button
var box := row["box"] as VBoxContainer
header.visible = any
if searching:
# Treffer-Gruppen bleiben auf — auch wenn sie zugeklappt waren
# (Pfeil zeigt dann offen, kosmetisch konsistent).
box.visible = any
if any:
header.text = _library_header_text(true, str(row["label"]), int(row["count"]))
else:
var is_open := bool(_library_group_open.get(str(row["key"]), false))
box.visible = is_open
header.text = _library_header_text(is_open, str(row["label"]), int(row["count"]))
func _library_oid_matches(oid: String, q: String) -> bool:
if oid.to_lower().contains(q):
return true
var entry: Dictionary = (main.manifest.get("objects", {}) as Dictionary).get(oid, {})
if LibraryBrowser.entry_label(oid, entry).to_lower().contains(q):
return true
if LibraryBrowser.object_label(oid).to_lower().contains(q):
return true
return false
func _add_library_button(oid: String, parent: Container) -> void:
var lib: Dictionary = main.manifest.get("objects", {})
var entry: Dictionary = lib.get(oid, {})
var poly := int(entry.get("poly", 0))
var name_txt := LibraryBrowser.entry_label(oid, entry)
var btn := Button.new()
btn.toggle_mode = true
btn.button_pressed = false
btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
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)
if tex == null:
# 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 = 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", UIScale.px(2))
btn.add_child(tile)
var tr := TextureRect.new()
tr.texture = tex
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
tr.mouse_filter = Control.MOUSE_FILTER_IGNORE
tile.add_child(tr)
var lbl := Label.new()
lbl.text = name_txt
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
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
tile.add_child(lbl)
parent.add_child(btn)
library_buttons[oid] = btn
func _on_library_pressed(oid: String) -> void:
if str(main.get("tool_mode")) != "objects":
main.call("_set_tool_mode", "objects") # Bibliothek-Klick wechselt automatisch zurück
if main.interaction.placing and main.interaction.place_oid == oid:
# Nochmal geklickt → Modus beenden (An/Aus ohne Umwege).
main.interaction.cancel_placement()
main.call("_set_status", "Platzieren abgebrochen.")
return
for other_oid in library_buttons:
(library_buttons[other_oid] as Button).button_pressed = false
library_buttons[oid].button_pressed = true
main.interaction.start_placement(oid)
## Der Bibliothek-Button des aktiven Platzier-Objekts leuchtet auf.
func highlight_library(oid: String) -> void:
for other_oid in library_buttons:
var btn := library_buttons[other_oid] as Button
btn.button_pressed = other_oid == oid
btn.modulate = Color(1, 1, 1) if oid == other_oid else Color(1, 1, 1, 0.85)

View file

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

View file

@ -0,0 +1,146 @@
extends "res://src/ui/panels/editor_panel.gd"
## Spawn-Check + Spawn-Zonen-Werkzeug-Panel (aus main.gd ausgezogen, Karte
## t_eaccbfed U1 — Logik unverändert, Karte t_c4162c5d): Live-Validierung
## (Loader-Regeln, IMMER sichtbar) + großer Werkzeug-Button + Team/Radius/
## Löschen. Werkzeug-Ein/Aus bleibt im Koordinator (main.gd).
var spawn_edit_btn: Button
var spawn_panel: VBoxContainer
var spawn_team_blue: Button
var spawn_team_red: Button
var spawn_radius_slider: HSlider
var spawn_radius_label: Label
var spawn_status: Label # Live-Validierung (IMMER sichtbar, Loader-Regeln)
var spawn_sel_label: Label # Welche Zone ist an?
var spawn_delete_btn: Button
static func spec() -> Dictionary:
return {"titel": "Prüfen", "gruppe": "rechts", "order": 50}
func panel_title() -> String:
return "Prüfen"
## Bau (aus der alten main.gd/_build_right_panel übernommen, Logik identisch):
## Live-Status IMMER sichtbar + Werkzeug-Button + Bedien-Panel (Team/Radius/
## Auswahl/Löschen). Ein/Aus des Werkzeugs bleibt beim Koordinator (main.gd).
func build() -> void:
# --- Spawn-Zonen (Karte t_c4162c5d): Live-Status IMMER sichtbar ---
spawn_status = Label.new()
spawn_status.text = ""
spawn_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
spawn_status.custom_minimum_size = UIScale.v2(0, 44)
add_child(spawn_status)
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 = 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)
add_child(spawn_edit_btn)
spawn_panel = VBoxContainer.new()
spawn_panel.add_theme_constant_override("separation", UIScale.px(6))
spawn_panel.visible = false
add_child(spawn_panel)
spawn_panel.add_child(_field_label("Team:"))
var team_row := HBoxContainer.new()
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)
spawn_team_red = _spawn_team_button("🔴 Team Rot", team_group, 1)
spawn_team_blue.button_pressed = true
team_row.add_child(spawn_team_blue)
team_row.add_child(spawn_team_red)
spawn_radius_label = _field_label("Zonen-Radius: 6 m")
spawn_panel.add_child(spawn_radius_label)
spawn_radius_slider = HSlider.new()
spawn_radius_slider.min_value = SpawnOps.MIN_ZONE_RADIUS
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 = UIScale.v2(0, 32)
spawn_radius_slider.value_changed.connect(_on_spawn_radius_changed)
spawn_panel.add_child(spawn_radius_slider)
spawn_sel_label = Label.new()
spawn_sel_label.text = "Keine Zone ausgewählt."
spawn_sel_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
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 = 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)
spawn_panel.add_child(_muted_label(
"Klick setzt eine Zone, Klick+Ziehen verschiebt sie. Jede Zone ist "
+ "EIN Rückgängig-Schritt (Strg+Z). ESC beendet das Spawn-Werkzeug."))
func _spawn_team_button(text: String, group: ButtonGroup, team: int) -> Button:
var btn := Button.new()
btn.text = text
btn.toggle_mode = true
btn.button_group = group
btn.custom_minimum_size = UIScale.v2(0, 40)
btn.pressed.connect(_on_spawn_team_changed.bind(team))
return btn
func _on_spawn_btn_pressed() -> void:
if spawn_edit_btn.button_pressed:
main.call("_set_tool_mode", "spawns")
else:
main.call("_set_tool_mode", "objects")
func _selected_spawn_team() -> int:
return 1 if spawn_team_red.button_pressed else 0
func _on_spawn_team_changed(team: int) -> void:
if main.spawn_tool != null and str(main.get("tool_mode")) == "spawns":
main.spawn_tool.call("set_team", team)
func _on_spawn_radius_changed(value: float) -> void:
spawn_radius_label.text = "Zonen-Radius: %d m" % int(value)
if main.spawn_tool != null and str(main.get("tool_mode")) == "spawns":
main.spawn_tool.call("set_radius", value)
func _on_spawn_delete_pressed() -> void:
if str(main.get("tool_mode")) == "spawns":
main.spawn_tool.call("delete_selected")
## Panel-Zeile „Welche Zone ist angewählt?“ — WIRD vom Spawn-Tool gerufen.
func sync_spawn_selection() -> void:
if main.spawn_tool == null:
return
var team: int = main.spawn_tool.get("selected_team")
var pid: String = str(main.spawn_tool.get("selected_id"))
if team < 0 or pid == "":
spawn_sel_label.text = "Keine Zone ausgewählt.\nKlicke im Viewport auf einen Kreis."
spawn_delete_btn.disabled = true
return
spawn_sel_label.text = "Ausgewählt: %s (%s)\nVerschieben: klicken+ziehen · Radius: Regler oben" % [
pid, SpawnOps.team_label(team)]
spawn_delete_btn.disabled = false
## LIVE-Validierung (Loader-Regeln) — immer sichtbar, nach JEDEM Struktur-Wechsel.
func refresh_spawn_status() -> void:
if main.doc.is_empty() or spawn_status == null:
return
spawn_status.text = SpawnOps.live_status(main.doc, main.manifest)

View file

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

View file

@ -0,0 +1,53 @@
extends VBoxContainer
class_name EditorPanel
## Basis-Klasse aller Registry-Panels (Karte t_eaccbfed). Jedes Modul baut
## seinen eigenen Inhalt in setup(main) und wird von main.gd NUR über die
## Registry (src/ui/panels/registry.gd) in den Baum gehängt — main.gd kennt
## keine Panel-Interna mehr.
##
## Datenfluss (bewusst 1:1 wie vorher): Module greifen über `main` auf das
## Dokument/History/Werkzeuge zu und rufen die Koordinator-Helfer über
## main.call(...) — der Aufruf-Vertrag zu den Tools (interaction.gd etc.)
## bleibt unverändert, die Methoden wohnen jetzt nur in den Modulen.
var main # src/ui/main.gd (script-erweitert → untypisiert)
func setup(p_main) -> void:
main = p_main
add_theme_constant_override("separation", 8)
## Titel der Sektions-Überschrift (Registry-Titel als Default).
func panel_title() -> String:
return "Panel"
# --- Gemeinsame Bau-Helfer (aus main.gd übernommen, Logik identisch) --------
func _header_label(text: String) -> Label:
var label := Label.new()
label.text = text
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
func _field_label(text: String) -> Label:
var label := Label.new()
label.text = text
label.modulate = Color(1, 1, 1, 0.7)
return label
func _muted_label(text: String) -> Label:
var label := Label.new()
label.text = text
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.modulate = Color(1, 1, 1, 0.55)
return label
func _separator() -> HSeparator:
return HSeparator.new()

View file

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

127
src/ui/panels/map_panel.gd Normal file
View file

@ -0,0 +1,127 @@
extends "res://src/ui/panels/editor_panel.gd"
## Map-Eigenschaften (aus main.gd ausgezogen, Karte t_eaccbfed U1 — Logik
## unverändert): Name/Autor/Beschreibung/Version/Wetter + Terrain-Info.
## Eingaben schreiben LIVE ins doc (Live-Bindung wie vorher) und markieren
## dirty — die Signale wurden vorher in _build_right_panel verdrahtet, sie
## hängen jetzt hier.
var prop_name: LineEdit
var prop_author: LineEdit
var prop_desc: LineEdit
var prop_version: SpinBox
var prop_weather: OptionButton
var terrain_info: Label
var _loading_ui_values := false # verhindert dirty beim Auffüllen der Felder
static func spec() -> Dictionary:
return {"titel": "Map", "gruppe": "rechts", "order": 30}
func panel_title() -> String:
return "Map-Eigenschaften"
## Bau (aus der alten main.gd/_build_right_panel übernommen, Logik identisch):
## Name/Autor/Beschreibung/Version/Wetter + Terrain-Info. Die Signale hängen
## jetzt hier (statt in main.gd) — der doc-Datenfluss ist derselbe.
func build() -> void:
add_child(_field_label("Name"))
prop_name = LineEdit.new()
prop_name.max_length = 64
add_child(prop_name)
add_child(_field_label("Autor"))
prop_author = LineEdit.new()
add_child(prop_author)
add_child(_field_label("Beschreibung"))
prop_desc = LineEdit.new()
add_child(prop_desc)
add_child(_field_label("Version"))
prop_version = SpinBox.new()
prop_version.min_value = 1
prop_version.max_value = 99999
prop_version.step = 1
add_child(prop_version)
add_child(_field_label("Wetter"))
prop_weather = OptionButton.new()
prop_weather.add_item("Mittag (sonnig, klar)")
prop_weather.add_item("Abend (warmes Licht)")
prop_weather.add_item("Regen (grau, kühl)")
prop_weather.add_item("Nacht (dunkel)")
add_child(prop_weather)
add_child(_separator())
add_child(_header_label("Terrain"))
terrain_info = Label.new()
terrain_info.text = ""
terrain_info.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
add_child(terrain_info)
# Signale (Änderungen → Dokument + Ansicht) — wie vorher in main.gd.
prop_name.text_changed.connect(_on_prop_changed.bind("name"))
prop_author.text_changed.connect(_on_prop_changed.bind("author"))
prop_desc.text_changed.connect(_on_prop_changed.bind("description"))
prop_version.value_changed.connect(_on_version_changed)
prop_weather.item_selected.connect(_on_weather_changed)
func _on_prop_changed(text: String, field: String) -> void:
if _loading_ui_values or main.doc.is_empty():
return
var info: Dictionary = main.doc["info"]
match field:
"name":
info["name"] = text
"author":
info["author"] = text
"description":
info["description"] = text
main.dirty = true
main.call("_refresh_file_label")
func _on_version_changed(value: float) -> void:
if _loading_ui_values or main.doc.is_empty():
return
main.doc["info"]["version"] = int(value)
main.dirty = true
main.call("_refresh_file_label")
func _on_weather_changed(index: int) -> void:
if _loading_ui_values or main.doc.is_empty():
return
var weathers := ["mittag", "abend", "regen", "nacht"]
main.doc["weather"] = weathers[index]
main.dirty = true
main.call("_refresh_file_label")
# Licht/Stimmung im Viewport sofort anpassen
main.view_builder.call("rebuild", main.doc, main.manifest)
main.call("_sync_grid_to_builder")
main.call("_set_status", "Wetter geändert auf „%s“." % weathers[index])
## Felder aus dem doc auffüllen (aus main.gd _refresh_all-Anfang ausgezogen).
func refresh_values() -> void:
_loading_ui_values = true
var info: Dictionary = main.doc.get("info", {})
prop_name.text = str(info.get("name", ""))
prop_author.text = str(info.get("author", ""))
prop_desc.text = str(info.get("description", ""))
prop_version.value = float(info.get("version", 1))
var weathers := ["mittag", "abend", "regen", "nacht"]
var weather := str(main.doc.get("weather", "mittag"))
prop_weather.select(maxi(0, weathers.find(weather)))
var terrain: Dictionary = main.doc.get("terrain", {})
terrain_info.text = "Größe: %.0f × %.0f m\nAuflösung: %d × %d Höhenpunkte\nHöhenbereich: %.0f bis %.0f m" % [
float(terrain.get("size_x_m", 0.0)), float(terrain.get("size_z_m", 0.0)),
int(terrain.get("resolution", 0)),
int(terrain.get("resolution", 0)),
float(terrain.get("min_height_m", 0.0)), float(terrain.get("max_height_m", 0.0))]
_loading_ui_values = false

View file

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

View file

@ -0,0 +1,34 @@
extends "res://src/ui/panels/editor_panel.gd"
## Objekt-Werkzeuge (aus main.gd ausgezogen, Karte t_eaccbfed U1 — Logik
## unverändert): „Alles auf den Boden setzen" + Raster-Snap-Schalter. Vor U1
## wohnten diese beiden Widgets in der linken Spalte unter der Bibliothek;
## sie sind als eigener Registry-Bereich (Gruppe "unten") erhalten geblieben,
## damit nichts verschwindet.
var snap_btn: Button
var grid_check: CheckButton
static func spec() -> Dictionary:
return {"titel": "Objekt-Werkzeuge", "gruppe": "unten", "order": 20}
func panel_title() -> String:
return "Objekt-Werkzeuge"
func build() -> void:
snap_btn = Button.new()
snap_btn.text = "⚡ Alles auf den Boden setzen"
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(func() -> void: main.call("_on_snap_to_ground"))
add_child(snap_btn)
grid_check = CheckButton.new()
grid_check.text = "Raster-Snap (1 m)"
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(func(pressed: bool) -> void: main.call("_on_grid_snap_changed", pressed))
add_child(grid_check)
add_child(_muted_label("Raster-Snap rastet Platzieren und Ziehen auf ganze Meter."))

View file

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

87
src/ui/panels/registry.gd Normal file
View file

@ -0,0 +1,87 @@
class_name PanelRegistry
extends RefCounted
## Zentrale, DATENGETRIEBENE Anmeldung aller Editor-Bereiche (Karte t_eaccbfed,
## Operator-Vorgabe 20.10.). main.gd baut das Layout AUSSCHLIESSLICH aus dieser
## Registry — keine Panel-Erzeugung mehr fest verdrahtet.
##
## Warum: Die Umbau-Karten U2 (Szenen-Baum), U3 (Gruppen), U4 (Assets),
## U5 (Platzieren nur einmal), U6 (dezente Auswahl) und U7 (Feinschliff) ändern
## danach NUR ihr eigenes Modul und melden sich HIER an (einzeiliger Eintrag)
## — ohne main.gd anzufassen. Dadurch kollidieren parallele Karten nicht mehr
## in derselben Datei (gleiche Falle wie früher bei match_hud.gd).
##
## Struktur je Eintrag: titel (Tab-/Überschrift), gruppe ("rechts" = rechtes
## TabControl, "links" = linke Spalte, "unten" = Assets-Bereich, "toolbar" /
## "status" = Leisten-Aktionen), order (Sortier-Schlüssel), skript (preload-Pfad
## des Panel-Moduls). Ein Bereich erscheint GENAU EINMAL im Baum (test_ui_layout
## prüft das gegen Doppel-Namen).
const TITLE_TOOLBAR := "Werkzeuge"
const TITLE_STATUS := "Statuszeile"
## Die Bereiche in fester Reihenfolge. skript = preload-Pfad; jeder
## Panel-Eintrag muss in seinem Skript `static func spec()` haben, das
## mindestens {"titel", "gruppe", "order"} liefert (die Werte hier sind die
## Authorität — spec() wird von test_ui_layout GEGEN diese Tabelle geprüft).
const PANELS: Array = [
# --- rechts: Tab-Gruppe (Unity-Inspector-Spalte) ---
{"id": "inspektor", "titel": "Inspektor", "gruppe": "rechts", "order": 10,
"skript": "res://src/ui/inspector_panel.gd"},
{"id": "auswahl", "titel": "Auswahl", "gruppe": "rechts", "order": 20,
"skript": "res://src/ui/panels/selection_panel.gd"},
{"id": "map", "titel": "Map", "gruppe": "rechts", "order": 30,
"skript": "res://src/ui/panels/map_panel.gd"},
{"id": "terrain", "titel": "Terrain", "gruppe": "rechts", "order": 40,
"skript": "res://src/ui/panels/terrain_panel.gd"},
{"id": "check", "titel": "Prüfen", "gruppe": "rechts", "order": 50,
"skript": "res://src/ui/panels/check_panel.gd"},
# --- links: Vorbereitet für U2 (Szenen-Baum); Bibliothek ist UNTEN ---
{"id": "szene", "titel": "Map-Inhalt", "gruppe": "links", "order": 10,
"skript": "res://src/ui/panels/scene_tree_panel.gd"},
# --- unten: Objekt-Bibliothek / Assets (Unity-Projekt-Fenster-Platz) ---
{"id": "assets", "titel": "Objekt-Bibliothek", "gruppe": "unten", "order": 10,
"skript": "res://src/ui/panels/assets_panel.gd"},
# Objekt-Werkzeuge (Boden-Setzen/Raster-Snap, vor U1 links) — erhalten
# geblieben als eigener Tab unten, damit nichts verschwindet.
{"id": "werkzeuge", "titel": "Objekt-Werkzeuge", "gruppe": "unten", "order": 20,
"skript": "res://src/ui/panels/object_tools_panel.gd"},
]
## Leisten-Aktionen (Toolbar-Buttons/Menüs — KEINE Panels, melden sich ditto
## datengetrieben an, damit T1s Lauf-Modus und künftige Aktionen main.gd
## nicht mehr anfassen). action = Methodenname an main (Callable wird gebunden).
const ACTIONS: Array = [
{"id": "karte_pruefen", "titel": "✅ Karte prüfen", "gruppe": "toolbar",
"order": 10, "action": "_on_check_map"},
{"id": "karte_testen", "titel": "▶ Karte testen", "gruppe": "toolbar",
"order": 20, "action": "_on_test_run"},
]
## Panel-Einträge einer Gruppe, nach order sortiert (Reihenfolge = Tab-Reihenfolge).
static func entries(gruppe: String) -> Array:
var out: Array = []
for e in PANELS:
if str(e["gruppe"]) == gruppe:
out.append(e)
out.sort_custom(func(a, b) -> bool: return int(a["order"]) < int(b["order"]))
return out
## Toolbar-Aktionen, nach order sortiert.
static func toolbar_actions() -> Array:
var out: Array = []
for e in ACTIONS:
if str(e["gruppe"]) == "toolbar":
out.append(e)
out.sort_custom(func(a, b) -> bool: return int(a["order"]) < int(b["order"]))
return out
## Eintrag per ID (für Tests/Probes).
static func entry(id: String) -> Dictionary:
for e in PANELS:
if str(e["id"]) == id:
return e
return {}

View file

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

View file

@ -0,0 +1,155 @@
extends "res://src/ui/panels/editor_panel.gd"
## Map-Inhalt-Baum (aus main.gd ausgezogen, Karte t_eaccbfed U1 — Logik
## unverändert). U2 baut hier den hierarchischen Szenen-Baum aus; diese Karte
## zieht nur um: Der Baum lebt jetzt im LINKEN TabContainer (Registry-Gruppe
## "links"), Zustand/Signale wie vorher.
var content_tree: Tree
var _tree_select_lock := false # verhindert Rückkopplung Auswahl ↔ Baum
static func spec() -> Dictionary:
return {"titel": "Map-Inhalt", "gruppe": "links", "order": 10}
func build() -> void:
add_child(_header_label("Map-Inhalt"))
content_tree = Tree.new()
content_tree.size_flags_vertical = Control.SIZE_EXPAND_FILL
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
content_tree.item_selected.connect(_on_tree_selected)
add_child(content_tree)
## Der Baum ruft zurück, wenn der Nutzer dort ein Objekt auswählt.
## SELECT_MULTI (Karte t_c46d31c2): einfacher Klick = Ersatz-Auswahl,
## Strg+Klick addiert/entfernt nativ — ALLE markierten Objekt-Zeilen spiegeln
## sich in die Viewport-Auswahl (Baum und Szene sind immer synchron).
func _on_tree_selected() -> void:
if _tree_select_lock:
return
var item := content_tree.get_selected()
if item == null:
return
if item.has_meta("gebaeude_index"):
_on_tree_selected_gebaeude(int(item.get_meta("gebaeude_index")))
return
var indices: Array = []
var walk := content_tree.get_next_selected(null)
while walk != null:
if walk.has_meta("obj_index"):
indices.append(int(walk.get_meta("obj_index")))
walk = content_tree.get_next_selected(walk)
if indices.is_empty():
return
var objects: Array = main.doc.get("objects", [])
var valid: Array = []
for idx in indices:
if idx >= 0 and idx < objects.size():
valid.append(idx)
if valid.is_empty():
return
if valid.size() == 1:
main.interaction.select_object(valid[0], true)
main.call("_set_status", "%s ausgewählt — ziehen zum Verschieben, R/Mausrad zum Drehen, Entf zum Löschen. Strg+Klick markiert weitere dazu." % main.object_label(str(objects[valid[0]].get("id", ""))))
else:
main.interaction.select_many(valid)
main.call("_set_status", "%d Objekte im Baum markiert — Entf löscht ALLE, Strg+D dupliziert ALLE (jeweils EIN Undo-Schritt)." % valid.size())
## Baum: Bauteil-Klick → Bauen-Modus + Teil anwählen (Karte t_97ea59c5).
func _on_tree_selected_gebaeude(gi: int) -> void:
if str(main.get("tool_mode")) != "bauen":
main.call("_set_tool_mode", "bauen")
main.build_tool.call("select_index", gi)
func mark_tree_selection(idx: int) -> void:
mark_tree_selection_multi([idx])
func mark_tree_selection_multi(indices: Array) -> void:
_tree_select_lock = true
var wanted := {}
for i in indices:
wanted[int(i)] = true
_mark_tree_selection_walk(content_tree.get_root(), wanted)
var primary: Variant = main.interaction.get("selected_index") if main.interaction != null else -1
if primary is int and primary >= 0:
_ensure_tree_visible(content_tree.get_root(), primary)
_tree_select_lock = false
func _mark_tree_selection_walk(item: TreeItem, wanted: Dictionary) -> void:
if item == null:
return
if item.has_meta("obj_index"):
var idx := int(item.get_meta("obj_index"))
if wanted.has(idx):
item.select(0)
elif item.is_selected(0):
item.deselect(0)
for child in item.get_children():
_mark_tree_selection_walk(child, wanted)
func _ensure_tree_visible(item: TreeItem, idx: int) -> void:
if item == null:
return
if item.has_meta("obj_index") and int(item.get_meta("obj_index")) == idx:
# Fix (beim Inspektor-Einbau gefunden, Vorbestand t_f6f29d86): Tree
# und TreeItem haben KEIN ensure_visible — die Godot-4-API heißt
# Tree.scroll_to_item. Der alte Ruf warf bei JEDER Auswahl einen
# SCRIPT ERROR und scrollte den Baum nie zur Auswahl.
content_tree.scroll_to_item(item)
return
for child in item.get_children():
_ensure_tree_visible(child, idx)
func refresh_tree() -> void:
content_tree.clear()
var root := content_tree.create_item()
var objects: Array = main.doc.get("objects", [])
var obj_item := content_tree.create_item(root)
obj_item.set_text(0, "Objekte (%d)" % objects.size())
for i in objects.size():
var obj = objects[i]
var item := content_tree.create_item(obj_item)
if obj is Dictionary:
item.set_text(0, "%s #%d" % [main.object_label(str(obj.get("id", "?"))), i + 1])
item.set_meta("obj_index", i)
else:
item.set_text(0, "ungültig #%d" % (i + 1))
var zones: Dictionary = main.doc.get("spawn_zones", {})
var spawn_item := content_tree.create_item(root)
spawn_item.set_text(0, "Spawn-Zonen")
for team_key in ["0", "1"]:
if not zones.has(team_key):
continue
var zone: Dictionary = zones[team_key]
var team_item := content_tree.create_item(spawn_item)
var team_name := "Team Blau" if team_key == "0" else "Team Rot"
team_item.set_text(0, "%s (%d Punkte)" % [team_name, (zone.get("points", []) as Array).size()])
for p in (zone.get("points", []) as Array):
if p is Dictionary:
var point_item := content_tree.create_item(team_item)
point_item.set_text(0, "%s (%.0f, %.0f)" % [
str(p.get("id", "?")), float(p.get("pos", [0, 0, 0])[0]), float(p.get("pos", [0, 0, 0])[2])])
# Gebäude (Karte t_97ea59c5): Bauteile im Baum — Klick wählt das Teil an.
var geb_list: Array = BuildOps.pieces(main.doc)
if not geb_list.is_empty():
var geb_item := content_tree.create_item(root)
geb_item.set_text(0, "Gebäude (%d Bauteile)" % geb_list.size())
for i in geb_list.size():
var teil: Dictionary = geb_list[i]
var g_child := content_tree.create_item(geb_item)
g_child.set_text(0, "%s #%d" % [BuildOps.kind_label(str(teil.get("id", "?"))), i + 1])
g_child.set_meta("gebaeude_index", i)

View file

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

View file

@ -0,0 +1,234 @@
extends "res://src/ui/panels/editor_panel.gd"
## Auswahl-Panel (U1, Karte t_eaccbfed — aus main.gd ausgezogen, Logik
## UNVERÄNDERT von dort übernommen): Buttons Drehen/Löschen/Duplizieren/
## Spiegeln/Anheben/Absenken + Gruppen-Buttons + Gizmo-Schrittweite.
##
## Verdrahtung: main.gd ruft sync_selection() an allen Auswahl-Wegen
## (vorher: _sync_selection_panel) und selection_status(idx) bei frischer
## Auswahl (vorher: _selection_status). Alle Objekt-Operationen laufen über
## main.interaction — der Vertrag zu interaction.gd bleibt identisch.
var sel_label: Label
var sel_rotate_btn: Button
var sel_delete_btn: Button
var sel_dup_btn: Button
var sel_mirror_x_btn: Button
var sel_mirror_z_btn: Button
var sel_raise_btn: Button
var sel_lower_btn: Button
var sel_group_btn: Button
var sel_ungroup_btn: Button
var gizmo_step_box: SpinBox
static func spec() -> Dictionary:
return {"titel": "Auswahl", "gruppe": "rechts", "order": 20}
func panel_title() -> String:
return "Auswahl"
func build() -> void:
# --- Auswahl (Anfassen/Greifen) — 1:1 aus der alten _build_right_panel ---
sel_label = Label.new()
sel_label.text = "Nichts ausgewählt.\nKlicke im Viewport ein Objekt an."
sel_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
add_child(sel_label)
var sel_btns := HBoxContainer.new()
sel_btns.add_theme_constant_override("separation", UIScale.px(8))
add_child(sel_btns)
sel_rotate_btn = Button.new()
sel_rotate_btn.text = "Drehen (R)"
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: main.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 = 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(main.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", UIScale.px(8))
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.",
func() -> void: main.interaction.duplicate_selected())
dup_row.add_child(sel_dup_btn)
var mirror_row := HBoxContainer.new()
mirror_row.add_theme_constant_override("separation", UIScale.px(8))
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.",
func() -> void: main.interaction.mirror_selected("x"))
mirror_row.add_child(sel_mirror_x_btn)
sel_mirror_z_btn = _sel_op_button("⇅ Vor↔Zurück",
"Spiegelt das Objekt an seiner Stelle (vor↔zurück, Z-Achse) — die Blickrichtung wird mitgespiegelt.",
func() -> void: main.interaction.mirror_selected("z"))
mirror_row.add_child(sel_mirror_z_btn)
var lift_row := HBoxContainer.new()
lift_row.add_theme_constant_override("separation", UIScale.px(8))
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.",
func() -> void: main.interaction.lift_selected(ObjectOps.LIFT_STEP_M))
lift_row.add_child(sel_raise_btn)
sel_lower_btn = _sel_op_button("▼ Absenken (Bild↓)",
"Objekt 0,25 m absenken — stoppt am Boden (nichts wird in Hügel begraben). Bild↓ geht auch.",
func() -> void: main.interaction.lift_selected(-ObjectOps.LIFT_STEP_M))
lift_row.add_child(sel_lower_btn)
# Gruppen-Buttons (Karte t_f6f29d86): Mehrfachauswahl → Gruppe
var group_btns := HBoxContainer.new()
group_btns.add_theme_constant_override("separation", UIScale.px(8))
add_child(group_btns)
sel_group_btn = Button.new()
sel_group_btn.text = "🔗 Gruppieren"
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: main.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 = 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: main.interaction.ungroup_selection())
group_btns.add_child(sel_ungroup_btn)
add_child(_muted_label("Mehrfachauswahl: Umschalt+Klick oder Rahmen aufziehen. Eine Gruppe wandert und dreht gemeinsam — auch nach Speichern/Laden."))
# --- Gizmo-Einstellungen (Karte t_e0120dc2): Schrittweite der Achsen-
# Züge. Das Mausrad WÄHREND eines Gizmo-Zugs blättert durch die
# Voreinstellungen (fein/normal/grob); dieses Feld setzt sie exakt und
# überlebt UI-Rebuilds, weil der Wert in main.gizmo_step_m liegen bleibt.
add_child(_header_label("Gizmo-Einstellungen"))
var gizmo_row := HBoxContainer.new()
gizmo_row.add_theme_constant_override("separation", UIScale.px(8))
add_child(gizmo_row)
gizmo_row.add_child(_field_label("Schrittweite"))
gizmo_step_box = SpinBox.new()
gizmo_step_box.min_value = 0.01
gizmo_step_box.max_value = 10.0
gizmo_step_box.step = 0.01
gizmo_step_box.value = main.gizmo_step_m
gizmo_step_box.custom_minimum_size = UIScale.v2(110, 30)
gizmo_step_box.tooltip_text = "Wie weit bewegt sich das Objekt am Achsen-Gizmo pro Schritt (in Metern). Mausrad WÄHREND des Ziehens wechselt zwischen fein (0.1) / normal (0.5) / grob (2.0)."
gizmo_row.add_child(gizmo_step_box)
gizmo_step_box.value_changed.connect(func(v: float) -> void:
main.gizmo_step_m = v
if main.gizmo != null and is_instance_valid(main.gizmo):
main.gizmo.set_step_m(v)
)
add_child(_separator())
## Ein Auswahl-Button mit Klartext-Tipp (aus main.gd übernommen).
func _sel_op_button(text: String, tip: String, action: Callable) -> Button:
var btn := Button.new()
btn.text = text
btn.custom_minimum_size = UIScale.v2(140, 40)
btn.tooltip_text = tip
btn.pressed.connect(action)
return btn
## Objekt-Inspektor + Auswahl-Panel synchron halten (vorher
## main.gd/_sync_selection_panel — Logik unverändert).
func sync_selection() -> void:
# Objekt-Inspektor mit allen Auswahl-Wege synchron halten (Karte t_bb0d11c9).
if main.inspector != null:
main.inspector.call("refresh")
# Gizmo-Schrittweite-Feld mitziehen (Karte t_e0120dc2): Das Mausrad
# WÄHREND eines Zugs kann sie im Gizmo ändern (cycle_step) — das Panel-
# Feld folgt ohne eigene value_changed-Rückkopplung (set_value_no_signal).
if gizmo_step_box != null and is_instance_valid(gizmo_step_box) \
and not gizmo_step_box.has_focus():
gizmo_step_box.set_value_no_signal(main.gizmo_step_m)
var doc: Dictionary = main.doc
var objects: Array = doc.get("objects", [])
var sel: Array = main.interaction.selected_indices
var valid: Array = []
for i in sel:
if i is int and i >= 0 and i < objects.size():
valid.append(i)
var has_sel := not valid.is_empty()
for btn in [sel_rotate_btn, sel_delete_btn, sel_dup_btn, sel_mirror_x_btn,
sel_mirror_z_btn, sel_raise_btn, sel_lower_btn]:
btn.disabled = not has_sel
var has_group := false
for i in valid:
if GroupOps.group_id(objects[i]) != GroupOps.UNGROUPED:
has_group = true
break
sel_group_btn.disabled = valid.size() < 2
sel_ungroup_btn.disabled = not has_group
if not has_sel:
sel_label.text = "Nichts ausgewählt.\nKlicke im Viewport ein Objekt an."
return
if valid.size() == 1:
var idx: int = valid[0]
var obj: Dictionary = objects[idx]
var pos: Array = obj.get("pos", [0.0, 0.0, 0.0])
var lift := ObjectOps.lift_offset(doc, idx)
var lift_text := "am Boden" if absf(lift) < 0.02 else ("%+.2f m über dem Boden" % lift)
var group_line := ""
var gid := GroupOps.group_id(obj)
if gid != GroupOps.UNGROUPED:
group_line = "\nGruppe %d — zieht & dreht GEMEINSAM (Lösen: Button unten)" % (gid + 1)
sel_label.text = "%s (Objekt %d von %d)\nPosition: %.0f, %.0f m · Höhe: %s · Richtung: %.0f°%s\nZiehen: verschieben · R oder Mausrad: drehen (15°)\nBild↑/↓: anheben/absenken · Strg+D: duplizieren\nSpiegeln/Löschen: Buttons oben · Strg+Z: zurück" % [
main.object_label(str(obj.get("id", ""))), idx + 1, objects.size(),
float(pos[0]), float(pos[2]), lift_text, float(obj.get("rot_y_deg", 0.0)), group_line]
return
# Mehrfachauswahl: Liste aller gewählten Objekte (max. 8 Zeilen, dann „…“).
var names := PackedStringArray()
for i in valid:
if names.size() < 8:
names.append(main.object_label(str((objects[i] as Dictionary).get("id", "?"))))
var more := ""
if valid.size() > 8:
more = "\n… und %d weitere" % (valid.size() - 8)
sel_label.text = "%d Objekte ausgewählt:\n%s%s\nGemeinsam: ziehen (Gruppe wandert zusammen), drehen um die Mitte, Entf löscht alle." % [
valid.size(), " · ".join(names), more]
## Hilfs-Meldung zur frischen Auswahl (Klartext + Budget-Warnung + nächster
## Schritt) — vorher main.gd/_selection_status, Logik unverändert.
func selection_status(idx: int) -> void:
# Objekt-Inspektor füllen (Karte t_bb0d11c9) — dieser Weg läuft bei
# 3D-Klick, Rahmen-Ausziehen und Map-Inhalt-Baum.
if main.inspector != null:
main.inspector.call("refresh")
# Fix (Vorbestand, beim Inspektor-Einbau gefunden): select_object/
# select_many füllten das alte „Auswahl“-Feld NIE — sel_label blieb auf
# „Nichts ausgewählt“ stehen, während die Statuszeile das Objekt nannte.
# sync_selection ändert bewusst KEINEN Statuszeilen-Text (Stummsync).
sync_selection()
var doc: Dictionary = main.doc
var objects: Array = doc.get("objects", [])
var sel: Array = main.interaction.selected_indices
if (idx < 0 or idx >= objects.size()) and sel.is_empty():
main._base_tool_status()
return
var used := int(SvmapDocument.poly_usage(doc, main.manifest))
var budget := int(SvmapIO.MAX_TOTAL_POLY)
var budget_warning := ""
if used > budget * 80 / 100:
budget_warning = " ⚠ Poly-Budget zu %.0f %% voll — bald passt kein Objekt mehr rein." % (100.0 * used / budget)
if sel.size() > 1:
main._set_status("%d Objekte ausgewählt — ziehen bewegt ALLE gemeinsam, R/Mausrad dreht sie um die Mitte, Entf löscht alle, Strg+G gruppiert.%s" % [sel.size(), budget_warning])
return
if idx < 0 or idx >= objects.size():
main._base_tool_status()
return
var obj: Dictionary = objects[idx]
var gid := GroupOps.group_id(obj)
var group_hint := ""
if gid != GroupOps.UNGROUPED:
group_hint = " (Gruppe %d — die zieht GEMEINSAM)" % (gid + 1)
main._set_status("%s%s ausgewählt — ziehen zum Verschieben, R oder Mausrad zum Drehen, Bild↑/↓ anheben/absenken, Strg+D duplizieren, Entf zum Löschen.%s" % [
main.object_label(str(obj.get("id", ""))), group_hint, budget_warning])

View file

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

View file

@ -0,0 +1,58 @@
extends "res://src/ui/panels/editor_panel.gd"
## Statusleiste (aus main.gd ausgezogen, Karte t_eaccbfed U1 — Logik
## unverändert): Hilfs-Meldungen + permanente Werkzeug-Anzeige +
## Symmetrie-Anzeige + Objekt-/Terrain-Zähler + FPS-Timer.
var status_label: Label
var tool_label: Label # permanenter Werkzeugmodus + Flug-Hint
var sym_label: Label
var stats_objects: Label
var stats_terrain: Label
static func spec() -> Dictionary:
return {"titel": "Statuszeile", "gruppe": "status", "order": 0}
func build() -> void:
add_theme_constant_override("separation", UIScale.px(16))
status_label = Label.new()
status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
status_label.clip_text = true # langer Text darf das Layout NICHT sprengen
add_child(status_label)
# Karte 3/3 (t_11b5c25b): PERMANENTE Werkzeug-Anzeige — Sly sieht immer,
# 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
add_child(tool_label)
# Symmetrie-Anzeige (Karte t_f5935023, M1): Sly sieht in der Statuszeile
# immer, ob/wie gespiegelt wird (Standard: Punkt).
sym_label = Label.new()
sym_label.modulate = Color(0.55, 0.8, 1.0)
sym_label.clip_text = true
sym_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
add_child(sym_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
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
add_child(stats_terrain)
var fps_timer := Timer.new()
fps_timer.wait_time = 0.5
fps_timer.autostart = true
fps_timer.timeout.connect(main.call.bind("_refresh_stats"))
add_child(fps_timer)

View file

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

View file

@ -0,0 +1,279 @@
extends "res://src/ui/panels/editor_panel.gd"
## Terrain-Werkzeug-Panel (aus main.gd ausgezogen, Karte t_eaccbfed U1 —
## Logik unverändert, Karte t_9aba04f1 + t_603ec1a8): großer Modus-Button +
## Pinsel-Modi, Material-Palette, Größe/Stärke-Regler. Werkzeug-Ein/Aus bleibt
## im Koordinator (main.gd _enter/_leave terrain), das Panel hält nur die
## Widgets und die Regler-Verdrahtung.
var terrain_edit_btn: Button
var terrain_panel: VBoxContainer
var terrain_mode_raise: Button
var terrain_mode_lower: Button
var terrain_mode_smooth: Button
var terrain_mode_paint: Button
var terrain_palette: VBoxContainer
var terrain_mat_buttons: Array = [] # Gras/Sand/Fels (ButtonGroup)
var terrain_size_btn_small: Button
var terrain_size_btn_medium: Button
var terrain_size_btn_large: Button
var terrain_radius_slider: HSlider
var terrain_radius_label: Label
var terrain_strength_slider: HSlider
var terrain_strength_label: Label
# --- Bauen (Karte t_97ea59c5) — im selben Werkzeug-Tab (aus main.gd ausgezogen)
var build_edit_btn: Button
var build_panel: VBoxContainer
var build_kind_buttons: Dictionary = {} # kind_id → Button
static func spec() -> Dictionary:
return {"titel": "Terrain", "gruppe": "rechts", "order": 40}
func build() -> void:
terrain_edit_btn = Button.new()
terrain_edit_btn.text = "⛰ Terrain formen"
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)
add_child(terrain_edit_btn)
terrain_panel = VBoxContainer.new()
terrain_panel.add_theme_constant_override("separation", UIScale.px(6))
terrain_panel.visible = false
add_child(terrain_panel)
terrain_panel.add_child(_field_label("Pinsel-Werkzeug:"))
var mode_row := HBoxContainer.new()
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)
terrain_mode_lower = _terrain_mode_button("Senken ⬇", mode_group)
terrain_mode_smooth = _terrain_mode_button("Glätten ≈", mode_group)
terrain_mode_paint = _terrain_mode_button("Malen 🖌", mode_group)
terrain_mode_raise.button_pressed = true
terrain_mode_raise.toggled.connect(_on_terrain_mode_changed)
terrain_mode_lower.toggled.connect(_on_terrain_mode_changed)
terrain_mode_smooth.toggled.connect(_on_terrain_mode_changed)
terrain_mode_paint.toggled.connect(_on_terrain_mode_changed)
mode_row.add_child(terrain_mode_raise)
mode_row.add_child(terrain_mode_lower)
mode_row.add_child(terrain_mode_smooth)
mode_row.add_child(terrain_mode_paint)
# Material-Palette (Karte t_603ec1a8): nur sichtbar im Malen-Modus.
terrain_palette = VBoxContainer.new()
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", 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 = 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])
if mat_id == 0:
mat_btn.button_pressed = true
mat_btn.toggled.connect(_on_terrain_material_changed.bind(mat_id))
terrain_mat_buttons.append(mat_btn)
mat_row.add_child(mat_btn)
# 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", 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)
terrain_size_btn_medium = _terrain_size_button("Mittel\n(12 m)", size_group, 12.0)
terrain_size_btn_large = _terrain_size_button("Groß\n(32 m)", size_group, 32.0)
# Kein Signal: Der Handler greift auf terrain_radius_slider zu, der erst
# unten erzeugt wird — beim Panel-Bau wäre das ein Crash (GUI-Start-Bug
# 15.10.). Der Slider startet unten passend mit 12 m (= Mittel).
terrain_size_btn_medium.set_pressed_no_signal(true)
size_row.add_child(terrain_size_btn_small)
size_row.add_child(terrain_size_btn_medium)
size_row.add_child(terrain_size_btn_large)
terrain_radius_label = _field_label("Pinsel-Größe: 12 m")
terrain_panel.add_child(terrain_radius_label)
terrain_radius_slider = HSlider.new()
terrain_radius_slider.min_value = 2.0
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 = UIScale.v2(0, 32)
terrain_radius_slider.value_changed.connect(_on_terrain_radius_changed)
terrain_panel.add_child(terrain_radius_slider)
terrain_strength_label = _field_label("Stärke: 8 m pro Sekunde")
terrain_panel.add_child(terrain_strength_label)
terrain_strength_slider = HSlider.new()
terrain_strength_slider.min_value = 1.0
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 = UIScale.v2(0, 32)
terrain_strength_slider.value_changed.connect(_on_terrain_strength_changed)
terrain_panel.add_child(terrain_strength_slider)
terrain_panel.add_child(_muted_label(
"Linksklick-Ziehen formt bzw. malt (je nach Werkzeug), Strg+Linksklick macht das Gegenteil — im Malen-Modus malt Strg+Klick Gras zurück. "
+ "Jeder Strich ist EIN Rückgängig-Schritt (Strg+Z). Rechte Maustaste = Flug, ESC beendet den Terrain-Modus."))
# --- Bauen (Karte t_97ea59c5): Modular-Builder-Pieces im App-Skelett ---
# (aus main.gd ausgezogen — liegt mit im Terrain-Tab, Werkzeug-Zusammenhang)
build_edit_btn = Button.new()
build_edit_btn.text = "🏠 Bauen (Häuser)"
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)
add_child(build_edit_btn)
build_panel = VBoxContainer.new()
build_panel.add_theme_constant_override("separation", UIScale.px(6))
build_panel.visible = false
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", 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():
var kind_btn := Button.new()
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 = 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)))
build_kind_buttons[str(kind_id)] = kind_btn
build_grid.add_child(kind_btn)
(build_kind_buttons["fundament"] as Button).set_pressed_no_signal(true)
build_panel.add_child(_muted_label(
"Linksklick setzt das Bauteil aufs 2-m-Raster — auch AUF andere Teile "
+ "(Wände aufs Fundament stapeln, Dach auf die Wände). Jedes Bauteil ist "
+ "EIN Rückgängig-Schritt (Strg+Z). ESC beendet den Bauen-Modus."))
func _on_build_btn_pressed() -> void:
if build_edit_btn.button_pressed:
main.call("_set_tool_mode", "bauen")
else:
main.call("_set_tool_mode", "objects")
func _on_build_kind_pressed(kind_id: String) -> void:
main.call("_on_build_kind_pressed", kind_id)
func _on_terrain_btn_pressed() -> void:
if terrain_edit_btn.button_pressed:
main.call("_set_tool_mode", "terrain")
else:
main.call("_set_tool_mode", "objects")
func _terrain_mode_button(text: String, group: ButtonGroup) -> Button:
var btn := Button.new()
btn.text = text
btn.toggle_mode = true
btn.button_group = group
btn.custom_minimum_size = UIScale.v2(0, 40)
return btn
func _selected_terrain_mode() -> int:
if terrain_mode_lower.button_pressed:
return TerrainField.Mode.LOWER
if terrain_mode_smooth.button_pressed:
return TerrainField.Mode.SMOOTH
if terrain_mode_paint.button_pressed:
return TerrainField.Mode.PAINT
return TerrainField.Mode.RAISE
## Mode-Buttons (ButtonGroup) → Pinsel-Modus; Palette nur im Malen-Modus.
func _on_terrain_mode_changed(_pressed: bool) -> void:
if terrain_palette != null:
terrain_palette.visible = _selected_terrain_mode() == TerrainField.Mode.PAINT
if main.terrain_tool != null:
main.terrain_tool.call("set_brush_mode", _selected_terrain_mode())
## Material-Palette (Karte t_603ec1a8): gewähltes Material an den Pinsel.
func _on_terrain_material_changed(pressed: bool, mat_id: int) -> void:
if pressed and main.terrain_tool != null:
main.terrain_tool.call("set_paint_material", mat_id)
## Pinselgrößen-Presets (Karte t_603ec1a8): setzen den Radius-Slider
## (der Label-Text + Ring-Optik hängen am value_changed-Signal).
func _terrain_size_button(text: String, group: ButtonGroup, radius_m: float) -> Button:
var btn := Button.new()
btn.text = text
btn.toggle_mode = true
btn.button_group = group
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
func _on_terrain_size_preset(pressed: bool, radius_m: float) -> void:
# Null-Check als Schutznetz: beim Panel-Bau existiert der Slider noch
# nicht (Programmatic-Pressed darf den Handler nie früh treffen).
if pressed and terrain_radius_slider != null:
terrain_radius_slider.value = radius_m
func _on_terrain_radius_changed(value: float) -> void:
terrain_radius_label.text = "Pinsel-Größe: %d m" % int(value)
if main.terrain_tool != null:
main.terrain_tool.call("set_radius", value)
func _on_terrain_strength_changed(value: float) -> void:
terrain_strength_label.text = "Stärke: %.1f m pro Sekunde" % value
if main.terrain_tool != null:
main.terrain_tool.call("set_strength", value)
## Terrain-Pinsel-Größe per [ / ] (Karte t_55257792): durch die 3 Presets
## Klein/Mittel/Groß zyklisch — der Preset-Button wird mit gesetzt (set_pressed
## feuert toggled → Slider + Label + Pinssel-Ring wandern mit).
## (Aus main.gd ausgezogen, Logik unverändert; Koordinator ruft weiter.)
func terrain_brush_size_step(up: bool) -> void:
if terrain_radius_slider == null:
return
var next := MoveMath.brush_next_preset(float(terrain_radius_slider.value), up)
var btn: Button = null
match next:
4.0:
btn = terrain_size_btn_small
12.0:
btn = terrain_size_btn_medium
32.0:
btn = terrain_size_btn_large
if btn != null:
btn.set_pressed(true) # feuert _on_terrain_size_preset → Slider/Label
else:
terrain_radius_slider.value = next
main.terrain_tool.call("set_radius", next)
main.call("_set_status", "Pinsel-Größe: %d m (mit [ / ] durch Klein/Mittel/Groß zyklisch)." % int(next))

View file

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

157
src/ui/panels/toolbar.gd Normal file
View file

@ -0,0 +1,157 @@
extends "res://src/ui/panels/editor_panel.gd"
## Toolbar (aus main.gd ausgezogen, Karte t_eaccbfed U1 — Logik unverändert):
## Menü Datei + Undo/Redo + Gitter/Höhenlinien + Symmetrie + Toolbar-Aktionen
## aus der Registry (Karte prüfen, ▶ Karte testen). Neue Aktionen melden sich
## künftig in registry.gd an (U2–U7, T1-F5 bleibt unverändert erreichbar).
var undo_btn: Button
var redo_btn: Button
var grid_btn: Button # „⊞ Gitter“ (Karte t_7ac70014)
var contours_btn: Button # „≈ Höhenlinien“ (Karte t_3ebe4086)
var sym_menu: MenuButton
var size_menu: MenuButton # „UI-Größe“ (Karte t_297221b5)
var title_label: Label
var file_label: Label
static func spec() -> Dictionary:
return {"titel": "Toolbar", "gruppe": "toolbar", "order": 0}
func build() -> void:
add_theme_constant_override("separation", UIScale.px(10))
var file_menu := MenuButton.new()
file_menu.text = "Datei"
file_menu.flat = false
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)
popup.add_item("Kartengröße ändern…", 6)
popup.add_separator()
popup.add_item("Speichern (Strg+S)", 2)
popup.add_item("Speichern unter…", 3)
popup.add_separator()
popup.add_item("▶ Karte testen", 5)
popup.add_separator()
popup.add_item("🔄 Updates prüfen", 7)
popup.add_item("Beenden", 4)
popup.id_pressed.connect(main.call.bind("_on_menu_action"))
add_child(file_menu)
undo_btn = Button.new()
undo_btn.text = "↩ Rückgängig"
undo_btn.custom_minimum_size = UIScale.v2(140, 40)
undo_btn.tooltip_text = "Letzte Aktion zurücknehmen (Strg+Z)"
undo_btn.pressed.connect(main.call.bind("_do_undo"))
add_child(undo_btn)
redo_btn = Button.new()
redo_btn.text = "↪ Wiederholen"
redo_btn.custom_minimum_size = UIScale.v2(150, 40)
redo_btn.tooltip_text = "Zurückgenommene Aktion wiederherstellen (Strg+Y)"
redo_btn.pressed.connect(main.call.bind("_do_redo"))
add_child(redo_btn)
# Maßstabsgitter-Toggle (Karte t_7ac70014): über sichtbarem Terrain ist das
# Gitter standardmäßig AUS (das Terrain ist die Bodendarstellung); wer das
# 100-m-Raster als Planungshilfe will, schaltet es hier ohne Rebuild zu.
grid_btn = Button.new()
grid_btn.text = "⊞ Gitter"
grid_btn.toggle_mode = true
grid_btn.button_pressed = false
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(func(pressed: bool) -> void: main.call("_on_grid_toggled", pressed))
add_child(grid_btn)
# Höhenlinien-Button (Karte t_3ebe4086, Kopplung t_89c0ec95): drücken =
# LINIEN FIXIEREN (immer sichtbar, in jedem Werkzeug). Ohne Fix sind die
# Linien NUR im Terrain-Werkzeug (F1) sichtbar; auf flachen Maps (< 2 m
# Gesamtrelief) schaltet das Terrain-Mesh zusätzlich selbst auf aus.
contours_btn = Button.new()
contours_btn.text = "≈ Höhenlinien"
contours_btn.toggle_mode = true
contours_btn.button_pressed = false
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(func(pressed: bool) -> void: main.call("_on_contours_toggled", pressed))
add_child(contours_btn)
# Symmetrie-Schalter (Karte t_f5935023, M1): Aus / Punkt / Achse X / Achse Z.
# Standard = Punktspiegelung (Sly 20.10.). M1 setzt nur Modus + Anzeige;
# das gespiegelte Bauen selbst kommt mit M2–M4.
sym_menu = MenuButton.new()
sym_menu.text = "Symmetrie: Punkt"
sym_menu.flat = false
sym_menu.custom_minimum_size = UIScale.v2(160, 40)
sym_menu.tooltip_text = "Gespiegeltes Bauen: Änderungen werden automatisch auf die andere Kartenseite übertragen. Aus / Punkt (180° um die Kartenmitte) / Achse X / Achse Z. Standard: Punkt."
var sym_popup := sym_menu.get_popup()
for i in SymmetryCore.MODE_LABELS.size():
sym_popup.add_check_item(SymmetryCore.MODE_LABELS[i], i)
sym_popup.index_pressed.connect(func(index: int) -> void: main.call("_on_sym_mode_selected", index))
add_child(sym_menu)
# Toolbar-Aktionen DATENGETRIEBEN aus der Registry (Operator-Vorgabe
# 20.10.): Karte prüfen, ▶ Karte testen — künftige Karten melden sich
# hier an, ohne main.gd anzufassen. Die Buttons bekommen den Registry-Namen
# als Node-Namen (main kann sie per get_node/find_child ansprechen, z. B.
# der Shake-Effekt beim fehlgeschlagenen Map-Check).
for entry in PanelRegistry.toolbar_actions():
var btn := Button.new()
btn.text = str(entry["titel"])
btn.name = str(entry["id"])
if str(entry["id"]) == "karte_pruefen":
btn.custom_minimum_size = UIScale.v2(170, 40)
btn.tooltip_text = "Prüft, ob die Map bereit für den Export ist — sagen wir VORHER, was fehlt"
else:
btn.custom_minimum_size = UIScale.v2(190, 40)
btn.tooltip_text = "Startet die Map SOFORT als Spiel zum Durchlaufen — eigenes Fenster, der Editor bleibt offen. ESC im Spiel = zurück zum Editor."
btn.pressed.connect(main.call.bind(str(entry["action"])))
add_child(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)
# Häkchen-Sync macht der KOORDINATOR nach _pull_toolbar_refs (hier ist
# main.size_menu noch null — alter Reihenfolge-Bug, Karte t_eaccbfed).
size_popup.index_pressed.connect(func(index: int) -> void: main.call("_on_ui_size_selected", index))
add_child(size_menu)
title_label = Label.new()
title_label.text = " %s" % main.APP_TITLE
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
add_child(title_label)
var spacer := Control.new()
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
add_child(spacer)
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
add_child(file_label)

View file

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

155
tests/test_ui_layout.gd Normal file
View file

@ -0,0 +1,155 @@
extends SceneTree
## test_ui_layout (Karte t_eaccbfed, U1 UI-Fundament): prüft das Unity-artige
## Layout, das AUSSCHLIESSLICH aus der Registry (src/ui/panels/registry.gd)
## gebaut wird.
##
## Geprüft wird:
## 1. Alle Registry-Bereiche existieren GENAU EINMAL im Baum (kein Doppel,
## kein Verwaister) und als Tab mit dem Registry-Titel erreichbar sind.
## 2. Die Tab-Gruppen stimmen: rechts = 5 Tabs, links = 1, unten = 2.
## 3. Assets-Bereich unten vorhanden (Objekt-Bibliothek mit Suche + Kacheln).
## 4. Theme gesetzt (theme/editor_theme.tres als einzige Farbquelle).
## 5. Keine doppelten Node-Namen im UI-Subbaum.
## 6. main.gd hält die bekannten Werkzeug-/Test-Referenzen (Vertrag).
const UIScaleStepDefault := 0 # (Lesbarkeit; Stufen sind nicht Teil dieser Suite)
var _fails := 0
var _oks := 0
func _check(cond: bool, what: String) -> void:
if cond:
_oks += 1
print(" ok: ", what)
else:
_fails += 1
print(" FAIL: ", what)
func _init() -> void:
print("Testgruppe: Layout aus der Registry")
var scene: PackedScene = load("res://scenes/main.tscn")
var main: Control = scene.instantiate()
root.add_child(main)
await process_frame
await process_frame
await process_frame
# --- 1. Registry-Tabelle ist die Autorität --------------------------------
var registry_script: GDScript = load("res://src/ui/panels/registry.gd")
var ids: Array = []
for entry in registry_script.PANELS:
ids.append(str(entry["id"]))
_check(ids.size() == registry_script.PANELS.size(), "Registry: IDs eindeutig (keine Duplikate in der Tabelle)")
_check(not ids.is_empty(), "Registry: mindestens ein Bereich angemeldet")
# --- 2. Jeder Bereich GENAU EINMAL im Baum, als Tab mit Titel --------------
var tabs_by_name: Dictionary = {} # gruppe → TabContainer
var stack: Array = [main]
var all_controls: Array = []
while not stack.is_empty():
var n: Node = stack.pop_back()
if n is TabContainer:
all_controls.append(n)
stack.append_array(n.get_children())
for tab in all_controls:
for c in (tab as TabContainer).get_children():
var cid := str(c.name)
if cid in ids:
_check(tabs_by_name.get(cid) == null, "Bereich '%s' GENAU EINMAL im Baum" % cid)
tabs_by_name[cid] = c
# Tab-Titel = Registry-Titel (erreichbar/erkennbar)
var title: String = ""
for entry in registry_script.PANELS:
if str(entry["id"]) == cid:
title = str(entry["titel"])
var idx: int = c.get_index()
_check(tab.get_tab_title(idx) == title,
"Tab '%s' trägt Registry-Titel '%s'" % [cid, title])
for wanted in ids:
_check(tabs_by_name.get(wanted) != null, "Bereich '%s' aus der Registry im Baum" % wanted)
# --- 3. Tab-Gruppen --------------------------------------------------------
var right: TabContainer = main.get("ui_right_panel")
var left: TabContainer = main.get("ui_left_panel")
var bottom: Node = main.get("ui_dock_bottom")
_check(right != null and right is TabContainer, "rechte Spalte ist ein TabContainer")
_check(left != null and left is TabContainer, "linke Spalte ist ein TabContainer (U2-vorbereitet)")
_check(bottom != null and bottom is VSplitContainer, "unten: VSplit (größenverstellbar)")
if right != null:
_check(right.get_child_count() == 5, "rechts: 5 Tabs (Inspektor/Auswahl/Map/Terrain/Prüfen) — hat %d" % right.get_child_count())
_check(right.get_tab_count() == right.get_child_count(),
"rechts: alle Bereiche als Tab in der Leiste erreichbar (Titel je Bereich)")
for i in right.get_tab_count():
_check(not str(right.get_tab_title(i)).is_empty(),
"rechts: Tab %d hat sichtbaren Titel" % i)
if left != null:
_check(left.get_child_count() == 1, "links: 1 Tab (Map-Inhalt) — hat %d" % left.get_child_count())
if bottom is VSplitContainer and bottom.get_child_count() == 2:
var bchild: Node = bottom.get_child(1) # PanelContainer mit BottomTabs
var btab: TabContainer = null
for gc in bchild.get_children():
if gc is TabContainer:
btab = gc
_check(btab != null, "unten: TabContainer für Assets gefunden")
if btab != null:
_check(btab.get_child_count() == 2, "unten: 2 Tabs (Bibliothek/Werkzeuge) — hat %d" % btab.get_child_count())
# --- 4. Assets-Bereich unten vollständig -----------------------------------
var assets: Control = tabs_by_name.get("assets")
_check(assets != null, "Objekt-Bibliothek (Assets) unten vorhanden")
if assets != null:
_check(assets.get("library_search") != null, "Bibliothek: Suchfeld vorhanden")
_check(assets.get("library_open_all_btn") != null and assets.get("library_close_all_btn") != null,
"Bibliothek: „Alle auf/zu“ vorhanden")
var lib_box: Node = assets.get("library_box")
_check(lib_box != null and lib_box.get_child_count() > 0, "Bibliothek: Kachel-/Gruppen-Liste gefüllt")
var tools: Control = tabs_by_name.get("werkzeuge")
_check(tools != null and tools.get("grid_check") != null, "Objekt-Werkzeuge: Raster-Snap erhalten (nichts verschwindet)")
# --- 5. Theme als einzige Farbquelle ----------------------------------------
var theme_res: Theme = main.get("theme")
_check(theme_res != null, "Theme gesetzt (main.theme)")
if theme_res != null:
_check(int(theme_res.default_font_size) >= 14, "Theme: Grundschrift ≥ 14 px (Lesbarkeits-Grundsatz)")
var theme_file: Theme = load("res://theme/editor_theme.tres")
_check(theme_res == theme_file, "Theme: main nutzt theme/editor_theme.tres (eine Quelle)")
# --- 6. Keine doppelten Node-Namen unter main -------------------------------
var names: Dictionary = {}
var dupes: Array = []
var stack2: Array = [main]
while not stack2.is_empty():
var n2: Node = stack2.pop_back()
var key := str(n2.name) + "#" + n2.get_class()
if key in names:
dupes.append(str(n2.name))
names[key] = true
stack2.append_array(n2.get_children())
_check(dupes.is_empty(), "keine doppelten Node-Namen im UI — %s" % str(dupes))
# --- 7. Koordinator-Vertrag (Werkzeuge/Tests greifen weiter auf main.*) ----
for ref_name in ["toolbar_mod", "status_mod", "selection_mod", "map_mod",
"terrain_mod", "check_mod", "tree_mod", "assets_mod",
"content_tree", "status_label", "prop_name", "library_search",
"terrain_radius_slider", "terrain_size_btn_medium"]:
_check(main.get(ref_name) != null, "main.%s vorhanden" % ref_name)
# --- 8. Lauf-Modus (T1) bleibt erreichbar: Toolbar-Aktion aus Registry ------
var tb: Control = main.get("toolbar_mod")
var found_test_btn := false
if tb != null:
for entry in registry_script.ACTIONS:
if str(entry["id"]) == "karte_testen":
var btn: Node = tb.find_child(str(entry["id"]), true, false)
found_test_btn = btn != null
_check(found_test_btn, "Lauf-Modus: „▶ Karte testen“ in der Toolbar (Registry-Aktion)")
print("CHECKS: %d FAILURES: %d" % [_oks + _fails, _fails])
if _fails > 0:
print("UI_LAYOUT_TEST_FAIL")
quit(1)
else:
print("ALL OK: %d checks" % (_oks + _fails))
quit(0)

View file

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

124
theme/editor_theme.tres Normal file
View file

@ -0,0 +1,124 @@
[gd_resource type="Theme" load_steps=10 format=3]
[sub_resource type="StyleBoxFlat" id="panel"]
bg_color = Color(0.12, 0.14, 0.17, 1)
border_color = Color(0.3, 0.36, 0.42, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="tab_sel"]
bg_color = Color(0.24, 0.3, 0.36, 1)
border_color = Color(0.35, 0.55, 0.7, 1)
border_width_top = 2
corner_radius_top_left = 4
corner_radius_top_right = 4
content_margin_left = 10.0
content_margin_right = 10.0
content_margin_top = 4.0
content_margin_bottom = 4.0
[sub_resource type="StyleBoxFlat" id="tab_unsel"]
bg_color = Color(0.15, 0.18, 0.21, 1)
corner_radius_top_left = 4
corner_radius_top_right = 4
content_margin_left = 10.0
content_margin_right = 10.0
content_margin_top = 4.0
content_margin_bottom = 4.0
[sub_resource type="StyleBoxFlat" id="tabbar"]
bg_color = Color(0.1, 0.12, 0.14, 1)
[sub_resource type="StyleBoxFlat" id="btn_normal"]
bg_color = Color(0.19, 0.23, 0.27, 1)
border_color = Color(0.3, 0.36, 0.42, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
content_margin_left = 8.0
content_margin_right = 8.0
[sub_resource type="StyleBoxFlat" id="btn_hover"]
bg_color = Color(0.25, 0.31, 0.37, 1)
border_color = Color(0.35, 0.55, 0.7, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
content_margin_left = 8.0
content_margin_right = 8.0
[sub_resource type="StyleBoxFlat" id="btn_pressed"]
bg_color = Color(0.14, 0.19, 0.24, 1)
border_color = Color(0.35, 0.55, 0.7, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
content_margin_left = 8.0
content_margin_right = 8.0
[sub_resource type="StyleBoxFlat" id="tooltip"]
bg_color = Color(0.08, 0.09, 0.11, 0.96)
border_color = Color(0.35, 0.55, 0.7, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
content_margin_left = 6.0
content_margin_right = 6.0
content_margin_top = 4.0
content_margin_bottom = 4.0
[sub_resource type="StyleBoxEmpty" id="focus_empty"]
[resource]
default_font_size = 16
Editor/colors/accent = Color(0.55, 0.8, 1, 1)
Editor/colors/status_ok = Color(0.75, 0.95, 0.75, 1)
Editor/colors/status_error = Color(1, 0.6, 0.5, 1)
Editor/colors/muted = Color(1, 1, 1, 0.55)
Editor/colors/field = Color(1, 1, 1, 0.7)
Editor/colors/title = Color(1, 1, 1, 0.6)
Editor/colors/help_bg = Color(0.09, 0.11, 0.13, 0.97)
Editor/colors/help_border = Color(0.35, 0.55, 0.7, 1)
Editor/colors/update_bg = Color(0.36, 0.25, 0.06, 0.97)
Editor/colors/update_border = Color(0.95, 0.72, 0.15, 1)
Editor/colors/rubber_fill = Color(0.45, 0.8, 1, 0.16)
Editor/colors/rubber_line = Color(0.45, 0.8, 1, 0.9)
PanelContainer/styles/panel = SubResource("panel")
TabContainer/styles/panel = SubResource("panel")
TabContainer/styles/tab_selected = SubResource("tab_sel")
TabContainer/styles/tab_unselected = SubResource("tab_unsel")
TabContainer/styles/tabbar_background = SubResource("tabbar")
TabContainer/colors/font_selected_color = Color(1, 1, 1, 1)
TabContainer/colors/font_unselected_color = Color(1, 1, 1, 0.7)
Button/styles/normal = SubResource("btn_normal")
Button/styles/hover = SubResource("btn_hover")
Button/styles/pressed = SubResource("btn_pressed")
Button/styles/focus = SubResource("focus_empty")
TooltipPanel/styles/panel = SubResource("tooltip")
TooltipLabel/colors/font_color = Color(1, 1, 1, 1)

29
tools/run_suites.py Normal file
View file

@ -0,0 +1,29 @@
#!/usr/bin/env python3
"""Run all Godot -s test suites and summarize (Karte t_eaccbfed)."""
import subprocess, re, sys
GODOT = "/opt/data/home/bin/godot4"
suites = sorted(__import__("glob").glob("tests/test_*.gd"))
results = []
for path in suites:
name = path.split("/")[-1][:-3]
proc = subprocess.run([GODOT, "--headless", "--path", ".", "-s", path],
capture_output=True, text=True, timeout=600)
out = proc.stdout + proc.stderr
if re.search(r"SCRIPT ERROR:|Parse Error", out):
st = "SCRIPT_ERROR"
elif re.search(r"FAILURES: [1-9]|FAILED|TEST_FAIL", out):
st = "FAIL"
else:
st = "OK"
summary = ""
for line in out.splitlines():
if re.search(r"ALL OK|CHECKS:|FAILED:", line):
summary = line.strip()
results.append((name, st, summary))
print(f"{name}: {st} | {summary}", flush=True)
ok = sum(1 for _, s, _ in results if s == "OK")
print(f"\nSUITEN_TOTAL={len(results)} GRUEN={ok} ROT={len(results)-ok}")
sys.exit(0 if ok == len(results) else 1)