scopedvr-map-editor/src/ui/main.gd
Hermes (scopedvr-dev) 7a1b5037e7 Playtest: Splitter-Lagen bleiben erhalten (Sly-Befund 20.10.)
Beim Start des Test-Modus versteckt der Editor die Panels — Godot
klappt dabei die Splitter zusammen (Lage springt z. B. [-320,0] →
[160]), beim Wiedereinblenden baut er die Trenner mit Defaults wieder
auf → die fein gezogenen Seitenbereiche waren weg („riesen groß").

Fix in main.gd (besitzt die UI, überlebt den Playtest-Node): beim
Start alle split_offsets von MainSplit/BottomSplit sichern, im
Exit-Handler deferred UND im nächsten Frame zurücksetzen (das Layout
muss die Panels erst neu vermessen haben). Suite test_playtest 52/52
(mit Splitter-Regression: merken → verbiegen → Exit → unverändert),
test_ui_layout 79, test_gui_smoke 92 grün.
2026-10-20 21:21:05 +00:00

2679 lines
No EOL
112 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

extends Control
## ScopedVR Map-Editor — Hauptfenster (M2 UX-Fundament, Karte t_40d855d0;
## U1-Modularisierung, Karte t_eaccbfed).
##
## U1 (Unity-artiges Layout-Fundament): diese Datei ist jetzt ein DÜNNER
## KOORDINATOR — Dokument/History, Werkzeug-Umschaltung, Shortcuts, Dialoge,
## Update, Playtest. Die Oberflächen-Bereiche wohnen in eigenen Modulen unter
## src/ui/panels/ und melden sich DATENGETRIEBEN in
## src/ui/panels/registry.gd an; das Layout wird ausschließlich aus der
## Registry gebaut (Operator-Vorgabe 20.10. — die Umbau-Karten U2–U7 ändern
## je nur IHR Modul + eine Registry-Zeile, nie wieder main.gd).
##
## Layout (Schaubild in /opt/data/scopedvr/KARTEN_UI_UNITY_vc27.md):
## Toolbar oben · links Tab "Map-Inhalt" · Mitte 3D-Ansicht ·
## rechts Tabs (Inspektor/Auswahl/Map/Terrain/Prüfen) ·
## unten Assets (Objekt-Bibliothek) · Statuszeile.
## Alles über HSplit/HSplit/VSplit größenverstellbar; die Splitter-Lage
## bleibt in user://scopedvr_editor.cfg ([fenster] Abschnitt) gemerkt.
##
## Farben/Namen kommen aus theme/editor_theme.tres (einzige Farbquelle).
##
## UX-Fundament (Chef-Grundsatz Sly — map-editor-README):
## 1. Undo/Redo (Strg+Z / Strg+Y) über ActionHistory — spätere Werkzeuge
## (Terrain, Spawn-Zonen) melden sich mit snapshot/commit einfach an.
## 2. Ghost-Preview: Platzier-Vorschau schwebt transparent am Cursor
## (interaction.gd), Klick setzt das Objekt.
## 3. Auswahl-Anfassen: Klick wählt, Ziehen verschiebt,
## R ODER Mausrad dreht (15°-Raster), Entf löscht.
## 4. Helfende Meldungen: Klartext + DER NÄCHSTE SCHRITT, keine Godot-Texte.
## 5. Deutsch & groß: Schrift ≥ 14 px, klickbare Flächen ≥ 40 px Höhe.
const APP_TITLE := "ScopedVR Map-Editor"
# Shortcut-Infrastruktur (Karte t_c46d31c2): InputMap-Actions + Esc-Priorität.
const ShortcutKeys := preload("res://src/editor/shortcut_keys.gd")
const ShortcutPolicy := preload("res://src/editor/shortcut_policy.gd")
# Shortcuts-Mathe Karte 2/3 (t_55257792): Pfeil-Raster + Pinsel-Presets.
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")
# U1 (t_eaccbfed): die Registry ist die EINZIGE Layout-Quelle.
const PanelRegistryScript := preload("res://src/ui/panels/registry.gd")
# Editor-Theme (U1): eine Theme-Ressource als einzige Farbquelle.
const THEME_PATH := "res://theme/editor_theme.tres"
var manifest: Dictionary = {}
var doc: Dictionary = {} # das Map-Dictionary (1:1 svmap-Format)
var current_path := "" # leer = noch nie gespeichert
var dirty := false
var grid_snap := false # Raster-Snap (1 m) an/aus — Objekt-Werkzeuge links
var history := ActionHistory.new()
var interaction # src/editor/interaction.gd (script-erweitert → untypisiert)
var gizmo # src/viewport/gizmo.gd (Achsen-Pfeile, Kind von interaction)
var gizmo_step_m := 0.5 # Gizmo-Schrittweite in m (Panel-Feld; überlebt UI-Rebuilds)
var terrain_tool # src/editor/terrain_tool.gd (Pinsel, Karte t_9aba04f1)
var spawn_tool # src/editor/spawn_tool.gd (Zonen, Karte t_c4162c5d)
var build_tool # src/editor/build_tool.gd (Bauen-Modus, Karte t_97ea59c5)
var tool_mode := "objects" # "objects" | "terrain" | "spawns" | "bauen" (Modular-Builder)
# Symmetrie-Modus (Karte t_f5935023, M1): Modus + Toolbar-Schalter
# + Statuszeilen-Anzeige. Standard = Punktspiegelung (Sly 20.10.). M1 legt nur
# Modus + Anzeige fest; das Spiegeln selbst kommt mit M2–M4.
var sym_mode: int = SymmetryCore.Mode.POINT
# UI-Referenzen (Koordinator-Sicht auf die Module — Panels bauen selbst)
var status_label: Label
var file_label: Label
var help_label: Label
var view_builder: Node3D
var camera: Camera3D
var inspector: Control # Objekt-Inspektor (Karte t_bb0d11c9) — im Inspektor-Tab
# U1-Module (aus der Registry gebaut; Toolbar/Status/Kern-Widget-Referenzen
# bleiben hier, damit Tests/Tools denselben Vertrag sehen wie vorher)
var toolbar_mod # src/ui/panels/toolbar.gd
var status_mod # src/ui/panels/status_bar.gd
var selection_mod # src/ui/panels/selection_panel.gd
var map_mod # src/ui/panels/map_panel.gd
var terrain_mod # src/ui/panels/terrain_panel.gd
var check_mod # src/ui/panels/check_panel.gd
var tree_mod # src/ui/panels/scene_tree_panel.gd
var assets_mod # src/ui/panels/assets_panel.gd
var content_tree: Tree # weiter in tree_mod (Bequemlichkeits-Referenz)
var stats_objects: Label # weiter in status_mod
var stats_terrain: Label # weiter in status_mod
var tool_label: Label # weiter in status_mod
var sym_label: Label # weiter in status_mod
var undo_btn: Button # weiter in toolbar_mod
var redo_btn: Button # weiter in toolbar_mod
var grid_btn: Button # weiter in toolbar_mod
var contours_btn: Button # weiter in toolbar_mod
var size_menu: MenuButton # weiter in toolbar_mod
var sym_menu: MenuButton # weiter in toolbar_mod
var prop_name: LineEdit # weiter in map_mod
var sel_label: Label # weiter in selection_mod
var gizmo_step_box: SpinBox # weiter in selection_mod
var library_search: LineEdit # weiter in assets_mod
var library_buttons: Dictionary = {} # weiter in assets_mod (Bequemlichkeit)
var _library_group_rows: Array = [] # weiter in assets_mod (Bequemlichkeit)
var spawn_status: Label # weiter in check_mod
var terrain_info: Label # weiter in map_mod
var build_edit_btn: Button # weiter in terrain_mod
var build_panel: VBoxContainer # weiter in terrain_mod
var build_kind_buttons: Dictionary = {} # weiter in terrain_mod
var terrain_mode_lower: Button # weiter in terrain_mod (Test-Vertrag)
var terrain_size_btn_small: Button # weiter in terrain_mod
var terrain_size_btn_medium: Button # weiter in terrain_mod
var terrain_size_btn_large: Button # weiter in terrain_mod
var terrain_palette: VBoxContainer # weiter in terrain_mod
var terrain_radius_slider: HSlider # weiter in terrain_mod
var terrain_radius_label: Label # weiter in terrain_mod
var terrain_strength_slider: HSlider # weiter in terrain_mod
var terrain_mat_buttons: Array = [] # weiter in terrain_mod (Gras/Sand/Fels)
var library_open_all_btn: Button # weiter in assets_mod
var library_close_all_btn: Button # weiter in assets_mod
# Toolbar-/Panel-Zustand (gehört dem Koordinator)
var _grid_on := false # Nutzer-Wahl für das Maßstabsgitter
# Höhenlinien (Karte t_89c0ec95): _contours_on ist die NUTZER-Wahl „fixiert
# an“ (L-Taste oder Button). Ohne Fix erscheinen die Linien NUR im
# Terrain-Werkzeug (F1) — Sly-Befund 17.10.: dauerhaft sichtbar störte.
# Effektiv = _contours_fix AN ODER tool_mode == "terrain".
var _contours_on := false
# --- Test-Start (Karte t_96e3c4ee / T1b) -------------------------------------
# main hält den ZUSTAND und die Haken (Vertrag mit tests/test_playtest_spawn.gd);
# das Panel-Modul test_start_panel.gd zeigt ihn nur an (U1-Muster).
var test_start_point: Variant = null # Vector3 (nur x/z zählt) oder null
var test_start_tool # src/editor/playtest/test_start_tool.gd
var test_start_panel: Control # src/ui/panels/test_start_panel.gd (von der Prüfsuite erwartet)
var test_start_btn: Button # „🎯 Test-Start" in der Leiste (Umschalter)
var _test_start_active := false
var _loading_ui_values := false # verhindert dirty beim Auffüllen der Felder
# Rahmen-Ausziehen (Rubberband) — Overlay über dem 3D-Viewport
var rubber_overlay: Control
var rubber_rect := Rect2() # leer = Overlay aus
# Playtest (Karte t_1cdc1a72) + Lauf-Modus-Panelverweise
var playtest: Node = null # src/editor/playtest/playtest_mode.gd Instanz
var _playtest_tool_mode := "" # gemerkter Werkzeug-Modus für die Rückkehr
var ui_top_bar: Control # Top-Bar (im Lauf-Modus aus)
var ui_left_panel: Control # linke Spalte (im Lauf-Modus aus)
var ui_right_panel: Control # rechte Tab-Spalte (im Lauf-Modus aus)
var ui_status_bar: Control # Statusleiste (im Lauf-Modus aus)
var ui_dock_bottom: Control # Assets-Bereich unten (im Lauf-Modus aus)
var world3d: Node3D # 3D-Wurzel im SubViewport (Spieler lebt hier)
# Karte 3/3 (t_11b5c25b): Dirty-Schutz + Kurzhilfe + Werkzeug-Statuszeile.
var dirty_dialog: ConfirmationDialog # „Ungespeicherte Änderungen verwerfen?“
var _pending_after_dirty := Callable() # was nach „Verwerfen“ ausgeführt wird
var help_overlay: PanelContainer # H-Kurzhilfe (nicht-blockierend)
var help_overlay_title: Label
var help_overlay_text: RichTextLabel
# Editor-Selbstupdate (Karte t_d95f087a): Start-Check + Banner + „Jetzt updaten“
var update_banner: PanelContainer # Leiste „🔄 Update verfügbar …“ (versteckt)
var update_label: Label
var update_btn: Button # „Jetzt updaten“ im Banner
var check_thread: Thread # Update-Check im Hintergrund (Start/Menü)
var update_thread: Thread # Update-Ausführung (pull + Import-Lauf)
var _update_check_busy := false
var _update_running := false
var _update_result: Dictionary = {} # letzter Check (Status/Banner-Nachfrage)
# Dialoge
var new_dialog: AcceptDialog
var open_dialog: FileDialog
var save_dialog: FileDialog
var error_dialog: AcceptDialog
var resize_dialog: ConfirmationDialog # „Kartengröße ändern“ (R3, t_c863b93b)
var error_text: Label
# Map-Check (Karte prüfen)
var check_dialog: AcceptDialog
var check_text: Label
# Update-Check-Ergebnis (Menü „🔄 Updates prüfen“, Sly 20.10.: nur die
# Statuszeile las sich wie „tut nichts“ → kleines Ergebnis-Fenster).
var update_dialog: AcceptDialog
var update_text: Label
# Karte testen (Karte t_b84af40f)
var test_btn: Button
# U1-Dock-Zustand: Splitter-Lagen ([fenster]-Abschnitt, gemerkt)
var _dock_offsets: Dictionary = {}
var _split_lagen_playtest: Dictionary = {} # Playtest: Trenner-Lagen (Sly-Befund 20.10.)
const GizmoScript := preload("res://src/viewport/gizmo.gd")
const INSPECTOR_PANEL := preload("res://src/ui/inspector_panel.gd")
func _ready() -> void:
# InputMap-Actions der Tastatur-Shortcuts (Karte t_c46d31c2) — VOR UI/Kamera,
# damit die Kamera-Polls (Flug W/A/S/D + Leertaste/Shift) sofort kennen.
ShortcutKeys.register()
manifest = _load_manifest()
_build_ui()
_update_tool_label()
# Symmetrie-Anzeige initialisieren (Karte t_f5935023, M1): Standard = Punkt.
_update_sym_ui()
# 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()
_load_dock_state()
_start_with_new_map()
_maybe_start_demo_mode()
# Selbst-Update (Karte t_d95f087a): STILLER Start-Check im Hintergrund —
# nur bei „Update verfügbar“ erscheint das Banner; offline/Tunnel weg
# bleibt alles ruhig (kein Banner, keine Meldung).
_startup_update_check()
func _load_manifest() -> Dictionary:
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(SvmapIO.MANIFEST_PATH))
if parsed is Dictionary:
return parsed
push_error("Bibliothek library/objects.json konnte nicht geladen werden!")
return {}
# ------------------------------------------------------------------ UI-Bau --
## Layout aus der Registry (U1, Operator-Vorgabe): Top-Bar → Update-Banner →
## Haupt-WSplit (links Tabs | Mitte 3D | rechts Tabs) → unten Assets →
## Statuszeile. Kein Panel wird hier fest verdrahtet — Registry ändert das
## Layout, main.gd bleibt gleich.
func _build_ui() -> void:
var theme_res: Theme = load(THEME_PATH)
theme_res.default_font_size = UIScale.font_size()
self.theme = theme_res
var vbox := VBoxContainer.new()
vbox.set_anchors_preset(Control.PRESET_FULL_RECT)
vbox.add_theme_constant_override("separation", UIScale.px(4))
add_child(vbox)
# --- Top-Bar (Modul toolbar.gd) ---
var top := PanelContainer.new()
# _make_module hängt das Modul selbst an den Container (doppelt add_child
# würde „already has a parent“ werfen).
var top_h: Control = _make_module(toolbar_script(), top)
vbox.add_child(top)
ui_top_bar = top # Lauf-Modus (t_1cdc1a72): Top-Bar ausblenden
toolbar_mod = top_h
_pull_toolbar_refs()
_update_banner_build(vbox)
# --- Hauptbereich: links Tabs | Mitte 3D | rechts Tabs ---
var split := HSplitContainer.new()
split.size_flags_vertical = Control.SIZE_EXPAND_FILL
split.size_flags_horizontal = Control.SIZE_EXPAND_FILL
split.split_offset = UIScale.px(-320)
split.name = "MainSplit"
vbox.add_child(split)
# Lauf-Modus (t_1cdc1a72): linke/rechte Spalte als Member — werden im
# Lauf-Modus ausgeblendet, die 3D-Ansicht (Mitte) bleibt stehen.
var left_tabs := _build_group_tabs("links")
left_tabs.name = "LeftTabs"
ui_left_panel = left_tabs
split.add_child(left_tabs)
split.add_child(_build_center_viewport())
var right_tabs := _build_group_tabs("rechts")
right_tabs.name = "RightTabs"
ui_right_panel = right_tabs
split.add_child(right_tabs)
# --- Assets unten (Registry-Gruppe "unten", U1 Auftragspunkt 4) ---
# Die Objekt-Bibliothek wohnt jetzt UNTER dem Hauptbereich (Unity-
# Projekt-Fenster-Platz). Höhe größenverstellbar über den VSplit und
# gemerkt in [fenster] (dock_unten).
var bottom_wrap := VSplitContainer.new()
bottom_wrap.size_flags_vertical = Control.SIZE_EXPAND_FILL
bottom_wrap.size_flags_horizontal = Control.SIZE_EXPAND_FILL
bottom_wrap.name = "BottomSplit"
# WICHTIG: den Haupt-Split ERST aus der VBox trennen, dann in den VSplit
# hängen — add_child auf ein Kind mit Parent ist ein stiller Baum-Fehler
# (Node hängt dann im Nichts: Slider/Skript-Signale laufen nicht).
vbox.remove_child(split)
# VSplit-Kind 1: der Haupt-Split (links|3D|rechts) — er wächst.
split.size_flags_vertical = Control.SIZE_EXPAND_FILL
bottom_wrap.add_child(split)
# VSplit-Kind 2: die Assets-Tabs.
var bottom_tabs := _build_group_tabs("unten")
bottom_tabs.name = "BottomTabs"
bottom_tabs.custom_minimum_size = UIScale.v2(0, 300)
var bottom_panel := PanelContainer.new()
bottom_panel.add_child(bottom_tabs)
bottom_wrap.add_child(bottom_panel)
# Der VSplit ersetzt den Platz des bisherigen Haupt-Splits in der VBox.
vbox.add_child(bottom_wrap)
ui_dock_bottom = bottom_wrap
# --- Statusleiste (Modul status_bar.gd) ---
var status_panel := PanelContainer.new()
var status_h: Control = _make_module(status_bar_script(), status_panel)
vbox.add_child(status_panel)
ui_status_bar = status_panel # Lauf-Modus (t_1cdc1a72): Statusleiste aus
status_mod = status_h
_pull_status_refs()
status_label = status_mod.status_label
file_label = toolbar_mod.file_label
_build_dialogs_and_overlays()
func toolbar_script() -> GDScript:
return load("res://src/ui/panels/toolbar.gd")
func status_bar_script() -> GDScript:
return load("res://src/ui/panels/status_bar.gd")
## Ein Modul (EditorPanel-Ableger) instanziieren und bauen; Rückgabe = das
## Panel selbst (Container). Fehler im Modul-Skript = fette Meldung statt
## stiller null-Referenzen.
func _make_module(script: GDScript, parent: Control) -> Control:
var mod: Control = script.new()
parent.add_child(mod)
if mod.has_method("setup"):
mod.setup(self)
if mod.has_method("build"):
mod.call("build")
return mod
func _pull_toolbar_refs() -> void:
undo_btn = toolbar_mod.undo_btn
redo_btn = toolbar_mod.redo_btn
grid_btn = toolbar_mod.grid_btn
contours_btn = toolbar_mod.contours_btn
size_menu = toolbar_mod.size_menu
sym_menu = toolbar_mod.sym_menu
# Häkchen im UI-Größe-Menü auf die aktuelle Stufe (vor U1 rief die
# Top-Bar _sync_size_menu während des Baus — jetzt nach dem Referenz-Zug).
_sync_size_menu()
func _pull_status_refs() -> void:
stats_objects = status_mod.stats_objects
stats_terrain = status_mod.stats_terrain
tool_label = status_mod.tool_label
sym_label = status_mod.sym_label
status_label = status_mod.status_label
## Eine Tab-Gruppe aus der Registry bauen (links/rechts/unten). Jeder Bereich
## erscheint GENAU EINMAL (test_ui_layout prüft das). Die Panels werden per
## preload aus dem Registry-Eintrag erzeugt.
func _build_group_tabs(gruppe: String) -> TabContainer:
var tabs := TabContainer.new()
tabs.size_flags_horizontal = Control.SIZE_EXPAND_FILL
tabs.size_flags_vertical = Control.SIZE_EXPAND_FILL
tabs.add_theme_constant_override("separation", UIScale.px(8))
var entries: Array = PanelRegistryScript.entries(gruppe)
for entry in entries:
var script: GDScript = load(str(entry["skript"]))
var panel: Control = script.new()
panel.name = str(entry["id"])
panel.setup(self)
# Der Alt-Inspektor (t_bb0d11c9) baut in setup() — build() ist dort
# nicht vorhanden; neue Module bauen in build(). Beides zulassen.
if panel.has_method("build"):
panel.call("build")
tabs.add_child(panel)
tabs.set_tab_title(panel.get_index(), str(entry["titel"]))
_panel_group_built(gruppe, tabs)
return tabs
## Gruppen-Nacharbeit: Referenzen ziehen, sobald ihre Tabs stehen.
func _panel_group_built(gruppe: String, tabs: TabContainer) -> void:
match gruppe:
"rechts":
for child in tabs.get_children():
match child.name:
"inspektor":
inspector = child
"auswahl":
selection_mod = child
"map":
map_mod = child
"terrain":
terrain_mod = child
"check":
check_mod = child
sel_label = selection_mod.sel_label
gizmo_step_box = selection_mod.gizmo_step_box
prop_name = map_mod.prop_name
spawn_status = check_mod.spawn_status
terrain_info = map_mod.terrain_info
build_edit_btn = terrain_mod.build_edit_btn
build_panel = terrain_mod.build_panel
build_kind_buttons = terrain_mod.build_kind_buttons
# Test-/Werkzeug-Vertrag (wie vor der U1-Aufteilung): die Widgets
# wohnen in den Modulen, der Koordinator hält die alten Namen
# als Bequemlichkeits-Referenzen — nichts wird doppelt gebaut.
terrain_mode_lower = terrain_mod.terrain_mode_lower
terrain_size_btn_small = terrain_mod.terrain_size_btn_small
terrain_size_btn_medium = terrain_mod.terrain_size_btn_medium
terrain_size_btn_large = terrain_mod.terrain_size_btn_large
terrain_palette = terrain_mod.terrain_palette
terrain_radius_slider = terrain_mod.terrain_radius_slider
terrain_radius_label = terrain_mod.terrain_radius_label
terrain_strength_slider = terrain_mod.terrain_strength_slider
terrain_mat_buttons = terrain_mod.terrain_mat_buttons
"links":
for child in tabs.get_children():
if child.name == "szene":
tree_mod = child
content_tree = tree_mod.content_tree
"unten":
for child in tabs.get_children():
if child.name == "assets":
assets_mod = child
library_search = assets_mod.library_search
library_buttons = assets_mod.library_buttons
_library_group_rows = assets_mod._library_group_rows
library_open_all_btn = assets_mod.library_open_all_btn
library_close_all_btn = assets_mod.library_close_all_btn
func _build_dialogs_and_overlays() -> void:
# --- Dialoge ---
new_dialog = preload("res://src/ui/new_map_dialog.gd").new()
new_dialog.confirmed.connect(_on_new_map_confirmed)
add_child(new_dialog)
# R3 (t_c863b93b): „Kartengröße ändern“ — Preview-Dialog, Ausführen als
# EINE Undo-Einheit in _on_resize_confirmed.
resize_dialog = preload("res://src/ui/resize_map_dialog.gd").new()
resize_dialog.confirmed.connect(_on_resize_confirmed)
add_child(resize_dialog)
open_dialog = FileDialog.new()
open_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
open_dialog.access = FileDialog.ACCESS_FILESYSTEM
open_dialog.title = "Map öffnen"
open_dialog.filters = PackedStringArray(["*.svmap ; ScopedVR Map"])
open_dialog.size = UIScale.v2i(800, 560)
open_dialog.file_selected.connect(_on_open_selected)
add_child(open_dialog)
save_dialog = FileDialog.new()
save_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE
save_dialog.access = FileDialog.ACCESS_FILESYSTEM
save_dialog.title = "Map speichern unter"
save_dialog.filters = PackedStringArray(["*.svmap ; ScopedVR Map"])
save_dialog.size = UIScale.v2i(800, 560)
save_dialog.file_selected.connect(_on_save_selected)
add_child(save_dialog)
error_dialog = AcceptDialog.new()
error_dialog.title = "Fehler"
error_dialog.ok_button_text = "Alles klar"
error_text = Label.new()
error_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
error_text.custom_minimum_size = UIScale.v2(560, 0)
error_dialog.add_child(error_text)
add_child(error_dialog)
# Map-Check-Dialog (Karte prüfen, Karte t_c4162c5d).
check_dialog = AcceptDialog.new()
check_dialog.title = "Map-Check"
check_dialog.ok_button_text = "Danke!"
check_text = Label.new()
check_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
check_text.custom_minimum_size = UIScale.v2(620, 0)
check_dialog.add_child(check_text)
add_child(check_dialog)
# Update-Check-Ergebnis (Sly 20.10.: „tut nichts“ — weil die Rückmeldung
# NUR in der Statuszeile kam und dort schnell wieder weg war). Kleines
# Fenster mit dem Klartext-Ergebnis, Stil wie der Map-Check-Dialog.
update_dialog = AcceptDialog.new()
update_dialog.title = "Update-Check"
update_dialog.ok_button_text = "Alles klar"
update_text = Label.new()
update_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
update_text.custom_minimum_size = UIScale.v2(560, 0)
update_dialog.add_child(update_text)
add_child(update_dialog)
# Karte 3/3 (t_11b5c25b): Dirty-Schutz vor „Neue Map“ / „Öffnen“ — deutsche
# Klartext-Frage, „Verwerfen“ führt die wartende Aktion aus, „Abbrechen“ tut
# nichts (Map bleibt, wie sie ist).
dirty_dialog = ConfirmationDialog.new()
dirty_dialog.title = "Ungespeicherte Änderungen"
dirty_dialog.dialog_text = "Die Map hat ungespeicherte Änderungen — beim Fortfahren gehen sie verloren.\nJetzt verwerfen und fortfahren?"
dirty_dialog.ok_button_text = "Verwerfen"
dirty_dialog.cancel_button_text = "Abbrechen"
dirty_dialog.confirmed.connect(_on_dirty_confirmed)
add_child(dirty_dialog)
# Kurzhilfe-Overlay (Karte 3/3): H schaltet es an, H/Esc aus. Kein modal
# Dialog — der Editor bleibt bedienbar, das Panel liegt nur obenauf.
help_overlay = PanelContainer.new()
var overlay_style := StyleBoxFlat.new()
# U7: Farben über das Theme (Editor/colors/help_bg, help_border).
overlay_style.bg_color = get_theme_color("help_bg", "Editor")
overlay_style.border_color = get_theme_color("help_border", "Editor")
overlay_style.set_border_width_all(2)
overlay_style.set_corner_radius_all(8)
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.
# Grow BOTH: das Panel wächst SYMMETRISCH um den Mittelpunkt (sonst hängt
# seine linke obere Ecke am Zentrum und es läuft rechts/unten aus dem Bild).
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 = UIScale.v2(880, 640)
help_overlay.visible = false
var overlay_box := VBoxContainer.new()
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", UIScale.heading_font_size())
help_overlay_title.add_theme_color_override("font_color", get_theme_color("accent", "Editor"))
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 = UIScale.v2(0, 0)
help_overlay_text.text = HelpText.text()
overlay_box.add_child(help_overlay_text)
add_child(help_overlay)
func _build_center_viewport() -> SubViewportContainer:
var container := SubViewportContainer.new()
container.stretch = true
container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
container.size_flags_vertical = Control.SIZE_EXPAND_FILL
var viewport := SubViewport.new()
viewport.own_world_3d = true
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
container.add_child(viewport)
var world := Node3D.new()
viewport.add_child(world)
world3d = world # Lauf-Modus (t_1cdc1a72): Spieler-Body lebt in diesem Baum
view_builder = Node3D.new()
view_builder.set_script(load("res://src/viewport/map_view_builder.gd"))
world.add_child(view_builder)
camera = Camera3D.new()
camera.set_script(load("res://src/viewport/orbit_camera.gd"))
camera.current = true
world.add_child(camera)
interaction = Node3D.new()
interaction.set_script(load("res://src/editor/interaction.gd"))
world.add_child(interaction)
interaction.setup(self, camera, view_builder)
# Achsen-Gizmo (Karte t_e0120dc2) als KIND von interaction — Kinder
# bekommen _unhandled_input ZUERST, es fängt Pfeil-Klicks ab bevor
# interaction sie sieht. interaction.gd bleibt dadurch unverändert.
gizmo = GizmoScript.new()
gizmo.setup(self, camera, view_builder)
interaction.add_child(gizmo)
camera.set("wheel_filter", Callable(interaction, "wheel_blocked"))
# Freelook-Flug-Verdrahtung (Karte t_c46d31c2): Status-Meldungen der
# Kamera laufen ins Statusfeld. Karte t_2470de89: die rechte Maustaste
# gehört IMMER dem Flug — kein Filter mehr, die Werkzeuge fassen RMB
# nicht mehr an.
camera.set("status_cb", Callable(self, "_set_status"))
# Terrain-Pinsel (Karte t_9aba04f1) — eigener Eingabe-Knoten im Weltraum.
terrain_tool = Node3D.new()
terrain_tool.set_script(load("res://src/editor/terrain_tool.gd"))
world.add_child(terrain_tool)
terrain_tool.setup(self, camera, view_builder, interaction)
# Spawn-Zonen-Werkzeug (Karte t_c4162c5d) — ditto eigener Eingabe-Knoten.
spawn_tool = Node3D.new()
spawn_tool.set_script(load("res://src/editor/spawn_tool.gd"))
world.add_child(spawn_tool)
spawn_tool.setup(self, camera, view_builder, interaction)
# Bauen-Werkzeug (Karte t_97ea59c5) — ditto eigener Eingabe-Knoten.
build_tool = Node3D.new()
build_tool.set_script(load("res://src/editor/build_tool.gd"))
world.add_child(build_tool)
build_tool.setup(self, camera, view_builder, interaction)
# Test-Start-Werkzeug (T1b, Wiederanbindung 20.10.): Marker in der Karte,
# Klick setzt den Startpunkt. Lebt — wie die übrigen Werkzeuge — in main,
# gelesen wird es von test_start_tool.gd über main.test_start_point.
test_start_tool = Node3D.new()
test_start_tool.set_script(load("res://src/editor/playtest/test_start_tool.gd"))
world.add_child(test_start_tool)
test_start_tool.setup(self, camera, view_builder, interaction)
# Bedien-Hinweis im Viewport (Overlay)
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 = UIScale.px(12)
help_label.offset_bottom = UIScale.px(-8)
help_label.offset_top = UIScale.px(-46)
# U7: Hinweis-Farbe über Theme (Editor/colors/muted) statt modulate.
help_label.add_theme_color_override("font_color", get_theme_color("muted", "Editor"))
# 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
# dem Viewport — mouse_filter IGNORE, stört keine Maus-Ereignisse.
rubber_overlay = Control.new()
rubber_overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
rubber_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
rubber_overlay.draw.connect(_draw_rubberband)
rubber_overlay.visible = false
container.add_child(rubber_overlay)
return container
## Rahmen-Rechteck aktualisieren (aus interaction.gd, Viewport-Koordinaten).
func _rubberband_update(start_pos: Vector2, cur_pos: Vector2) -> void:
rubber_rect = SelectionMath.rect_from(start_pos, cur_pos)
rubber_overlay.visible = true
rubber_overlay.queue_redraw()
## Rahmen-Overlay ausblenden (Release/Abbruch).
func _rubberband_clear() -> void:
rubber_rect = Rect2()
rubber_overlay.visible = false
rubber_overlay.queue_redraw()
func _draw_rubberband() -> void:
if rubber_rect.size.x <= 0.0 or rubber_rect.size.y <= 0.0:
return
# U7: Rubberband-Farben über das Theme (Editor/colors/rubber_fill/_line).
rubber_overlay.draw_rect(rubber_rect, get_theme_color("rubber_fill", "Editor"), true)
rubber_overlay.draw_rect(rubber_rect, get_theme_color("rubber_line", "Editor"), false, 2.0)
# ------------------------------------------------------------ Map-Aktionen --
func _start_with_new_map() -> void:
doc = SvmapDocument.create_new("Neue Map", 1024.0, "mittag")
current_path = ""
dirty = false
history.begin(doc)
_refresh_all()
_set_status("Willkommen! Links ein Objekt aus der Bibliothek anklicken, dann kannst du es in der Karte platzieren. Hilfe gibt's unten in dieser Leiste.")
func _on_menu_action(id: int) -> void:
match id:
0: # Neue Map — bei ungespeicherten Änderungen erst NACHFRAGEN (Karte 3/3)
_request_lossy(func() -> void: new_dialog.popup_centered())
1: # Öffnen — ditto Dirty-Schutz
_request_lossy(func() -> void: open_dialog.popup_centered())
2: # Speichern
_save()
3: # Speichern unter
save_dialog.popup_centered()
4: # Beenden (Fensterlage vorher merken, Karte t_297221b5)
_quit_editor()
5: # Karte testen
_on_test_run()
6: # Kartengröße ändern (R3, t_c863b93b)
_open_resize_dialog()
7: # Updates prüfen (Selbst-Update, t_d95f087a)
_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()
_save_dock_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.
func _open_resize_dialog() -> void:
if doc.is_empty():
_set_status("Erst eine Map anlegen (Datei → Neue Map), dann kann die Größe geändert werden.", true)
return
resize_dialog.setup(doc)
resize_dialog.popup_centered()
## „Größe ändern“ bestätigt: resize_apply liefert ein FRISCHES Dict (R1/R2 —
## reine Funktion). Der Resize wird als EINE Undo-Einheit in die History
## gehängt: before = kompletter alter doc-Snapshot, after = kompletter neuer.
## Wichtig: doc bleibt dasselbe Dictionary-Objekt (History hält eine Referenz —
## history.begin(doc) nur bei Neu/Laden) — Blöcke werden IN-PLACE ersetzt,
## wie ActionHistory._restore_block es beim Undo macht.
func _on_resize_confirmed() -> void:
var new_x: float = resize_dialog.get_new_size_x()
var new_z: float = resize_dialog.get_new_size_z()
var drop: bool = resize_dialog.get_drop_outside()
var before := history.snapshot(doc.keys())
var result := SvmapDocument.resize_apply(doc, new_x, new_z, drop)
if result.is_empty():
_set_status("Kartengröße ändern fehlgeschlagen — die Map wurde nicht verändert.", true)
return
var old_terrain: Dictionary = doc.get("terrain", {})
_replace_doc_in_place(result)
dirty = true
history.commit("Kartengröße %d × %d m" % [int(new_x), int(new_z)],
before, history.snapshot(doc.keys()))
_after_structure_change()
# Karte K1 (t_950fddaa): nach Resize die GANZE Karte passend einpassen
# (Kartenmitte aus bounds — frame_map ist weg).
var rb: Dictionary = doc.get("bounds", {})
var rmn: Array = rb.get("min", [])
var rmx: Array = rb.get("max", [])
if rmn.size() == 3 and rmx.size() == 3:
camera.call("fit_to_bounds",
Vector3(rmn[0], rmn[1], rmn[2]), Vector3(rmx[0], rmx[1], rmx[2]))
var old_sx := float(old_terrain.get("size_x_m", 0.0))
var old_sz := float(old_terrain.get("size_z_m", 0.0))
var verb := "verkleinert" if (new_x < old_sx or new_z < old_sz) else "vergrößert"
_set_status("Karte auf %d × %d m %s — Strg+Z stellt den alten Zustand komplett her." % [
int(new_x), int(new_z), verb])
## Kompletten doc-Inhalt IN-PLACE durch ein frisches Ergebnis-Dict ersetzen
## (Identität von doc und seiner Arrays/Dicts bleibt erhalten — History,
## Tests und Ansichten behalten gültige Referenzen; Muster wie
## ActionHistory._restore_block, aber über ALLE Ober-Blöcke).
func _replace_doc_in_place(result: Dictionary) -> void:
for key in doc.keys():
if not result.has(key):
doc.erase(key)
for key in result.keys():
var source: Variant = result[key]
if source is Array:
var arr: Array = doc.get(key, [])
arr.clear()
arr.append_array((source as Array).duplicate(true))
doc[key] = arr
elif source is Dictionary:
var dict: Dictionary = doc.get(key, {})
dict.clear()
dict.merge((source as Dictionary).duplicate(true))
doc[key] = dict
else:
doc[key] = source
# --------------------------------------------- Selbst-Update (t_d95f087a) ----
## Banner „🔄 Update verfügbar“ (unaufdringliche Leiste unter der Top-Bar) —
## nur sichtbar, wenn ein Check einen Update findet. Offline bleibt alles still.
func _update_banner_build(parent: Container) -> void:
update_banner = PanelContainer.new()
var style := StyleBoxFlat.new()
# U7: Banner-Farben über das Theme (Editor/colors/update_bg/_border).
style.bg_color = get_theme_color("update_bg", "Editor")
style.border_color = get_theme_color("update_border", "Editor")
style.set_border_width_all(2)
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", UIScale.px(14))
update_banner.add_child(h)
update_label = Label.new()
update_label.add_theme_font_size_override("font_size", UIScale.font_size())
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 = 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)
update_banner.visible = false
parent.add_child(update_banner)
## Still Start-Check (nur Banner bei „Update verfügbar“ — offline: nichts).
func _startup_update_check() -> void:
_updater_check_async(false)
## Datei-Menü „🔄 Updates prüfen“ (ID 7): gleicher Check mit Ergebnis-Meldung.
func _on_update_check_menu() -> void:
if _update_check_busy:
_set_status("Update-Check läuft schon…")
return
_set_status("Prüfe auf Updates…")
_updater_check_async(true)
func _updater_check_async(report: bool) -> void:
_update_check_busy = true
check_thread = Thread.new()
check_thread.start(_updater_check_thread.bind(report))
func _updater_check_thread(report: bool) -> void:
var result := EditorUpdater.check_update(
EditorUpdater.git_exe(TestRun.load_cfg()),
ProjectSettings.globalize_path("res://"))
_update_result = result
call_deferred("_updater_check_done", result, report)
## Klartext-Ergebnis-Fenster für den manuellen Update-Check (Sly 20.10.:
## Statuszeile allein las sich wie „tut nichts“). Nur bei MANUELLEM Check
## (report=true), der Still-Start-Check bleibt still.
func _show_update_popup(text: String) -> void:
if update_dialog == null or not is_instance_valid(update_dialog):
return # UI wurde gerade neu aufgebaut (UI-Größe) — nichts anfassen
update_text.text = text
update_dialog.popup_centered()
func _updater_check_done(result: Dictionary, report: bool) -> void:
_update_check_busy = false
_join(check_thread)
check_thread = null
match str(result.get("status")):
"update_available":
_show_update_banner(str(result["local"]), str(result["remote"]))
if report:
_set_status("🔄 Update verfügbar: %s → %s — im Banner „Jetzt updaten“ klicken." % [
EditorUpdater.short_sha(str(result["local"])),
EditorUpdater.short_sha(str(result["remote"]))])
_show_update_popup("🔄 Update verfügbar: %s → %s.\n\nIm Banner oben „Jetzt updaten“ klicken — der Editor holt sich den Stand, baut ihn und startet neu." % [
EditorUpdater.short_sha(str(result["local"])),
EditorUpdater.short_sha(str(result["remote"]))])
"up_to_date":
if report:
_set_status("Du bist auf dem neusten Stand (%s)." % EditorUpdater.short_sha(str(result["local"])))
_show_update_popup("Du bist auf dem neusten Stand (%s).\n\nAktuelle Version ist installiert — es gibt nichts zu tun." % EditorUpdater.short_sha(str(result["local"])))
"unreachable":
if report:
_set_status("Update-Check nicht erreichbar (offline oder Tunnel weg) — später nochmal probieren.", true)
_show_update_popup("Update-Check nicht erreichbar.\n\nDer Server ist gerade nicht erreichbar (offline oder Tunnel weg) — später nochmal probieren.")
"auth_failed":
# Karte t_1457e935: Zugangsdaten fehlen → klarer Hinweis statt
# GUI-Login-Dialog (Askpass ist im Updater abgeschaltet).
if report:
_set_status(EditorUpdater.AUTH_ERROR_TEXT, true)
_show_update_popup(EditorUpdater.AUTH_ERROR_TEXT)
_:
if report:
_set_status("Update-Check fehlgeschlagen: %s" % str(result.get("error", "")), true)
_show_update_popup("Update-Check fehlgeschlagen:\n%s" % str(result.get("error", "")))
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
## Thread sauber einsammeln (Ergebnis kam per call_deferred — der Thread ist
## längst fertig, join ist nur Ordnung).
func _join(thread: Thread) -> void:
if thread != null and thread.is_started():
thread.wait_to_finish()
## Banner-Knopf „Jetzt updaten“: Dirty-Schutz wie Strg+O/Strg+N (derselbe
## deutsche Dialog — „Abbrechen“ bricht das Update ab), dann git pull +
## Import-Lauf + Neustart. Import-Fehler = Abbruch mit Meldung, KEIN Neustart
## (Lektion 17.10.: sonst sieht Sly einen Editor ohne Map-Ansicht).
func _on_update_now() -> void:
if _update_running:
_set_status("Ein Update läuft schon…")
return
_request_lossy(_start_update)
func _start_update() -> void:
_update_running = true
update_btn.disabled = true
_set_status("Update läuft — neue Version wird geladen (git pull)…")
update_thread = Thread.new()
update_thread.start(_updater_run_thread)
func _updater_run_thread() -> void:
var project_dir := ProjectSettings.globalize_path("res://")
var cfg := TestRun.load_cfg()
var pull := EditorUpdater.pull(EditorUpdater.git_exe(cfg), project_dir)
if not bool(pull["ok"]):
call_deferred("_update_finished", false, "Update abgebrochen — git pull fehlgeschlagen:", pull["errors"])
return
# Karte t_f73a1cc5: Wenn der Pull eigene Dateien automatisch beiseite-
# gelegt hat, zeigt die Statuszeile den Klartext statt der technischen
# Import-Meldung (KEINE rohe Git-Zeile).
var info := str(pull.get("info", ""))
if info.is_empty():
info = "Update geladen — Version wird importiert (Godot-Import-Lauf)…"
call_deferred("_set_status", info)
var godot := TestRun.find_godot(cfg)
if godot.is_empty():
call_deferred("_update_finished", false, "Update abgebrochen — kein Godot-Programm für den Import-Lauf:", [
"Der Editor läuft nicht über ein Godot-Binary und es ist kein godot_path gesetzt.",
"Trage in scopedvr_editor.cfg (Godot-App-Daten) ein:",
" [test]",
" godot_path=\"/pfad/zu/godot\"",
])
return
var imp := EditorUpdater.run_import(godot, project_dir)
if not bool(imp["ok"]):
call_deferred("_update_finished", false, "Update ABGEBROCHEN — der Import ist fehlgeschlagen, es wird NICHT neu gestartet:", imp["errors"])
return
call_deferred("_update_finished", true, "", [])
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
if not ok:
update_banner.visible = true
_show_errors(title, details)
_set_status("Update fehlgeschlagen — der Editor bleibt unverändert (es ist nichts kaputt gemacht).", true)
return
var restart := EditorUpdater.resolve_restart(TestRun.load_cfg())
var project_dir := ProjectSettings.globalize_path("res://")
var pid := -1
if str(restart["mode"]) == "command":
var argv: PackedStringArray = restart["argv"]
pid = OS.create_process(str(argv[0]), argv.slice(1))
else:
var godot := TestRun.find_godot(TestRun.load_cfg())
if godot.is_empty():
_show_errors("Update installiert — Neustart nicht möglich:", [
"Der neue Stand ist geladen, aber das Godot-Programm für den Neustart ist unbekannt.",
"Editor einfach einmal selbst neu starten.",
])
_set_status("Update installiert — bitte den Editor einmal selbst neu starten.")
return
pid = OS.create_process(godot, EditorUpdater.self_restart_args(project_dir))
if pid <= 0:
_show_errors("Update installiert — Neustart fehlgeschlagen:", [
"Der neue Stand ist geladen, aber der Neustart wurde vom System abgelehnt.",
"Editor einfach einmal selbst neu starten.",
])
_set_status("Update installiert — bitte den Editor einmal selbst neu starten.")
return
_set_status("Update installiert (%s) — Editor startet neu…" % EditorUpdater.short_sha(str(_update_result.get("remote", ""))))
await get_tree().create_timer(1.0).timeout
get_tree().quit()
## „▶ Karte testen" (Karte t_1cdc1a72): Lauf-Modus IM Map-Builder selbst —
## erste Person, WASD + Maus, ohne Client-Ordner/VR/Server (Slys verbindlicher
## Wunsch: der Map-Builder ist überall installierbar und testet die Map ohne
## jegliche ScopedVR-Client-Abhängigkeit). Der ALTE Client-Start (Export nach
## Temp + eigener Client-Prozess, Karte t_b84af40f) bleibt als „alt" in
## src/editor/test_run.gd — der Rückbau kommt in einer späteren Karte.
func _on_test_run() -> void:
if doc.is_empty():
_set_status("Erst eine Map anlegen (Datei → Neue Map), dann testen.", true)
return
_start_playtest()
## Lauf-Modus anwerfen: Werkzeuge einfrieren, Panels ausblenden, Maus einfangen,
## PlaytestMode in der 3D-Welt anlegen (Spieler am ersten Spawnpunkt der Map).
func _start_playtest() -> void:
if playtest != null:
return
# Editor-Zustand einfrieren (ohne Auswahl/Dokument/History anzufassen):
# laufende Ghost-Platzierung zurückrollen, Gizmo-Zug abbrechen (beides
# rollt ohne History-Eintrag zurück), Werkzeug-Modus auf Auswahl umschalten.
interaction.call("cancel_placement")
if gizmo != null and gizmo.has_method("cancel_drag_if_active"):
gizmo.call("cancel_drag_if_active")
_playtest_tool_mode = tool_mode
tool_mode = "objects"
_playtest_set_frozen(true)
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
playtest = load("res://src/editor/playtest/playtest_mode.gd").new()
playtest.ui_panels = [ui_top_bar, ui_left_panel, ui_right_panel, ui_status_bar]
# Trenner-Lagen JETZT sichern (Sly-Befund 20.10.: beim Verstecken der
# Panels klappen die Trenner zusammen — nach dem Playtest mussten die
# Seitenbereiche neu gezogen werden). Wiederherstellung im Exit-Handler.
_split_lagen_playtest.clear()
for sname in ["MainSplit", "BottomSplit"]:
var sp := _find_split(sname)
if sp != null:
_split_lagen_playtest[sname] = sp.split_offsets.duplicate()
playtest.ui_status = status_label
playtest.help_label = help_label
playtest.exit_requested.connect(_on_playtest_exit_requested)
world3d.add_child(playtest)
camera.current = false # Editor-Orbit abgeben — die Spiel-Kamera übernimmt
# Karte t_96e3c4ee: Fallback-Reihenfolge mit Klartext-Hinweis — gesetzter
# Test-Start → erster Team-Spawnpunkt → Kartenmitte.
var resolved: Dictionary = TestStart.resolve_start(doc, test_start_point)
playtest.enter(doc, view_builder, resolved["pos"])
_set_status("▶ Testlauf läuft: WASD laufen, Shift rennen, Space springen, Maus umsehen — ESC zurück zum Editor. (%s)" % str(resolved["status"]))
## Editor-Input einfrieren/freigeben (Kamera-Flug, Werkzeuge, Gizmo).
## Nur auf die Werkzeug-Nodes — die Sicht (view_builder) bleibt 1:1 stehen.
func _playtest_set_frozen(on: bool) -> void:
for t in [camera, interaction, terrain_tool, spawn_tool, build_tool, test_start_tool]:
if t == null:
continue
t.set_process_unhandled_input(not on)
t.set_process(not on)
if gizmo != null:
gizmo.set_process_unhandled_input(not on)
gizmo.set_process(not on)
gizmo.visible = not on
## ESC im Lauf-Modus: sauber zurück in den Editor — Werkzeuge + Panels wieder
## an, Maus frei, Kamera + Auswahl unverändert, Dokument + Undo-Stapel unverändert.
func _on_playtest_exit_requested() -> void:
if playtest == null:
return
var mode: Node = playtest
playtest = null
mode.exit()
mode.queue_free()
_playtest_set_frozen(false)
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
camera.current = true
tool_mode = _playtest_tool_mode
_update_tool_label()
_set_status("Testlauf beendet — zurück im Editor (Auswahl und Dokument unverändert).")
# Trenner-Lagen zurücksetzen (Sly-Befund 20.10.): das Re-Layout nach dem
# Wiedereinblenden der Panels setzt die Trenner auf Defaults — die gemerkten
# Lagen deshalb deferred UND im nächsten Frame zurücksetzen (das Layout
# muss die Panels erst neu vermessen haben, sonst clampen sie erneut).
_playtest_split_lagen_zurueck.call_deferred()
get_tree().process_frame.connect(_playtest_split_lagen_zurueck, CONNECT_ONE_SHOT)
## Gemerkte Playtest-Trenner-Lagen zurück auf die Splitter schreiben.
func _playtest_split_lagen_zurueck() -> void:
if _split_lagen_playtest.is_empty():
return
for sname in _split_lagen_playtest.keys():
var sp := _find_split(str(sname))
if sp != null:
sp.split_offsets = _split_lagen_playtest[sname]
func _on_new_map_confirmed() -> void:
doc = SvmapDocument.create_new(new_dialog.get_map_name(), new_dialog.get_size_m(), new_dialog.get_weather())
current_path = ""
dirty = false
history.begin(doc)
_clear_edit_state()
_refresh_all()
_test_start_reload() # Karte t_96e3c4ee: neue Map → kein Test-Start gesetzt
_set_status("Neue Map „%s“ erstellt (%.0f × %.0f m, Wetter: %s). Wähle links ein Objekt, um zu bauen!" % [
new_dialog.get_map_name(), new_dialog.get_size_m(), new_dialog.get_size_m(), new_dialog.get_weather()])
func _on_open_selected(path: String) -> void:
var result := SvmapIO.load_from_file(path)
if not result["ok"]:
_show_errors("Map konnte NICHT geladen werden:\n(Falls sie in einem anderen Programm geändert wurde, prüfe die Datei.)", result["errors"])
_set_status("Laden fehlgeschlagen: %s — versuche eine .svmap-Datei aus dem Map-Editor oder vom Server." % path.get_file(), true)
return
doc = result["data"]
current_path = path
dirty = false
history.begin(doc)
_clear_edit_state()
_refresh_all()
_test_start_reload() # Karte t_96e3c4ee: Sidecar der geladenen Map lesen
var map_name := str(doc.get("info", {}).get("name", "?"))
_set_status("Map „%s“ geladen (%d Objekte). Klicke ein Objekt in der Karte an, um es anzupassen." % [map_name, SvmapDocument.object_count(doc)])
func _clear_edit_state() -> void:
if interaction != null:
interaction.call("clear_selection", true)
interaction.call("cancel_placement")
func _save() -> void:
if current_path.is_empty():
save_dialog.popup_centered()
return
_save_to(current_path)
func _on_save_selected(path: String) -> void:
if not path.get_extension().is_empty() and path.get_extension() != "svmap":
path += ".svmap"
_save_to(path)
func _save_to(path: String) -> void:
# Felder stehen im Dokument (Live-Bindung) — vor dem Speichern noch einmal
# sicherstellen, dass Name/Autor nicht leer sind (verständliche Meldung statt V4-Kauderwelsch).
var name_v := str(doc.get("info", {}).get("name", "")).strip_edges()
if name_v.is_empty():
_show_errors("Map konnte nicht gespeichert werden:", ["Die Map braucht einen Namen — trag ihn rechts unter „Map-Eigenschaften“ ein."])
return
var result := SvmapIO.save_to_file(path, doc)
if not result["ok"]:
_show_errors("Map konnte nicht gespeichert werden (ungültige Map-Daten):", result["errors"])
_set_status("Speichern fehlgeschlagen.", true)
return
current_path = path
dirty = false
_test_start_reload() # Karte t_96e3c4ee: Punkt wandert beim Speichern mit
_refresh_file_label()
_set_status("Map gespeichert: %s" % path)
_refresh_tree()
func _show_errors(title: String, errors: Array) -> void:
var lines := ""
for e in errors:
lines += "• %s\n" % str(e)
error_text.text = "%s\n%s" % [title, lines]
error_dialog.title = "Fehler"
error_dialog.popup_centered()
# ------------------------------------------- Karte 3/3: Globale Shortcuts ---
## Verlust-Operation (Neue Map / Öffnen): bei ungespeicherten Änderungen erst
## NACHFRAGEN (deutscher Dialog), sonst direkt ausführen. Die wartende Aktion
## liegt in _pending_after_dirty und läuft bei „Verwerfen“ an.
func _request_lossy(next: Callable) -> void:
if ShortcutPolicy.needs_dirty_confirm(dirty):
_pending_after_dirty = next
dirty_dialog.popup_centered()
return
next.call()
## „Verwerfen“ im Dirty-Dialog: die wartende Aktion (Neu/Öffnen) jetzt ausführen.
func _on_dirty_confirmed() -> void:
var next := _pending_after_dirty
_pending_after_dirty = Callable()
if next.is_valid():
next.call()
## Karte 3/3: Dürfen die globalen Tasten (F1–F5, H) gerade wirken? Pure Policy
## in ShortcutPolicy — hier nur die Fakten: Textfeld/SpinBox fokussiert? Ein
## Dialog/Popup offen? (Eigene Fenster des Editors; das Hilfe-Panel ist
## absichtlich KEIN Fenster und zählt nicht.)
func _tool_key_allowed() -> bool:
var focus := get_viewport().gui_get_focus_owner()
var text_focused: bool = focus != null \
and (focus is LineEdit or focus is TextEdit or focus is SpinBox)
var dialog_open := false
for child in get_children():
if child is Window and child.visible:
dialog_open = true
break
return ShortcutPolicy.tool_keys_allowed(text_focused, dialog_open)
## Kurzhilfe-Overlay umschalten; Statuszeile meldet den Zustand.
func _toggle_help_overlay() -> void:
help_overlay.visible = not help_overlay.visible
if help_overlay.visible:
_set_status("Kurzhilfe an — H oder Esc schließt das Overlay wieder.")
else:
_base_tool_status()
## Permanente Werkzeug-Anzeige (Karte 3/3): aktives Werkzeug + Flug-Hint —
## Sly sieht in der Statuszeile immer, wo er ist.
func _update_tool_label() -> void:
if tool_label == null:
return
var names := {
"objects": "Objekte (F2)",
"terrain": "Terrain (F1)",
"bauen": "Bauen (F3)",
"spawns": "Spawn-Zonen (F4)",
}
tool_label.text = "Werkzeug: %s · Umschalt+F = Flugmodus" % str(names.get(tool_mode, tool_mode))
## Symmetrie-Schalter (Karte t_f5935023, M1): Modus aus dem Toolbar-Menü übernehmen.
func _on_sym_mode_selected(index: int) -> void:
sym_mode = clampi(index, 0, SymmetryCore.MODE_LABELS.size() - 1)
_update_sym_ui()
if sym_mode == SymmetryCore.Mode.OFF:
_set_status("Symmetrie aus — die Karte wird nicht mehr automatisch gespiegelt.")
else:
_set_status("Symmetrie: %s — Änderungen an einer Seite werden auf die andere übertragen." %
SymmetryCore.mode_label(sym_mode))
## Symmetrie-Status: Toolbar-Text + Haken + Statuszeilen-Label (M1 Fundament).
## Das tatsächliche Spiegeln von Änderungen kommt mit M2–M4.
func _update_sym_ui() -> void:
if sym_menu == null or sym_label == null:
return
var label := "Symmetrie: %s" % SymmetryCore.mode_label(sym_mode)
sym_menu.text = label
sym_label.text = label
var popup := sym_menu.get_popup()
for i in SymmetryCore.MODE_LABELS.size():
popup.set_item_checked(i, i == sym_mode)
# --------------------------------------------------------------- History ----
func _do_undo() -> void:
if not history.can_undo():
_set_status("Nichts zum Rückgängigmachen — die History ist schon am Anfang.")
return
var desc := history.undo()
_after_structure_change()
_set_status("Rückgängig gemacht: %s. Mit Strg+Y kannst du es wiederherstellen." % desc)
func _do_redo() -> void:
if not history.can_redo():
_set_status("Nichts zum Wiederholen — schon am aktuellsten Stand.")
return
var desc := history.redo()
_after_structure_change()
_set_status("Wiederhergestellt: %s." % desc)
## F: Auswahl ins Bild zentrieren (Karte t_c46d31c2) — Kamera-Ziel auf den
## Auswahl-Mittelpunkt; im Flugmodus dreht sich nur die Blickrichtung.
func _focus_selection() -> void:
var indices: Array = interaction.selected_indices
if indices.is_empty():
_set_status("Nichts ausgewählt — F zentriert die Auswahl im Bild. Klicke zuerst ein Objekt an (im Viewport oder im Baum).")
return
var center := SelectionMath.focus_center(view_builder.object_markers, indices)
camera.call("focus_on", center)
_set_status("%d Objekt(e) ins Bild zentriert — Strg+Klick/Strg+LMB markiert weitere dazu, Entf löscht die ganze Auswahl." % indices.size())
## Karte t_2470de89: Der alte Rechtsklick-Filter (_fly_rmb_blocked) ist weg —
## RMB startet den Freelook-Flug in JEDEM Werkzeugzustand (absoluter Vorrang).
func _refresh_history_buttons() -> void:
undo_btn.disabled = not history.can_undo()
redo_btn.disabled = not history.can_redo()
undo_btn.tooltip_text = ("Rückgängig machen: %s (Strg+Z)" % history.undo_desc()) if history.can_undo() else "Nichts zum Rückgängigmachen"
redo_btn.tooltip_text = ("Wiederherstellen: %s (Strg+Y)" % history.redo_desc()) if history.can_redo() else "Nichts zum Wiederholen"
func _unhandled_key_input(event: InputEvent) -> void:
if not (event is InputEventKey) or not event.is_pressed() or event.is_echo():
return
var key_evt := event as InputEventKey
var key := key_evt.keycode
# InputEventKey hat KEIN `command_pressed` (Godot-3-API-Relikt — crashete
# bei jeder Taste, GUI-Start-Befund 15.10.). Die Godot-4-Ersatz-API ist
# is_command_or_control_pressed(): Ctrl auf Linux/Windows, Cmd auf macOS.
var ctrl: bool = key_evt.is_command_or_control_pressed()
# Lauf-Modus (t_1cdc1a72): nur ESC behandeln — ALLE anderen Editor-Tasten
# (F1-F5, Undo/Redo, Werkzeuge) sind im Spiel eingefroren.
if playtest != null and playtest.active:
if key == KEY_ESCAPE:
playtest.handle_escape()
get_viewport().set_input_as_handled()
elif key == KEY_T:
# Karte t_96e3c4ee, „jederzeit": T im Testlauf = Test-Start auf den
# Punkt unterm Fadenkreuz setzen + SOFORT dorthin versetzen
# (Augenhöhe 1,70 m, Kollision geprüft).
var tp: Dictionary = playtest.call("teleport_crosshair")
if bool(tp.get("ok")):
_test_start_apply(float(tp.get("x")), float(tp.get("z")), str(tp.get("hinweis")))
get_viewport().set_input_as_handled()
return
match key:
KEY_T:
# Karte t_96e3c4ee: T schaltet das Test-Start-Werkzeug um (Editor).
if not ctrl:
_set_tool_mode("teststart" if tool_mode != "teststart" else "objects")
get_viewport().set_input_as_handled()
KEY_Z:
if ctrl:
if event.shift_pressed:
_do_redo()
else:
_do_undo()
get_viewport().set_input_as_handled()
KEY_Y:
if ctrl:
_do_redo()
get_viewport().set_input_as_handled()
KEY_S:
if ctrl:
if event.shift_pressed:
# Strg+Umschalt+S (Karte 3/3): Speichern unter …
save_dialog.popup_centered()
else:
_save()
get_viewport().set_input_as_handled()
KEY_O:
if ctrl:
# Strg+O (Karte 3/3): Map öffnen — bei dirty erst NACHFRAGEN.
_request_lossy(func() -> void: open_dialog.popup_centered())
get_viewport().set_input_as_handled()
KEY_N:
if ctrl:
# Strg+N (Karte 3/3): Neue Map — bei dirty erst NACHFRAGEN.
_request_lossy(func() -> void: new_dialog.popup_centered())
get_viewport().set_input_as_handled()
KEY_F1, KEY_F2, KEY_F3, KEY_F4, KEY_F5:
# Karte 3/3: Werkzeugwechsel + Test-Platzhalter. NUR wirken, wenn
# kein Textfeld tippt und kein Dialog offen ist (ShortcutPolicy).
if _tool_key_allowed():
match key:
KEY_F1:
_set_tool_mode("terrain")
KEY_F2:
_set_tool_mode("objects")
KEY_F3:
_set_tool_mode("bauen")
KEY_F4:
_set_tool_mode("spawns")
KEY_F5:
# ▶ Karte testen: der echte Freigabe-/Test-Flow existiert
# (Karte t_b84af40f) — F5 ruft ihn direkt auf.
_on_test_run()
get_viewport().set_input_as_handled()
KEY_H:
# Karte 3/3: Kurzhilfe-Overlay an/aus (Esc schließt ebenfalls).
if _tool_key_allowed():
_toggle_help_overlay()
get_viewport().set_input_as_handled()
KEY_L:
# Karte t_89c0ec95: Höhenlinien FIXIEREN an/aus — unabhängig vom
# Werkzeug (wer die Linien dauerhaft will, fixiert sie hier).
# Ohne Modifier (kein Strg+L), gleiche Freigabe wie F1–F5/H:
# Tippen in Textfeldern und offene Dialoge gehen vor.
if not ctrl and _tool_key_allowed():
_on_contours_toggled(not _contours_on)
get_viewport().set_input_as_handled()
KEY_G:
if ctrl:
# Gruppieren/Lösen (Karte t_f6f29d86)
if event.shift_pressed:
interaction.ungroup_selection()
else:
interaction.group_selection()
get_viewport().set_input_as_handled()
elif tool_mode == "objects" and not interaction.placing:
# G = Greifen (Karte t_55257792, Blender-Semantik): Auswahl
# hängt an der Maus, Linksklick setzt neu, Esc bricht ab.
interaction.grab_begin()
get_viewport().set_input_as_handled()
KEY_R:
if not ctrl:
# R = Vierteldrehung 90° (Karte t_55257792): Bauen-Modus dreht
# Vorschau/Auswahl (wie bisher), Objekt-Auswahl UND Ghost drehen
# jetzt auch in 90°-Schritten (Umschalt+R = zurück). Feindrehen
# 15° bleibt am Mausrad.
var r_delta := -90.0 if event.shift_pressed else 90.0
if tool_mode == "bauen":
build_tool.call("rotate_input")
get_viewport().set_input_as_handled()
return
if interaction.placing:
interaction.place_rot_deg = wrapf(interaction.place_rot_deg + r_delta, 0.0, 360.0)
else:
interaction.rotate_selected(r_delta)
get_viewport().set_input_as_handled()
KEY_F:
if not ctrl:
if event.shift_pressed:
# Umschalt+F: Freelook-Flug dauerhaft an/aus (Karte t_c46d31c2).
camera.call("toggle_fly_lock")
get_viewport().set_input_as_handled()
else:
# F: Auswahl ins Bild zentrieren.
_focus_selection()
get_viewport().set_input_as_handled()
KEY_ESCAPE:
# Karte 3/3: Steht das Hilfe-Overlay offen, gehört Esc IHM (schließen)
# — VOR der normalen Esc-Prioritätskette.
if help_overlay.visible:
_toggle_help_overlay()
get_viewport().set_input_as_handled()
return
# Karte t_e0120dc2: Läuft gerade ein Gizmo-Zug (Achsen-Pfeil), gehört
# Esc IHM — exakt zurückrollen auf die Zug-Startposition (kein
# History-Eintrag), danach NICHT weiter in der Esc-Kette eintauchen.
if gizmo != null and gizmo.cancel_drag_if_active():
get_viewport().set_input_as_handled()
return
# Esc-Priorität über pure Funktion (Karte t_c46d31c2, testbar):
# Werkzeug verlassen → Greifen zurückrollen → Ghost-Preview
# abbrechen → Auswahl aufheben (Greifen: Karte t_55257792).
var esc := ShortcutPolicy.esc_action(tool_mode,
interaction.placing,
interaction.selected_index != -1 or not interaction.selected_indices.is_empty(),
bool(interaction.get("_grabbing")))
match esc:
"exit_terrain":
_set_tool_mode("objects")
_set_status("Terrain-Modus beendet — zurück zum Objekt-Werkzeug.")
"exit_spawns":
_set_tool_mode("objects")
_set_status("Spawn-Werkzeug beendet — zurück zum Objekt-Werkzeug.")
"exit_bauen":
_set_tool_mode("objects")
_set_status("Bauen-Modus beendet — zurück zum Objekt-Werkzeug.")
"exit_teststart":
# Der gesetzte Test-Start bleibt bestehen (nur Werkzeug aus).
_set_tool_mode("objects")
_set_status("Test-Start-Werkzeug beendet — zurück zum Objekt-Werkzeug.")
"cancel_grab":
interaction.grab_cancel()
"cancel_placement":
interaction.cancel_placement()
_set_status("Platzieren abgebrochen.")
"clear_selection":
interaction.clear_selection()
_set_status("Auswahl aufgehoben.")
if esc != "none":
get_viewport().set_input_as_handled()
KEY_DELETE:
if not ctrl:
if interaction.placing:
return
if tool_mode == "spawns":
spawn_tool.call("delete_selected")
elif tool_mode == "bauen":
build_tool.call("delete_selected")
elif tool_mode == "teststart":
# Karte t_96e3c4ee: Entf im Test-Start-Werkzeug = Punkt löschen.
_test_start_delete()
else:
interaction.delete_selected()
get_viewport().set_input_as_handled()
KEY_D:
if ctrl and tool_mode == "objects":
interaction.duplicate_selected()
get_viewport().set_input_as_handled()
KEY_PAGEUP:
if interaction.placing:
return
if ctrl:
# Strg+Bild↑ (Karte t_55257792): Auswahl/Bauteil auf den Boden.
if tool_mode == "bauen":
build_tool.call("snap_selected_to_floor")
get_viewport().set_input_as_handled()
elif tool_mode == "objects" and interaction.selected_index >= 0:
interaction.snap_selection_to_floor()
get_viewport().set_input_as_handled()
elif not ctrl:
if tool_mode == "bauen":
build_tool.call("lift_selected", ObjectOps.LIFT_STEP_M)
get_viewport().set_input_as_handled()
elif tool_mode == "objects" and interaction.selected_index >= 0:
interaction.lift_selected(ObjectOps.LIFT_STEP_M)
get_viewport().set_input_as_handled()
KEY_PAGEDOWN:
if not ctrl and not interaction.placing:
if tool_mode == "bauen":
build_tool.call("lift_selected", -ObjectOps.LIFT_STEP_M)
get_viewport().set_input_as_handled()
elif tool_mode == "objects" and interaction.selected_index >= 0:
interaction.lift_selected(-ObjectOps.LIFT_STEP_M)
get_viewport().set_input_as_handled()
KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT:
# Pfeiltasten (Karte t_55257792): Auswahl im 2-m-Raster verschieben,
# Richtung = Kamera-Ausrichtung nächste Weltachse (MoveMath). Eine
# Zelle pro Tastendruck; beim Platzieren bleibt der Ghost herrschend.
if interaction.placing:
return
var fwd := 0
var side := 0
match key:
KEY_UP:
fwd = 1
KEY_DOWN:
fwd = -1
KEY_RIGHT:
side = 1
KEY_LEFT:
side = -1
var delta := MoveMath.nudge_delta(
float(camera.call("view_yaw_deg")), fwd, side)
if tool_mode == "bauen":
build_tool.call("nudge_selected", delta)
get_viewport().set_input_as_handled()
elif tool_mode == "objects" and interaction.selected_index >= 0:
interaction.nudge_selection(delta)
get_viewport().set_input_as_handled()
KEY_BRACKETLEFT, KEY_BRACKETRIGHT:
# Terrain-Pinsel [ / ] (Karte t_55257792): Größe durch die 3 Presets
# zyklisch — nur im Terrain-Werkzeug (Ziffern-Freiheit, Punkt 6).
if tool_mode == "terrain":
_terrain_brush_size_step(key == KEY_BRACKETRIGHT)
get_viewport().set_input_as_handled()
KEY_1, KEY_2, KEY_3, KEY_4:
# Terrain-Pinsel-Modi 1–4 (Karte t_55257792): Heben/Senken/Glätten/
# Malen — NUR im Terrain-Werkzeug (Konfliktfreiheit, Punkt 6; im
# Objekt-/Bauen-Modus bleiben die Ziffern unbenutzt).
if tool_mode == "terrain":
_terrain_brush_mode_key(int(key) - int(KEY_1))
get_viewport().set_input_as_handled()
# --------------------------------------------------------- Bibliothek ------
# U1: die Bibliothek wohnt im Assets-Bereich unten (assets_panel.gd); die
# Tools rufen weiter main.call("_highlight_library") — dünne Weiterleitung.
func _on_library_pressed(oid: String) -> void:
assets_mod._on_library_pressed(oid)
## U1-Delegation: die Bibliotheks-Logik wohnt in assets_panel.gd — die alten
## main.-Aufrufe (Werkzeuge/Tests) bleiben als dünne Delegaten erhalten.
func _apply_library_filter(query: String) -> void:
assets_mod._apply_library_filter(query)
func _on_library_expand_all() -> void:
assets_mod._on_library_expand_all()
func _on_library_collapse_all() -> void:
assets_mod._on_library_collapse_all()
## Der Bibliothek-Button des aktiven Platzier-Objekts leuchtet auf.
func _highlight_library(oid: String) -> void:
assets_mod.highlight_library(oid)
func object_label(oid: String) -> String:
return LibraryBrowser.object_label(oid)
## Snap to Ground (1 Klick): ALLE Objekte auf die Terrain-Höhe setzen —
## EIN Undo-Eintrag für die ganze Aktion.
func _on_snap_to_ground() -> void:
var objects: Array = doc.get("objects", [])
if objects.is_empty():
_set_status("Es gibt noch keine Objekte — platziere zuerst eins aus der Bibliothek links.")
return
var before: Dictionary = history.snapshot(["objects"])
var changed := ObjectOps.snap_to_ground(doc)
if changed == 0:
_set_status("Alle %d Objekte stehen schon auf dem Boden — nichts zu tun." % objects.size())
return
history.commit("Alle Objekte auf Boden gesetzt (%d)" % changed,
before, history.snapshot(["objects"]))
_after_structure_change()
_set_status("%d von %d Objekten auf den Boden gesetzt — Strg+Z nimmt es zurück." % [changed, objects.size()])
func _on_grid_snap_changed(pressed: bool) -> void:
grid_snap = pressed
_set_status("Raster-Snap %s — Objekte landen jetzt %s." % [
"AN" if pressed else "AUS",
"auf ganzen Metern (wie Bauklötze nebeneinander)" if pressed else "dort, wo du hinklickst"])
# ---------------------------------------------------- Werkzeug-Status ------
## Standard-Hilfstext, wenn gerade nichts "Im Gange" ist.
func _base_tool_status() -> void:
_set_status("Bereit. Links ein Objekt anklicken zum Platzieren oder in der Karte ein Objekt auswählen.")
# ------------------------------------------------------ Terrain-Werkzeug ----
# (Karte t_9aba04f1 — Höhenfeld heben/senken/glätten)
## U1-Delegation: die Widgets wohnen in den Modulen (terrain_panel.gd,
## check_panel.gd); der Koordinator hält die Werkzeug-Logik und steuert die
## Modul-Widgets über deren öffentliche Felder. Methoden, die die Tools per
## main.call(...) erreichen (_on_terrain_stroke_committed, _sync_spawn_selection,
## _refresh_spawn_status, _mark_tree_selection…), bleiben unter denselben
## Namen hier (dünne Weiterleitung) — der Werkzeug-Vertrag ist unverändert.
func _on_terrain_btn_pressed() -> void:
if terrain_mod.terrain_edit_btn.button_pressed:
_set_tool_mode("terrain")
else:
_set_tool_mode("objects")
## Werkzeug-Modus wechseln: "objects" (Platzieren/Auswahl), "terrain" (Pinsel),
## "spawns" (Spawn-Zonen malen).
func _set_tool_mode(mode: String) -> void:
if mode == tool_mode:
_sync_terrain_button()
_sync_spawn_button()
_sync_build_button()
_sync_test_start_button()
_update_tool_label()
return
if mode == "terrain":
_leave_spawn_mode()
_leave_build_mode()
_leave_test_start_mode()
_enter_terrain_mode()
elif mode == "spawns":
_leave_terrain_mode()
_leave_build_mode()
_leave_test_start_mode()
_enter_spawn_mode()
elif mode == "bauen":
_leave_terrain_mode()
_leave_spawn_mode()
_leave_test_start_mode()
_enter_build_mode()
elif mode == "teststart":
_leave_terrain_mode()
_leave_spawn_mode()
_leave_build_mode()
_enter_test_start_mode()
else:
_leave_terrain_mode()
_leave_spawn_mode()
_leave_build_mode()
_leave_test_start_mode()
_enter_objects_mode()
_update_tool_label()
# Karte t_89c0ec95: Werkzeugwechsel = Höhenlinien-Umschalter — nach dem
# Terrain-Werkzeug (F1) wieder aus, bei Terrain-Eintritt wieder an
# (sofern nicht fixiert). Signal-getrieben an diesem EINEN Wechselpunkt,
# kein per-Frame-Poll.
_sync_contours_to_builder()
func _enter_terrain_mode() -> void:
interaction.call("cancel_placement")
interaction.call("clear_selection", true)
if not terrain_tool.call("activate"):
# Abgelehnt (z. B. Auflösungs-Limit) — Button zurück, Meldung steht.
_sync_terrain_button()
return
tool_mode = "terrain"
terrain_mod.terrain_panel.visible = true
help_label.text = "Linksklick-Ziehen: Boden formen oder malen · Strg+Linksklick: Gegenteil / Gras zurück ·\n1/2/3/4: Heben/Senken/Glätten/Malen · [ / ]: Pinselgröße Klein/Mittel/Groß ·\nPinselgröße & Material rechts · Rechte Maustaste: Flug · Strg+Z: letzter Strich weg"
terrain_mod.terrain_edit_btn.button_pressed = true
terrain_tool.call("set_brush_mode", terrain_mod._selected_terrain_mode())
_set_status("Terrain-Modus an: LINKSKLICK-Ziehen formt den Boden, im Malen-Modus färbt er das Gelände (Gras/Sand/Fels). Regler rechts stellen Pinsel und Stärke ein.")
func _leave_terrain_mode() -> void:
if terrain_tool != null:
terrain_tool.call("deactivate")
if terrain_mod != null:
terrain_mod.terrain_panel.visible = false
_sync_terrain_button()
func _enter_spawn_mode() -> void:
interaction.call("cancel_placement")
interaction.call("clear_selection", true)
tool_mode = "spawns"
check_mod.spawn_panel.visible = true
help_label.text = "Linksklick: Zone setzen · Klick+Ziehen: Zone verschieben · Entf: löschen ·\nTeam/Radius rechts · Mittlere Maustaste: Kamera · Strg+Z: zurück"
check_mod.spawn_edit_btn.button_pressed = true
spawn_tool.call("activate")
spawn_tool.call("set_team", check_mod._selected_spawn_team())
spawn_tool.call("set_radius", float(check_mod.spawn_radius_slider.value))
_refresh_spawn_status()
_set_status("Spawn-Modus an: Klicke auf den Boden, um Zonen für %s zu malen. Die Kreis-Vorschau folgt der Maus." % SpawnOps.team_label(check_mod._selected_spawn_team()))
func _leave_spawn_mode() -> void:
if spawn_tool != null:
spawn_tool.call("deactivate")
if check_mod != null:
check_mod.spawn_panel.visible = false
_sync_spawn_button()
func _enter_objects_mode() -> void:
tool_mode = "objects"
help_label.text = "Linksklick: auswählen & ziehen · Umschalt+Klick: Auswahl erweitern · Rahmen aufziehen: mehrere wählen · Strg+G: gruppieren\nR: 90° drehen (Mausrad: 15° fein) · Pfeiltasten: 2-m-Raster · G: greifen (Esc: zurück) · Strg+D: duplizieren · Bild↑/↓: anheben/absenken\nStrg+Bild↑: auf Boden setzen · Strg halten: Snap-Zwang · Spiegeln rechts · Entf: löschen · Strg+Z: zurück"
_base_tool_status()
# ------------------------------------------------------ Bauen-Werkzeug ------
# (Karte t_97ea59c5 — Modular-Builder-Pieces direkt im App-Skelett; die
# Godot-Editor-UI des Plugins bleibt unangetastet im Addon aktivierbar.)
# U1: die Bauteil-Widgets wohnen im Terrain-Tab (terrain_panel.gd, Werkzeug-
# Zusammenhang); die Werkzeug-Logik bleibt hier im Koordinator. Tests/Tools
# erreichen sie über main.build_edit_btn/main.build_kind_buttons wie vorher.
func _on_build_btn_pressed() -> void:
if build_edit_btn.button_pressed:
_set_tool_mode("bauen")
else:
_set_tool_mode("objects")
func _on_build_kind_pressed(kind_id: String) -> void:
if tool_mode != "bauen":
_set_tool_mode("bauen")
for other in build_kind_buttons:
(build_kind_buttons[other] as Button).button_pressed = false
(build_kind_buttons[kind_id] as Button).button_pressed = true
build_tool.call("set_kind", kind_id)
func _enter_build_mode() -> void:
interaction.call("cancel_placement")
interaction.call("clear_selection", true)
tool_mode = "bauen"
build_panel.visible = true
help_label.text = "Linksklick: Bauteil setzen (auch auf andere Teile = stapeln) · Umschalt+Klick: Bauteil auswählen ·\nR: drehen (90°) · Pfeiltasten: im 2-m-Raster verschieben · Bild↑/↓: anheben/absenken · Strg+Bild↑: auf Boden · Entf: löschen · Mittlere Maustaste: Kamera · Strg+Z: zurück"
build_edit_btn.button_pressed = true
build_tool.call("activate")
_set_status("Bauen-Modus an: Bauteil rechts anklicken, dann in die Karte klicken — alles rastet aufs 2-m-Raster.")
func _leave_build_mode() -> void:
if build_tool != null:
build_tool.call("deactivate")
if build_panel != null:
build_panel.visible = false
_sync_build_button()
func _sync_build_button() -> void:
build_edit_btn.button_pressed = tool_mode == "bauen"
func _sync_terrain_button() -> void:
terrain_mod.terrain_edit_btn.button_pressed = tool_mode == "terrain"
func _terrain_brush_size_step(up: bool) -> void:
terrain_mod.terrain_brush_size_step(up)
func _terrain_brush_mode_key(mode_idx: int) -> void:
var t: Control = terrain_mod
var btn: Button = null
match mode_idx:
0:
btn = t.terrain_mode_raise
1:
btn = t.terrain_mode_lower
2:
btn = t.terrain_mode_smooth
3:
btn = t.terrain_mode_paint
if btn == null or terrain_tool == null:
return
btn.set_pressed(true)
var labels := ["Heben", "Senken", "Glätten", "Malen"]
var extra := ""
if mode_idx == 3:
var mats: Array = SvmapIO.MATERIAL_IDS
extra = " (Material: %s — mit der Palette rechts wechseln)" % str(mats[clampi(int(terrain_tool.get("paint_material")), 0, mats.size() - 1)])
_set_status("Pinsel-Modus: %s%s — Linksklick-Ziehen formt/malt jetzt so (Ziffern 1–4 wechseln)." % [str(labels[mode_idx]), extra])
## Ein Pinsel-Strich ist fertig (Doc + History schon aktualisiert vom Tool).
func _on_terrain_stroke_committed() -> void:
dirty = true
_refresh_file_label()
_refresh_stats()
_refresh_history_buttons()
view_builder.call("rebuild_terrain_from_doc", doc)
var desc: String = history.undo_desc()
_set_status("%s — Strg+Z macht den ganzen Strich rückgängig." % desc)
## Terrain-Modus nach Laden/Neu/Undo mit dem Doc synchron halten (Cache frisch,
## Auflösungs-Limit prüfen — sonst dezenter Rückschalter).
func _recheck_terrain_mode() -> void:
if tool_mode != "terrain" or terrain_tool == null:
return
if not terrain_tool.call("refresh_from_doc"):
terrain_tool.call("deactivate")
tool_mode = "objects"
terrain_mod.terrain_panel.visible = false
_sync_terrain_button()
_update_tool_label()
# Karte t_89c0ec95: Dezenter Terrain-Rückschalter = ebenfalls ein
# Werkzeugwechsel — Linien wieder ausblenden (sofern nicht fixiert).
_sync_contours_to_builder()
help_label.text = "Linksklick: auswählen & ziehen · Umschalt+Klick: Auswahl erweitern · Rahmen aufziehen: mehrere wählen · Strg+G: gruppieren\nR: 90° drehen (Mausrad: 15° fein) · Pfeiltasten: 2-m-Raster · G: greifen (Esc: zurück) · Strg+D: duplizieren · Bild↑/↓: anheben/absenken\nStrg+Bild↑: auf Boden setzen · Strg halten: Snap-Zwang · Spiegeln rechts · Entf: löschen · Strg+Z: zurück"
_set_status("Terrain-Modus aus: Diese Map passt nicht ins Editor-MVP (Terrain-Auflösung über %d) — die Objekte bleiben normal bearbeitbar." % TerrainField.EDITOR_MAX_RESOLUTION, true)
# ---------------------------------------------------- Spawn-Zonen-Werkzeug ---
func _on_spawn_btn_pressed() -> void:
check_mod._on_spawn_btn_pressed()
func _sync_spawn_button() -> void:
check_mod.spawn_edit_btn.button_pressed = tool_mode == "spawns"
## Panel-Zeile „Welche Zone ist angewählt?“ — WIRD vom Spawn-Tool gerufen.
func _sync_spawn_selection() -> void:
check_mod.sync_spawn_selection()
## LIVE-Validierung (Loader-Regeln) — immer sichtbar, nach JEDEM Struktur-Wechsel.
func _refresh_spawn_status() -> void:
check_mod.refresh_spawn_status()
## Map-Fertig-Check (Export-Button-Aktion): Klartext-Liste + Shake, wenn fehlt.
func _on_check_map() -> void:
var report := SpawnOps.ready_report(doc, manifest)
var lines := ""
for line in report["lines"]:
lines += "%s\n" % str(line["text"])
var hint := str(line.get("hint", ""))
if hint != "":
lines += " → %s\n" % hint
check_text.text = lines
check_dialog.popup_centered()
if report["ok"]:
_set_status("✅ Karte prüfen: Alles bereit zum Export! Speichern erzeugt die signierte .svmap-Datei.")
else:
_shake_button(_registry_toolbar_button("karte_pruefen"))
_set_status("Map ist noch nicht bereit — siehe Warnungen im Map-Check-Fenster.", true)
## Kurzes Wackeln des Buttons — AUFMERKSAMKEIT statt Fehlerbombe (Chef-Grundsatz).
func _shake_button(btn: Control) -> void:
var orig: Vector2 = btn.position
var tween := create_tween()
for offset in [-9.0, 9.0, -6.0, 6.0, -3.0, 0.0]:
tween.tween_property(btn, "position", orig + Vector2(offset, 0.0), 0.045)
## Toolbar-Button per Registry-ID finden (karte_pruefen / karte_testen …).
func _registry_toolbar_button(id: String) -> Control:
var found: Array = toolbar_mod.find_children(id, "Button", true, false)
return found[0] if not found.is_empty() else null
## Baum-Rückrufe wohnen jetzt im Modul (src/ui/panels/scene_tree_panel.gd);
## die Tools rufen weiter main.call("_mark_tree_selection…") — dünne
## Weiterleitungen unten (Registry-Koordinator).
# ------------------------------------------------------- Struktur-Änderung --
## Nach undo/redo/platzieren/löschen: Struktur hat sich geändert → Ansicht neu.
func _after_structure_change() -> void:
dirty = true
_refresh_file_label()
_refresh_stats()
_refresh_history_buttons()
_refresh_spawn_status()
if interaction != null:
interaction.call("clear_selection", true)
view_builder.call("rebuild", doc, manifest)
_sync_grid_to_builder()
# Karte t_89c0ec95: der Rebuild baut terrain_view NEU (Default-Pref „an“)
# — auch die Höhenlinien brauchen ihren Nacheich (Gitter-Muster). Sonst
# kehrte z. B. nach Strg+Z die Dauersicht zurück, die Sly störte.
_sync_contours_to_builder()
_refresh_tree()
_recheck_terrain_mode()
if interaction != null:
interaction.call("refresh_terrain_cache") # t_4092e3a0: Objekt-Cursor trifft das Gelände
if tool_mode == "spawns" and spawn_tool != null:
spawn_tool.call("refresh_from_doc")
if tool_mode == "bauen" and build_tool != null:
build_tool.call("refresh_from_doc")
# --------------------------------------------------------------- Anzeige ---
func _refresh_all() -> void:
_loading_ui_values = true
map_mod.refresh_values()
_loading_ui_values = false
_refresh_tree()
_refresh_stats()
_refresh_file_label()
_refresh_history_buttons()
_refresh_viewport()
_refresh_spawn_status()
_recheck_terrain_mode()
func _refresh_viewport() -> void:
view_builder.call("rebuild", doc, manifest)
_sync_grid_to_builder()
_sync_contours_to_builder()
# Karte K1b (t_eca890c5): Laden/Neu = Ansicht auf den Karten-INHALT
# (Objekte + Bauteile + Spawnpunkte; Terrain zählt nicht) — ohne Inhalt
# wie bisher die ganze Karte. Pos1 (Home) bleibt ganze Karte.
var fb: Dictionary = doc.get("bounds", {})
var fmn: Array = fb.get("min", [])
var fmx: Array = fb.get("max", [])
if fmn.size() == 3 and fmx.size() == 3:
camera.call("fit_to_document", doc,
Vector3(fmn[0], fmn[1], fmn[2]), Vector3(fmx[0], fmx[1], fmx[2]))
# --- Maßstabsgitter (Karte t_7ac70014) -------------------------------------
func _on_grid_toggled(pressed: bool) -> void:
_grid_on = pressed
_sync_grid_to_builder()
## Nach jedem Viewport-Rebuild: Nutzer-Wahl (bzw. Boden-Referenz ohne Terrain-
## Block) wieder anwenden — der Rebuild setzt das Gitter auf seinen Default.
func _sync_grid_to_builder() -> void:
if view_builder == null:
return
var no_terrain: bool = doc.get("terrain", {}).is_empty()
view_builder.call("set_grid_visible", _grid_on or no_terrain)
if grid_btn != null and grid_btn.button_pressed != _grid_on:
grid_btn.set_pressed_no_signal(_grid_on)
# --- Höhenlinien (Karte t_3ebe4086, Werkzeug-Kopplung t_89c0ec95) ------------
## Effektive Höhenlinien-Sichtbarkeit: FIX-Anforderung des Nutzers (L/Button)
## ODER das Terrain-Werkzeug (F1) ist aktiv. Bewusst KEIN _process-Poll —
## der Hook hängt am Werkzeug-Wechselpunkt (_set_tool_mode) und am Button/L.
func _contours_effective() -> bool:
return _contours_on or tool_mode == "terrain"
func _on_contours_toggled(pressed: bool) -> void:
_contours_on = pressed
_sync_contours_to_builder()
if status_label != null:
_set_status("Höhenlinien %s — %s" % [
"FIXIERT AN" if _contours_on else "Fix aus",
"jetzt in jedem Werkzeug sichtbar (L oder Button schaltet zurück)."
if _contours_on else
"sichtbar nur noch im Terrain-Werkzeug (F1). L fixiert sie wieder."])
## Nach jedem Viewport-Rebuild und nach JEDEM Werkzeug-Wechsel: die effektive
## Wahl anwenden — der Rebuild baut das Terrain-Mesh (und damit dessen
## Shader-Uniform) neu, der Werkzeugwechsel blendet die Linien ein/aus.
func _sync_contours_to_builder() -> void:
if view_builder == null:
return
var terrain_view = view_builder.get("terrain_view")
if terrain_view != null and is_instance_valid(terrain_view):
terrain_view.call("set_contours", _contours_effective())
if contours_btn != null and contours_btn.button_pressed != _contours_on:
contours_btn.set_pressed_no_signal(_contours_on)
# --- Baum / Auswahl-Sync (U1: Module, dünne Weiterleitung) -------------------
func _refresh_tree() -> void:
tree_mod.refresh_tree()
## Baum-Klicks kommen im Modul an; die Viewport-Seite (interaction.gd) ruft
## weiter main.call("_mark_tree_selection…") — hier weiterleiten.
func _mark_tree_selection(idx: int) -> void:
tree_mod.mark_tree_selection(idx)
func _mark_tree_selection_multi(indices: Array) -> void:
tree_mod.mark_tree_selection_multi(indices)
## Rechte Panel-Sektion synchronisieren (wird von interaction.gd gerufen).
func _sync_selection_panel() -> void:
selection_mod.sync_selection()
## Hilfs-Meldung zur frischen Auswahl (wird von interaction.gd gerufen).
func _selection_status(idx: int) -> void:
selection_mod.selection_status(idx)
func _refresh_stats() -> void:
var count := SvmapDocument.object_count(doc)
var poly := SvmapDocument.poly_usage(doc, manifest)
var budget := int(SvmapIO.MAX_TOTAL_POLY)
stats_objects.text = "Objekte: %d / 5000 · Poly-Budget: %s / %s (%.0f %%) · Bauteile: %d" % [
count, _fmt_int(int(poly)), _fmt_int(budget), 100.0 * poly / budget,
BuildOps.piece_count(doc)]
# GLB-Fehlende Objekte (Karte t_9b50b231): Platzhalter laufen, aber Sly soll
# sehen, WELCHE echten Modelle im Mesh-Lager nachgelegt werden müssen.
if view_builder != null and not (view_builder.get("fehlende_glbs") as Array).is_empty():
var fehlend: Array = view_builder.get("fehlende_glbs")
var nennung := ", ".join(fehlend.slice(0, 3))
if fehlend.size() > 3:
nennung += " … (+%d weitere)" % (fehlend.size() - 3)
stats_objects.text += " · ⚠ GLB fehlt: %s" % nennung
# Terrain-Anteil am Quest-3-Budget (res 513 = 524.288 Dreiecke, map_format.md §4)
var tris := 0
var res := 0
if view_builder != null and view_builder.get("terrain_view") != null:
tris = int(view_builder.terrain_view.call("triangle_count"))
res = int(view_builder.terrain_view.call("resolution"))
var quest_budget := 524288
var pct := 100.0 * float(tris) / float(quest_budget)
var fps := Engine.get_frames_per_second()
stats_terrain.text = " · Terrain: %s² Grid, %s Dreiecke (%.0f %% Quest-Budget) · %d FPS" % [
str(res) if res > 0 else "—", _fmt_int(tris), pct, fps]
func _refresh_file_label() -> void:
var map_name := str(doc.get("info", {}).get("name", "Ohne Namen"))
if current_path.is_empty():
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
# U7 (Karte t_2336c58e): ungespeicherte Änderungen auch im FENSTERTITEL —
# so sieht man sie selbst dann, wenn die Datei-Leiste egalisiert ist.
get_window().title = "%s — %s%s" % [APP_TITLE, map_name,
" (ungespeichert)" if dirty else ""]
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
# U7: Statusfarben über das Theme (Editor/colors/status_error/_ok).
status_label.add_theme_color_override("font_color",
get_theme_color("status_error" if is_error else "status_ok", "Editor"))
func _fmt_int(value: int) -> String:
var s := str(value)
var out := ""
while s.length() > 3:
out = "." + s.substr(s.length() - 3) + out
s = s.substr(0, s.length() - 3)
return s + out
# ------------------------------------------------------- U1 Dock-Zustand ----
## Splitter-Lagen (Assets-Höhe) aus der Config lesen — NACH _build_ui.
func _load_dock_state() -> void:
var cfg := ConfigFile.new()
if cfg.load(UIScale.cfg_path()) == OK:
var l: Variant = cfg.get_value(UIScale.WIN_SECTION, "dock_links", null)
if l is float or l is int:
_dock_offsets["links"] = float(l)
var r: Variant = cfg.get_value(UIScale.WIN_SECTION, "dock_rechts", null)
if r is float or r is int:
_dock_offsets["rechts"] = float(r)
var u: Variant = cfg.get_value(UIScale.WIN_SECTION, "dock_unten", null)
if u is float or u is int:
_dock_offsets["unten"] = float(u)
_apply_dock_state()
## Gemerkte Lagen anwenden — deferred, weil das Layout erst nach dem ersten
## Layout-Durchlauf vermessene Größen hat.
func _apply_dock_state() -> void:
if DisplayServer.get_name() == "headless":
return
_apply_dock_state_deferred.call_deferred()
func _apply_dock_state_deferred() -> void:
if _dock_offsets.is_empty():
return
var main_split := _find_split("MainSplit")
if main_split != null and _dock_offsets.has("links"):
main_split.split_offset = UIScale.f(float(_dock_offsets["links"]))
var bottom := _find_split("BottomSplit")
if bottom != null and _dock_offsets.has("unten"):
bottom.split_offset = -UIScale.f(float(_dock_offsets["unten"]))
func _find_split(sname: String) -> SplitContainer:
var found := find_children(sname, "SplitContainer", true, false)
return found[0] if not found.is_empty() else null
## Splitter-Lagen merken (beim Schließen, neben der Fensterlage).
func _save_dock_state() -> void:
if DisplayServer.get_name() == "headless":
return
var main_split := _find_split("MainSplit")
var bottom := _find_split("BottomSplit")
var cfg := ConfigFile.new()
cfg.load(UIScale.cfg_path())
if main_split != null:
cfg.set_value(UIScale.WIN_SECTION, "dock_links", main_split.split_offset)
if bottom != null:
cfg.set_value(UIScale.WIN_SECTION, "dock_unten", float(-bottom.split_offset))
cfg.save(UIScale.cfg_path())
# ---------------------------------------------------- Screenshot-Demo -------
## Desktop-Smoke-Hilfe: `godot ... -- --ghost-demo [x z objekt-id]` parkt die
## Platzier-Vorschau sichtbar in der Map und zoomt die Kamera heran, damit ein
## Screenshot die Vorschau zeigen kann (nur für Smoke-Tests).
func _maybe_start_demo_mode() -> void:
var args := OS.get_cmdline_user_args()
if args.has("--terrain-demo"):
_start_terrain_demo(args)
return
if args.has("--library-demo"):
_start_library_demo(args)
return
if args.has("--spawns-demo"):
_start_spawns_demo(args)
return
if args.has("--groups-demo"):
_start_groups_demo(args)
return
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"
var x := 0.0
var z := 0.0
for i in args.size():
if args[i] == "--ghost-demo":
if i + 1 < args.size():
x = float(args[i + 1])
if i + 2 < args.size():
z = float(args[i + 2])
if i + 3 < args.size():
oid = args[i + 3]
var pos := Vector3(x, SvmapDocument.terrain_height_at(doc, x, z), z)
interaction.demo_park_ghost(pos, oid)
# Demo-Aufbau: zwei Objekte setzen und eins orange auswählen — zeigt
# Ghost-Preview UND Auswahl-Markierung in einem Smoke-Screenshot.
if args.has("--ghost-demo-select"):
var before: Dictionary = history.snapshot(["objects"])
(doc["objects"] as Array).append({"id": "watchtower_wood", "pos": [6.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0})
(doc["objects"] as Array).append({"id": "crate_wood", "pos": [0.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0})
history.commit("Demo: Objekte gesetzt", before, history.snapshot(["objects"]))
interaction.call("clear_selection", true)
_refresh_all()
interaction.select_object(1, true)
else:
_refresh_history_buttons()
# Kamera nah heran, damit die Vorschau im Screenshot gut sichtbar ist.
camera.target = pos + Vector3(0.0, 0.6, 0.0)
camera.distance = 14.0
camera.pitch_deg = 32.0
camera.yaw_deg = 40.0
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):
## fährt die Terrain-Szenarien programmatisch durch und speichert PNGs:
## Hügel heben, Senke senken, glätten, Undo-Roundtrip. Die App bleibt offen
## (Sly sieht das Ergebnis live auf dem Desktop).
func _start_terrain_demo(args: PackedStringArray) -> void:
var out_dir := "/tmp/scopedvr_terrain"
var idx := args.find("--terrain-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)
_set_tool_mode("terrain")
terrain_mod.terrain_radius_slider.value = 45.0
terrain_mod.terrain_strength_slider.value = 10.0
terrain_mod.terrain_mode_raise.button_pressed = true
# 1) Hügel heben — Pinsel-Ring bleibt als visueller Beweis im Bild.
terrain_tool.call("demo_stroke", -80.0, -60.0, TerrainField.Mode.RAISE, 1.4)
terrain_tool.call("demo_park_brush", -80.0, -60.0)
_demo_camera(Vector3(-80.0, 12.0, -60.0), 150.0, 32.0, 40.0)
_set_status("Demo 1/5: Hügel GEHOBEN (Radius 45 m, Stärke 10 m/s) — der grüne Ring ist der Pinsel.")
await _demo_shot(out_dir + "/terrain_raise.png")
# 2) Senke senken (roter Ring).
terrain_mod.terrain_mode_lower.button_pressed = true
terrain_tool.call("demo_stroke", 90.0, 50.0, TerrainField.Mode.LOWER, 1.2)
terrain_tool.call("demo_park_brush", 90.0, 50.0)
_demo_camera(Vector3(90.0, 2.0, 50.0), 150.0, 32.0, -50.0)
_set_status("Demo 2/5: Senke GESSENKT (Strg+Linksklick senkt, ohne umzuschalten).")
await _demo_shot(out_dir + "/terrain_lower.png")
# 3) Hügelkuppe glätten (blauer Ring).
terrain_mod.terrain_mode_smooth.button_pressed = true
terrain_tool.call("demo_stroke", -70.0, -50.0, TerrainField.Mode.SMOOTH, 0.9)
terrain_tool.call("demo_park_brush", -70.0, -50.0)
_demo_camera(Vector3(-70.0, 10.0, -50.0), 130.0, 35.0, 20.0)
_set_status("Demo 3/5: Kuppe GLÄTTET — rau wird weich, ohne die Form zu zerstören.")
await _demo_shot(out_dir + "/terrain_smooth.png")
# 4) Undo-Roundtrip: kleiner Strich → Undo → Boden wieder flach.
terrain_mod.terrain_mode_raise.button_pressed = true
terrain_mod.terrain_radius_slider.value = 30.0
terrain_tool.call("demo_stroke", 0.0, 150.0, TerrainField.Mode.RAISE, 0.9)
terrain_tool.call("demo_park_brush", 0.0, 150.0)
_demo_camera(Vector3(0.0, 8.0, 150.0), 120.0, 35.0, 0.0)
_set_status("Demo 4/5: Test-Hügel für den Undo-Vergleich geformt.")
await _demo_shot(out_dir + "/terrain_before_undo.png")
_do_undo()
_set_status("Demo 5/5: Strg+Z — der GANZE Strich ist zurück, der Boden ist wieder flach.")
await _demo_shot(out_dir + "/terrain_after_undo.png")
print("TERRAIN_DEMO_OK: 5 Screenshots in %s" % out_dir)
func _demo_camera(target: Vector3, dist: float, pitch: float, yaw: float) -> void:
camera.target = target
camera.distance = dist
camera.pitch_deg = pitch
camera.yaw_deg = yaw
camera.call("_apply")
func _demo_shot(path: String) -> void:
for i in 5:
await get_tree().process_frame
await RenderingServer.frame_post_draw
var img := get_viewport().get_texture().get_image()
img.save_png(path)
print("DEMO_SHOT: %s (%d×%d)" % [path, img.get_width(), img.get_height()])
var tv = view_builder.get("terrain_view")
if tv != null and is_instance_valid(tv):
var mi = tv.get("_mi")
if mi != null and is_instance_valid(mi) and mi.mesh != null:
var aabb: AABB = mi.mesh.get_aabb()
print("DEMO_AABB: pos=%s size=%s tris=%d" % [aabb.position, aabb.size, tv.triangle_count()])
print("DEMO_CAM: cam=%s target=%s" % [camera.global_position, camera.target])
## `godot ... -- --library-demo [ordner]` — Desktop-Smoke (Karte t_3a998ae4):
## Gruppen-Bibliothek + platzierte Objekte + Snap-to-Ground programmatisch
## durchspielen, PNGs speichern. Die App bleibt offen.
func _start_library_demo(args: PackedStringArray) -> void:
var out_dir := "/tmp/scopedvr_library"
var idx := args.find("--library-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)
# 1) Bibliothek-Browser: Gruppen sichtbar, Ghost-Preview aktiv.
_set_status("Demo 1/3: Bibliothek in Gruppen — ein Objekt anklicken setzt eine Vorschau in die Karte.")
interaction.demo_park_ghost(Vector3(10.0, SvmapDocument.terrain_height_at(doc, 10.0, 6.0), 6.0), "watchtower_wood")
_demo_camera(Vector3(0.0, 2.0, 0.0), 90.0, 35.0, 40.0)
await _demo_shot(out_dir + "/library_browser.png")
# 2) Objekte platziert (versch. Gruppen), eins orange ausgewählt.
var before: Dictionary = history.snapshot(["objects"])
(doc["objects"] as Array).append({"id": "watchtower_wood", "pos": [10.0, 0.0, 6.0], "scale": 1.0, "rot_y_deg": 30.0})
(doc["objects"] as Array).append({"id": "crate_wood", "pos": [-8.0, 0.0, -4.0], "scale": 1.0, "rot_y_deg": 0.0})
(doc["objects"] as Array).append({"id": "tree_pine", "pos": [-20.0, 0.0, 18.0], "scale": 1.0, "rot_y_deg": 75.0})
(doc["objects"] as Array).append({"id": "rock_large", "pos": [24.0, 0.0, -18.0], "scale": 1.0, "rot_y_deg": 0.0})
history.commit("Demo: Objekte gesetzt", before, history.snapshot(["objects"]))
interaction.call("cancel_placement")
_clear_edit_state()
_refresh_all()
interaction.select_object(0, true)
_demo_camera(Vector3(0.0, 2.0, 0.0), 110.0, 40.0, -35.0)
_set_status("Demo 2/3: Objekte platziert (Wachturm orange ausgewählt) — ziehen, drehen, löschen wie gehabt.")
await _demo_shot(out_dir + "/library_placed.png")
# 3) Snap to Ground: Objekte künstlich in die Luft hängen, dann 1 Klick.
var float_before: Dictionary = history.snapshot(["objects"])
for obj in (doc["objects"] as Array):
var pos: Array = obj["pos"]
obj["pos"] = [float(pos[0]), float(pos[1]) + 7.0, float(pos[2])]
history.commit("Demo: Objekte schweben", float_before, history.snapshot(["objects"]))
_refresh_all()
_demo_camera(Vector3(0.0, 6.0, 0.0), 110.0, 30.0, -35.0)
_set_status("Demo 3/3a: Objekte SCHWEBEN nach dem Terrain-Formen — ein Klick auf „Alles auf den Boden setzen\" …")
await _demo_shot(out_dir + "/library_floating.png")
_on_snap_to_ground()
_demo_camera(Vector3(0.0, 4.0, 0.0), 110.0, 38.0, -35.0)
_set_status("Demo 3/3b: … und schon stehen ALLE wieder auf dem Boden (Strg+Z nimmt den ganzen Klick zurück).")
await _demo_shot(out_dir + "/library_after_snap.png")
print("LIBRARY_DEMO_OK: 4 Screenshots in %s" % out_dir)
if args.has("--library-demo-quit"):
get_tree().quit() # Batch-Smoke (xvfb): automatisch beenden, sobald die Shots da sind
## `godot ... -- --spawns-demo [ordner]` — Desktop-Smoke (Karte t_c4162c5d):
## Spawn-Zonen-Workflow programmatisch durchspielen, PNGs speichern:
## Zonen-Übersicht → Live-Check schlägt Alarm bei fehlenden Zonen → Undo rettet →
## Map-Check-Dialog meldet bereit. Die App bleibt offen (Sly sieht das live).
func _start_spawns_demo(args: PackedStringArray) -> void:
var out_dir := "/tmp/scopedvr_spawns"
var idx := args.find("--spawns-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)
# 1) Übersicht: Default-Zonen beider Teams (Kreise) + rote Ghost-Vorschau.
_set_tool_mode("spawns")
check_mod.spawn_team_red.button_pressed = true
spawn_tool.call("demo_park_ghost", 260.0, 120.0, 1, 12.0)
_demo_camera(Vector3(160.0, 140.0, 0.0), 700.0, 48.0, -90.0)
_set_status("Demo 1/4: Spawn-Zonen — blaue Kreise = Team Blau, rote = Team Rot, halbtransparent folgt der Ghost der Maus als Vorschau.")
await _demo_shot(out_dir + "/spawns_zones.png")
# 2) ALLE roten Zonen löschen → Live-Check springt auf ❌ (BEVOR exportiert wird).
_clear_edit_state()
var before: Dictionary = SpawnOps.snapshot_blocks(history, doc)
for p in SpawnOps.points(doc, 1).duplicate():
SpawnOps.remove_point(doc, 1, str(p["id"]))
history.commit("Demo: alle roten Zonen gelöscht", before,
SpawnOps.snapshot_blocks(history, doc))
_after_structure_change()
_set_status("Demo 2/4: ALLE roten Zonen weg — der Spawn-Check (rechts) zeigt sofort ❌: Export wäre blockiert.")
await _demo_shot(out_dir + "/spawns_check.png")
# 3) Strg+Z — alle vier roten Zonen sind zurück (EIN Undo-Schritt).
_do_undo()
_set_status("Demo 3/4: Strg+Z — alle 4 roten Zonen sind auf EINMAL zurück.")
await _demo_shot(out_dir + "/spawns_undo.png")
# 4) Karte prüfen: Klartext-Liste im Dialog + Status „Alles bereit“.
_on_check_map()
_set_status("Demo 4/4: „Karte prüfen“ — alles ✅ … beim Speichern wird die .svmap automatisch signiert (sha256).")
await _demo_shot(out_dir + "/spawns_ready.png")
print("SPAWNS_DEMO_OK: 4 Screenshots in %s" % out_dir)
if args.has("--spawns-demo-quit"):
get_tree().quit() # Batch-Smoke (xvfb): automatisch beenden, sobald die Shots da sind
## `godot ... -- --groups-demo [ordner]` — Desktop-Smoke (Karte t_f6f29d86):
## Auswahl-System programmatisch durchspielen, PNGs speichern:
## Mehrfachauswahl (Rahmen simuliert) → gruppieren → Gruppe gemeinsam ziehen →
## gemeinsames Drehen → Undo-Nachweis. Die App bleibt offen (Sly sieht live).
func _start_groups_demo(args: PackedStringArray) -> void:
var out_dir := "/tmp/scopedvr_groups"
var idx := args.find("--groups-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)
# Aufbau: 4 Objekte in zwei Wohnclustern (2 nahe beieinander = spätere Gruppe).
var before: Dictionary = history.snapshot(["objects"])
(doc["objects"] as Array).append({"id": "watchtower_wood", "pos": [10.0, 0.0, 6.0], "scale": 1.0, "rot_y_deg": 0.0})
(doc["objects"] as Array).append({"id": "crate_wood", "pos": [14.0, 0.0, 9.0], "scale": 1.0, "rot_y_deg": 0.0})
(doc["objects"] as Array).append({"id": "crate_metal", "pos": [14.0, 0.0, 13.0], "scale": 1.0, "rot_y_deg": 45.0})
(doc["objects"] as Array).append({"id": "tree_pine", "pos": [-30.0, 0.0, 24.0], "scale": 1.0, "rot_y_deg": 0.0})
history.commit("Demo: Objekte gesetzt", before, history.snapshot(["objects"]))
_refresh_all()
# 1) Mehrfachauswahl: Objekte 0–2 markiert (wie nach Rahmen-Ausziehen).
interaction.call("select_many", [0, 1, 2], true)
_demo_camera(Vector3(0.0, 2.0, 0.0), 130.0, 40.0, -35.0)
_set_status("Demo 1/4: Mehrfachauswahl — 3 Objekte orange (Umschalt+Klick oder Rahmen aufziehen). Der Baum links bleibt außen vor.")
await _demo_shot(out_dir + "/groups_select.png")
# 2) Gruppieren: EIN Klick, die 3 gehören ab jetzt zusammen.
interaction.call("group_selection")
_set_status("Demo 2/4: „Gruppieren“ — die 3 Objekte sind jetzt Gruppe 1. Der Baum links ist NICHT Teil der Gruppe.")
await _demo_shot(out_dir + "/groups_grouped.png")
# 3) Gruppe gemeinsam ziehen (exakt der Drag-Release-Pfad) + gemeinsames Drehen.
interaction.call("demo_drag_selection", Vector3(24.0, 0.0, -18.0))
interaction.call("rotate_selected", 30.0)
_demo_camera(Vector3(20.0, 2.0, 0.0), 130.0, 42.0, -20.0)
_set_status("Demo 3/4: Gruppe GEMEINSAM verschoben und um die eigene Mitte gedreht — die Anordnung bleibt erhalten, der Baum stand still.")
await _demo_shot(out_dir + "/groups_moved.png")
# 4) Undo-Nachweis: Strg+Z nimmt die Drehung zurück (EIN Schritt), die
# zweite Stufe würde den Zug zurücknehmen — jede Aktion = EIN Undo-Schritt.
_do_undo()
_demo_camera(Vector3(20.0, 2.0, 0.0), 130.0, 42.0, -20.0)
_set_status("Demo 4/4: Strg+Z — die Drehung ist zurück (Zug & Drehen sind getrennte Schritte); die Gruppe bleibt gebunden.")
await _demo_shot(out_dir + "/groups_undo.png")
print("GROUPS_DEMO_OK: 4 Screenshots in %s" % out_dir)
if args.has("--groups-demo-quit"):
get_tree().quit() # Batch-Smoke (xvfb): automatisch beenden, sobald die Shots da sind
## `godot ... -- --bauen-demo [ordner]` — Desktop-Smoke (Karte t_97ea59c5):
## baut programmatisch ein kleines Haus (Fundament+Wände+Giebel+Dach+Tür-Wand
## +Treppe), zeigt Auswahl + Ghost, speichert/lädt die Map zum Beweis. Die App
## bleibt offen (Sly sieht das Ergebnis live).
func _start_bauen_demo(args: PackedStringArray) -> void:
var out_dir := "/tmp/scopedvr_bauen"
var idx := args.find("--bauen-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)
_set_tool_mode("bauen")
var bt = build_tool # untypisiert (script-erweitert) — kein := möglich
# 1) Ghost-Vorschau am Cursor + erstes Fundament.
bt.call("demo_park_ghost", "wand", Vector3(2.0, 0.2, 2.0), 0.0)
_demo_camera(Vector3(3.0, 2.0, 8.0), 14.0, 32.0, 25.0)
_set_status("Demo 1/3: Bauen-Modus — Bauteil rechts anklicken, die gelbe Vorschau rastet aufs 2-m-Raster.")
await _demo_shot(out_dir + "/bauen_ghost.png")
# 2) Kleines Haus komplett gesetzt (jedes Teil = EIN Undo-Schritt):
# Fundament, 3 Wände, Tür-Wand, 2 Giebelwände, Dach, Treppe.
bt.call("demo_place", "fundament", Vector3(3.0, 0.0, 3.0), 0.0)
bt.call("demo_place", "wand", Vector3(2.0, 0.2, 2.0), 0.0)
bt.call("demo_place", "wand", Vector3(2.0, 0.2, 4.0), 0.0)
bt.call("demo_place", "wand", Vector3(2.0, 0.2, 4.0), 90.0)
bt.call("demo_place", "tuer", Vector3(4.0, 0.2, 4.0), 90.0)
bt.call("demo_place", "giebelwand", Vector3(2.0, 2.2, 2.0), 0.0)
bt.call("demo_place", "giebelwand", Vector3(2.0, 2.2, 4.0), 0.0)
bt.call("demo_place", "dach_giebel", Vector3(3.0, 2.2, 3.0), 0.0)
bt.call("demo_place", "treppe", Vector3(8.0, 0.0, 7.0), 0.0)
_after_structure_change()
bt.call("select_index", 7) # Dach ausgewählt (orangener Rahmen)
_demo_camera(Vector3(9.0, 4.5, 10.0), 16.0, 35.0, 40.0)
_set_status("Demo 2/3: Kleines Haus gebaut — Fundament, Wände, Tür-Öffnung, Giebelwände, Dach, Treppe. Dach ist ausgewählt (orange).")
await _demo_shot(out_dir + "/bauen_haus.png")
# 3) Speichern → Laden → weiterbauen (Abnahme-Flow): Map roundtrippen,
# dann eine zusätzliche Wand — sie muss nach dem Laden sauber passen.
_save_to(out_dir + "/bauen_demo.svmap")
var reload := SvmapIO.load_from_file(out_dir + "/bauen_demo.svmap")
if bool(reload["ok"]):
doc = reload["data"]
history.begin(doc)
_clear_edit_state()
_refresh_all()
var before: Dictionary = history.snapshot(["gebaeude"])
BuildOps.ensure_block(doc).append(BuildOps.make_piece("fenster", Vector3(2.0, 0.2, 4.0), 90.0))
history.commit("Wand mit Fenster gesetzt", before, history.snapshot(["gebaeude"]))
_after_structure_change()
bt.call("demo_park_ghost", "fenster", Vector3(6.0, 0.2, 4.0), 90.0)
_demo_camera(Vector3(9.0, 4.5, 10.0), 16.0, 35.0, 40.0)
_set_status("Demo 3/3: Gespeichert, geladen und WEITERGEBAUT (Fenster-Wand dazwischen) — Strg+Z nimmt nur das neue Teil zurück.")
await _demo_shot(out_dir + "/bauen_weiter.png")
print("BAUEN_DEMO_OK: 3 Screenshots in %s (%d Bauteile, Map OK)" % [
out_dir, BuildOps.piece_count(doc)])
if args.has("--bauen-demo-quit"):
get_tree().quit() # Batch-Smoke (xvfb): automatisch beenden, sobald die Shots da sind
## --- Test-Start: Haken (T1b-Vertrag, Operator-Wiederanbindung 20.10.) --------
## Diese drei Namen erwartet tests/test_playtest_spawn.gd (56 checks) auf main.
## Sie waren beim U1-Umbau verschwunden (Suite brach mit „Nonexistent function
## '_test_start_reload'" ab und hing) — deshalb stehen sie hier bewusst fest.
## Der sichtbare Teil (Info + Löschen) lebt in src/ui/panels/test_start_panel.gd,
## der Zustand hier — genau wie vor dem Umbau.
func _test_start_apply(x: float, z: float, hinweis: String) -> void:
test_start_point = Vector3(x, 0.0, z)
TestStartStore.save_point(current_path, test_start_point)
if test_start_tool != null:
test_start_tool.call("refresh_from_doc")
_refresh_test_start_panel()
# Klartext-Hinweis des Werkzeugs (z.B. „auf dem Boden unter dem Objekt")
# mitnehmen — die Prüfsuite liest ihn aus der Statuszeile.
var ort := " (Höhe automatisch)" if hinweis.is_empty() else " (Höhe automatisch — %s)" % hinweis
_set_status("Test-Start: x=%.0f z=%.0f%s — F5 startet den Testlauf genau dort." % [x, z, ort])
func _test_start_delete() -> void:
if test_start_point == null:
return
test_start_point = null
TestStartStore.clear_point(current_path)
if test_start_tool != null:
test_start_tool.call("refresh_from_doc")
_refresh_test_start_panel()
func _test_start_reload() -> void:
test_start_point = TestStartStore.load_point(current_path)
if test_start_tool != null:
test_start_tool.call("refresh_from_doc")
_refresh_test_start_panel()
func _refresh_test_start_panel() -> void:
if test_start_panel != null and is_instance_valid(test_start_panel):
test_start_panel.call("refresh_point", test_start_point)
## --- Test-Start: Werkzeug-Modus (wie Terrain/Spawns/Bauen) --------------------
## Die Prüfsuite erwartet tool_mode == "teststart", einen leuchtenden Leisten-
## Knopf (`test_start_btn`) und das erst dann sichtbare Panel (`test_start_panel`).
func _enter_test_start_mode() -> void:
interaction.call("cancel_placement")
tool_mode = "teststart"
if test_start_tool != null:
test_start_tool.call("activate")
_sync_test_start_button()
_sync_test_start_panel_visibility()
func _leave_test_start_mode() -> void:
if test_start_tool != null:
test_start_tool.call("deactivate")
# Direkt aus-/einschalten statt über tool_mode: _set_tool_mode ruft die
# _leave_*-Funktionen BEVOR der Zielmodus gesetzt ist — ein Abgleich gegen
# tool_mode ließe das Panel offen (Befund aus test_playtest_spawn).
if test_start_panel != null:
test_start_panel.visible = false
if test_start_btn != null:
test_start_btn.button_pressed = false
func _sync_test_start_button() -> void:
if test_start_btn != null:
test_start_btn.button_pressed = (tool_mode == "teststart")
func _sync_test_start_panel_visibility() -> void:
if test_start_panel != null:
test_start_panel.visible = (tool_mode == "teststart")
## Leisten-Knopf „🎯 Test-Start" (registry.gd ACTIONS) — schaltet das Werkzeug um.
func _on_test_start_tool_pressed() -> void:
_set_tool_mode("objects" if tool_mode == "teststart" else "teststart")