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 ## core/ui/panels/ und melden sich DATENGETRIEBEN in ## core/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://core/editor/shortcut_keys.gd") const ShortcutPolicy := preload("res://core/editor/shortcut_policy.gd") # Shortcuts-Mathe Karte 2/3 (t_55257792): Pfeil-Raster + Pinsel-Presets. const MoveMath := preload("res://core/editor/move_math.gd") # Kurzhilfe-Overlay Karte 3/3 (t_11b5c25b): Gesamte Tastenliste als BBCode. const HelpText := preload("res://core/ui/help_text.gd") # Zentrale UI-Skalierung (Karte t_297221b5): EIN Faktor für Schrift/Panels/Zeilen. const UIScale := preload("res://core/ui/ui_scale.gd") # U1 (t_eaccbfed): die Registry ist die EINZIGE Layout-Quelle. const PanelRegistryScript := preload("res://core/ui/panels/registry.gd") # Editor-Theme (U1): eine Theme-Ressource als einzige Farbquelle. const THEME_PATH := "res://core/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 # core/editor/interaction.gd (script-erweitert → untypisiert) var viewport_toolbar # core/ui/viewport_toolbar.gd (Buttons oben links in der 3D-Ansicht) var gizmo # core/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 # core/editor/terrain_tool.gd (Pinsel, Karte t_9aba04f1) var spawn_tool # core/editor/spawn_tool.gd (Zonen, Karte t_c4162c5d) var tool_mode := "objects" # "objects" | "terrain" | "spawns" | "teststart" (Bauen = Objekt-Platzieren mit Rastern) # 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 # core/ui/panels/toolbar.gd var status_mod # core/ui/panels/status_bar.gd var selection_mod # core/ui/panels/selection_panel.gd (nur Logik, kein Tab mehr) var settings_mod # core/ui/panels/settings_panel.gd (im Einstellungen-Fenster) var drop_overlay: Control # Ablege-Fläche über der 3D-Ansicht (nur während Drag aktiv) var settings_dialog: AcceptDialog # Fenster „Einstellungen“ (Knopf in der Kopfleiste) var sym_mod # core/ui/panels/symmetry_panel.gd (im Map-Tab) var map_mod # core/ui/panels/map_panel.gd var terrain_mod # core/ui/panels/terrain_panel.gd var malen_mod # core/ui/panels/malen_panel.gd (Malen-Tab) var bauen_mod # core/ui/panels/bauen_panel.gd (Bauen-Tab) var check_mod # core/ui/panels/check_panel.gd var tree_mod # core/ui/panels/scene_tree_panel.gd var assets_mod # core/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: CheckButton # weiter in settings_mod (Einstellungen-Fenster) var contours_btn: CheckButton # weiter in settings_mod var size_menu: OptionButton # weiter in settings_mod var sym_menu: OptionButton # weiter in settings_mod var prop_name: LineEdit # weiter in map_mod var gizmo_step_box: SpinBox # weiter in settings_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 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 # core/editor/playtest/test_start_tool.gd var test_start_panel: Control # core/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 # core/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 resign_dialog: ConfirmationDialog # „Trotzdem laden (Prüfsumme erneuern)“ (t_9814f18f) var _resign_pending_path: String = "" 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 _hand_pose_thread: Thread = null 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://core/viewport/gizmo.gd") const INSPECTOR_PANEL := preload("res://core/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: LibraryBrowser.setze_manifest(parsed) 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 right_tabs.tab_changed.connect(_on_right_tab_changed) split.add_child(right_tabs) _build_map_sections() # --- 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://core/ui/panels/toolbar.gd") func status_bar_script() -> GDScript: return load("res://core/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 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 "map": map_mod = child "terrain": terrain_mod = child "malen": malen_mod = child "bauen": bauen_mod = child prop_name = map_mod.prop_name terrain_info = map_mod.terrain_info # Malen-Widgets wohnen im Malen-Tab; das Terrain-Modul hält die # alten Namen als Aliase (Tests/Werkzeug-Vertrag). terrain_mod.terrain_palette = malen_mod.palette terrain_mod.terrain_mat_buttons = malen_mod.mat_buttons terrain_mod.terrain_weich_label = malen_mod.weich_label terrain_mod.terrain_weich_slider = malen_mod.weich_slider terrain_mod.terrain_paint_hint = malen_mod.paint_hint # 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 ## Abschnitte im Map-Tab (Registry-Gruppe "map"): Spawn-Zonen, Test-Start-Info, ## Symmetrie. Sie hängen UNTER den Map-Eigenschaften im selben Scroll-Bereich. func _build_map_sections() -> void: var host: Control = map_mod.sections_host() for entry in PanelRegistryScript.entries("map"): var script: GDScript = load(str(entry["skript"])) var panel: Control = script.new() panel.name = str(entry["id"]) panel.setup(self) if panel.has_method("build"): panel.call("build") host.add_child(HSeparator.new()) host.add_child(panel) match str(entry["id"]): "check": check_mod = panel spawn_status = check_mod.spawn_status "symmetrie": sym_mod = panel ## Einstellungen-Fenster (Knopf ⚙ in der Kopfleiste): Inhalt kommt aus der ## Registry-Gruppe "dialog". Gitter, Höhenlinien, Symmetrie-Modus, UI-Größe, ## Raster-Snap und Gizmo-Schritt wohnen dort (vorher Kopfleiste / Objekt-Werkzeuge). func _build_settings_dialog() -> void: settings_dialog = AcceptDialog.new() settings_dialog.name = "EinstellungenFenster" settings_dialog.title = "Einstellungen" settings_dialog.exclusive = false settings_dialog.ok_button_text = "Schließen" settings_dialog.min_size = UIScale.v2i(520, 420) var scroll := ScrollContainer.new() scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED scroll.custom_minimum_size = UIScale.v2(500, 380) settings_dialog.add_child(scroll) for entry in PanelRegistryScript.entries("dialog"): var script: GDScript = load(str(entry["skript"])) var panel: Control = script.new() panel.name = str(entry["id"]) panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL panel.setup(self) if panel.has_method("build"): panel.call("build") scroll.add_child(panel) settings_mod = panel add_child(settings_dialog) grid_btn = settings_mod.grid_btn contours_btn = settings_mod.contours_btn size_menu = settings_mod.size_menu sym_menu = settings_mod.sym_menu gizmo_step_box = settings_mod.gizmo_step_box settings_mod.call("refresh") func _open_settings() -> void: if settings_dialog == null: return settings_mod.call("refresh") # So groß, dass alles ohne Scrollen sichtbar ist (Inhalt + Titel/Knopf), begrenzt aufs Fenster. var inhalt: Vector2 = settings_mod.get_combined_minimum_size() var fenster := Vector2(get_window().size) var breite := clampf(maxf(inhalt.x + UIScale.px(60), UIScale.px(640)), UIScale.px(640), fenster.x * 0.95) var hoehe := clampf(inhalt.y + UIScale.px(110), UIScale.px(520), fenster.y * 0.95) settings_dialog.popup_centered(Vector2i(int(breite), int(hoehe))) func _build_dialogs_and_overlays() -> void: selection_mod = load("res://core/ui/panels/selection_panel.gd").new() selection_mod.name = "AuswahlLogik" selection_mod.setup(self) add_child(selection_mod) _build_settings_dialog() # --- Dialoge --- new_dialog = preload("res://core/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://core/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) # Fremdformat-Dialog (t_9814f18f): Lade-Fehler NUR an der Prüfsumme (V13), # Kern-Validierung grün → „Trotzdem laden (Prüfsumme erneuern)“ statt hartem # Abbruch; der Editor signiert das Dokument dann mit dem v2-Schema neu. resign_dialog = ConfirmationDialog.new() resign_dialog.title = "Prüfsumme stimmt nicht" resign_dialog.dialog_text = "Der Inhalt der Map ist intakt, aber die Prüfsumme stimmt nicht (die Datei wurde von einem fremden Programm geschrieben oder anders formatiert).\n\nTrotzdem laden? Die Prüfsumme wird beim nächsten Speichern erneuert." resign_dialog.ok_button_text = "Trotzdem laden (Prüfsumme erneuern)" resign_dialog.cancel_button_text = "Abbrechen" resign_dialog.confirmed.connect(_on_resign_confirmed) add_child(resign_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 = "Hilfe — Werkzeuge, Steuerung und Tastenkürzel (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) ## Ziehen aus der Objekt-Bibliothek: Vorschau folgt der Maus, Loslassen setzt. func _viewport_can_drop(at_position: Vector2, data: Variant) -> bool: if not (data is Dictionary) or not (data as Dictionary).has("svr_oid") or doc.is_empty() or assets_mod == null: return false assets_mod.drag_hover(str(data["svr_oid"]), at_position) return true func _viewport_drop(at_position: Vector2, data: Variant) -> void: if data is Dictionary and (data as Dictionary).has("svr_oid") and assets_mod != null: assets_mod.drag_drop(str(data["svr_oid"]), at_position) 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 # Objekt-Kachel aus der Bibliothek in die Szene ziehen (25.10.). container.set_drag_forwarding(Callable(), _viewport_can_drop, _viewport_drop) 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://core/viewport/map_view_builder.gd")) world.add_child(view_builder) camera = Camera3D.new() camera.set_script(load("res://core/viewport/orbit_camera.gd")) camera.current = true world.add_child(camera) interaction = Node3D.new() interaction.set_script(load("res://core/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://core/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://core/editor/spawn_tool.gd")) world.add_child(spawn_tool) spawn_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://core/editor/playtest/test_start_tool.gd")) world.add_child(test_start_tool) test_start_tool.setup(self, camera, view_builder, interaction) # Werkzeugleiste oben links (Verschieben / Drehen & Kippen / Aktionen) viewport_toolbar = preload("res://core/ui/viewport_toolbar.gd").new() container.add_child(viewport_toolbar) viewport_toolbar.setup(self) # Ablege-Fläche für Bibliotheks-Kacheln: Godot ruft can_drop/drop NICHT auf einem # SubViewportContainer auf (Test-Befund 25.10.: Ziehen startete, Loslassen setzte # nichts — echter Maus-Drag, tests/probe_drag_echt.gd). Eine durchsichtige Fläche # darüber nimmt den Drop an; sie ist nur WÄHREND eines Ziehens aktiv (sonst # bleibt die Maus für Kamera/Auswahl frei). drop_overlay = Control.new() drop_overlay.name = "AblegeFlaeche" drop_overlay.set_anchors_preset(Control.PRESET_FULL_RECT) drop_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE drop_overlay.set_drag_forwarding(Callable(), _viewport_can_drop, _viewport_drop) container.add_child(drop_overlay) # Bedien-Hinweis im Viewport (Overlay) help_label = Label.new() help_label.text = "Linksklick: auswählen · Rahmen: mehrere · R: drehen · Entf: löschen · H: Hilfe" 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() 8: # Hand-Haltung der Waffen (Tool im Client-Projekt) _on_hand_pose_tool() # --------------------------------------------- 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 size_menu.select(UIScale.step_index()) ## 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) var settings_war_offen := settings_dialog != null and settings_dialog.visible _rebuild_ui_for_scale() if settings_war_offen: _open_settings.call_deferred() _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 drop_overlay != null and is_instance_valid(drop_overlay): if what == NOTIFICATION_DRAG_BEGIN: drop_overlay.mouse_filter = Control.MOUSE_FILTER_STOP elif what == NOTIFICATION_DRAG_END: drop_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE 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) ## Hand-Haltungs-Tool starten: eigenes Fenster aus dem Client-Projekt. Nach einem ## neuen Client-Stand erst importieren (im Hintergrund, kann dauern), sonst fehlen ## dem Tool neue Modelle. func _on_hand_pose_tool() -> void: if _hand_pose_thread != null: _set_status("Hand-Haltungs-Tool wird schon vorbereitet…") return var cfg := TestRun.load_cfg() var client := TestRun.find_client(ProjectSettings.globalize_path("res://"), cfg) if client.is_empty(): _set_status("Client-Ordner nicht gefunden: er muss neben dem Editor liegen (scopedvr-client) oder in den Einstellungen stehen.", true) return var godot := TestRun.find_godot(cfg) if godot.is_empty(): _set_status("Godot nicht gefunden: bitte in den Einstellungen eintragen.", true) return _set_status("Hand-Haltungs-Tool wird vorbereitet…") _hand_pose_thread = Thread.new() _hand_pose_thread.start(_hand_pose_prepare.bind(godot, client)) func _hand_pose_prepare(godot: String, client: String) -> void: var head := EditorUpdater.git_run(EditorUpdater.git_exe(TestRun.load_cfg()), client, PackedStringArray(["rev-parse", "HEAD"])) var stand := str(head.get("output", "")).strip_edges() var cfg := TestRun.load_cfg() if stand.is_empty() or str(cfg.get_value("test", "hand_pose_import", "")) != stand: var out: Array = [] OS.execute(godot, TestRun.import_args(client), out, true) _hand_pose_started.call_deferred(godot, client, stand) func _hand_pose_started(godot: String, client: String, stand: String) -> void: _hand_pose_thread.wait_to_finish() _hand_pose_thread = null var cfg := TestRun.load_cfg() cfg.set_value("test", "hand_pose_import", stand) cfg.save(TestRun.CFG_PATH) var pid := OS.create_process(godot, TestRun.hand_pose_args(client)) if pid <= 0: _set_status("Hand-Haltungs-Tool ließ sich nicht starten.", true) return _set_status("Hand-Haltungs-Tool läuft in einem eigenen Fenster.") ## 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 ## core/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://core/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, 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(), new_dialog.get_game_mode()) 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, Modus: %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(), ModusKatalog.MODE_LABELS[new_dialog.get_game_mode()]]) func _on_open_selected(path: String) -> void: var result := SvmapIO.load_from_file(path) var migration := {} if not result["ok"] and SvmapIO.migrierbar(result["errors"]): # Gelöschte Pack-Objekte (z. B. Pilze): ersetzen/überspringen statt Abbruch. var migriert := SvmapIO.load_from_file_migriert(path) if migriert["ok"]: result = migriert migration = migriert["migration"] if not result["ok"]: # t_9814f18f: NUR die Prüfsumme (V13) schlägt fehl, Kern (V3–V16) grün → # Dialog „Trotzdem laden (Prüfsumme erneuern)“ statt hartem Abbruch. if SvmapIO.checksum_only_failure(result["errors"]): _resign_pending_path = path resign_dialog.popup_centered() return _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"] # Feines Material-Raster (Karte 24.10.): alte Karten beim Öffnen anheben — # optisch gleich, ab jetzt ~1 m fein malbar. Die Datei ändert sich erst # beim nächsten Speichern (deshalb kein dirty). SvmapIO.upgrade_material_layer(doc.get("terrain", {})) # Alte Bau-Sektion „gebaeude“ (vor 25.10.) → normale Bauteil-Objekte in einer Gruppe. var uebernommen := BauOps.migriere_gebaeude(doc) current_path = path dirty = uebernommen > 0 or not migration.is_empty() 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", "?")) if not migration.is_empty() and int(migration.get("gesamt", 0)) > 0: _set_status("Map „%s“ geladen. Veraltete Objekte angepasst: %s. Bitte prüfen und speichern — vorher bleibt die Datei unverändert." % [ map_name, SvmapMigration.zusammenfassung(migration, SvmapIO._manifest_or({}).get("objects", {}))]) elif uebernommen > 0: _set_status("Map „%s“ geladen. %d alte Bauteile wurden in das neue Bausystem übernommen (Gruppe „Gebäude“) — Wandhöhe ist jetzt 3 m, bitte kurz prüfen und speichern." % [map_name, uebernommen]) else: _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)", "spawns": "Spawn-Zonen (F4)", } tool_label.text = "Werkzeug: %s" % 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_label.text = label sym_menu.select(sym_mode) if sym_mod != null and sym_mod.has_method("refresh_mode"): sym_mod.call("refresh_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 if (key == KEY_PAGEUP or key == KEY_PAGEDOWN) and not ctrl and interaction.placing \ and BauKatalog.ist_bau(interaction.place_oid): interaction.bau_geschoss_aendern(1 if key == KEY_PAGEUP else -1) 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: _open_bauen_tab() 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: var r_delta := -90.0 if event.shift_pressed else 90.0 if ctrl and not event.alt_pressed: # Strg+R / Strg+Umschalt+R: KIPPEN um X (±15°, Karte t_e166e656 # — „nicht nur rotieren, sondern auch kippen“). Bauteile werden # nie gekippt (fest ausgerichtet). if BauKatalog.ist_bau(interaction.place_oid): return var t_delta: float = -interaction.TILT_STEP_DEG if event.shift_pressed else interaction.TILT_STEP_DEG if interaction.placing: interaction.place_tilt_x = fposmod(interaction.place_tilt_x + t_delta + 3600.0, 360.0) interaction._ghost_rotation_anwenden() else: interaction.tilt_selected(true, t_delta) get_viewport().set_input_as_handled() elif event.alt_pressed and not ctrl: # Alt+R / Alt+Umschalt+R: KIPPEN um Z (±15°, Karte t_e166e656). if BauKatalog.ist_bau(interaction.place_oid): return var t_delta2: float = -interaction.TILT_STEP_DEG if event.shift_pressed else interaction.TILT_STEP_DEG if interaction.placing: interaction.place_tilt_z = fposmod(interaction.place_tilt_z + t_delta2 + 3600.0, 360.0) interaction._ghost_rotation_anwenden() else: interaction.tilt_selected(false, t_delta2) get_viewport().set_input_as_handled() elif not ctrl and not event.alt_pressed: # R = Vierteldrehung 90° (Karte t_55257792): Objekt-Auswahl UND Ghost # drehen in 90°-Schritten (Umschalt+R = zurück). Feindrehen 15° # bleibt am Mausrad (Bauteile: 90° pro Rad-Tick). if interaction.placing: interaction.place_rot_deg = wrapf(interaction.place_rot_deg + r_delta, 0.0, 360.0) interaction._ghost_rotation_anwenden() 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_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 == "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 auf den Boden. if 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 == "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 == "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 == "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, KEY_5, KEY_6: # 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) if bauen_mod != null: bauen_mod.sync_platzierung() ## Bauen-Tab: Gebäude-Info nachziehen (nach Setzen/Löschen/Undo). func _refresh_bau_info() -> void: if bauen_mod != null: bauen_mod.refresh_info() ## „Neues Gebäude beginnen“: nächstes Teil startet eine neue Gruppe. func _bau_neues_gebaeude() -> void: interaction.call("neues_gebaeude") _refresh_bau_info() _set_status("Neues Gebäude: das nächste Bauteil beginnt eine neue Gruppe.") ## Alle Teile des Gebäudes wählen, zu dem die Auswahl gehört. func _bau_gebaeude_waehlen() -> bool: var sel: Array = interaction.selected_indices if sel.is_empty(): _set_status("Nichts markiert — klicke zuerst ein Bauteil des Gebäudes an.", true) return false var alle := {} for i in sel: for m in BauOps.gebaeude_von(doc, int(i)): alle[int(m)] = true var liste: Array = alle.keys() liste.sort() interaction.call("select_many", liste, true) _set_status("Ganzes Gebäude gewählt (%d Teile) — Ziehen verschiebt, R dreht, Strg+D kopiert, Entf löscht." % liste.size()) return true func _bau_gebaeude_kopieren() -> void: if _bau_gebaeude_waehlen(): interaction.call("duplicate_selected") func _bau_gebaeude_loeschen() -> void: if _bau_gebaeude_waehlen(): interaction.call("delete_selected") 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. ## Tab Terrain/Malen/Bauen angeklickt → das Werkzeug ist sofort aktiv, kein ## zweiter Klick auf einen Start-Knopf nötig (Befund 25.10.). func _on_right_tab_changed(idx: int) -> void: if terrain_mod == null or malen_mod == null or bauen_mod == null or doc.is_empty(): return var tab: Node = (ui_right_panel as TabContainer).get_child(idx) if tab == terrain_mod: if tool_mode == "terrain" and not terrain_mod.terrain_mode_paint.button_pressed: return if terrain_mod.terrain_mode_paint.button_pressed: terrain_mod.terrain_mode_raise.button_pressed = true _set_tool_mode("terrain") elif tab == malen_mod: if tool_mode == "terrain" and terrain_mod.terrain_mode_paint.button_pressed: return terrain_mod.terrain_mode_paint.button_pressed = true _set_tool_mode("terrain") elif tab == bauen_mod: if tool_mode != "objects": _set_tool_mode("objects") # Das Verlassen des Terrain-Werkzeugs schaltet den Tab auf Malen/Terrain # zurück — der Nutzer wollte aber Bauen. (ui_right_panel as TabContainer).current_tab = bauen_mod.get_index() func _on_terrain_btn_pressed() -> void: if tool_mode != "terrain": if terrain_mod.terrain_mode_paint.button_pressed: terrain_mod.terrain_mode_raise.button_pressed = true _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_test_start_button() _update_tool_label() return if mode == "terrain": _leave_spawn_mode() _leave_test_start_mode() _enter_terrain_mode() elif mode == "spawns": _leave_terrain_mode() _leave_test_start_mode() _enter_spawn_mode() elif mode == "teststart": _leave_terrain_mode() _leave_spawn_mode() _enter_test_start_mode() else: _leave_terrain_mode() _leave_spawn_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" help_label.text = "Linksklick-Ziehen: formen bzw. malen · Strg+Klick: Gegenteil · [ ]: Pinselgröße · H: Hilfe" terrain_tool.call("set_brush_mode", terrain_mod._selected_terrain_mode()) terrain_mod.refresh_panels() _set_status("Terrain-Modus an: LINKSKLICK-Ziehen formt den Boden, im Malen-Modus färbt er das Gelände (Steppe/Sand/Fels …). Regler rechts stellen Pinsel und Stärke ein.") func _leave_terrain_mode() -> void: if terrain_tool != null: terrain_tool.call("deactivate") _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 · Ziehen: verschieben · Entf: löschen · H: Hilfe" 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 · Rahmen: mehrere · R: drehen · Entf: löschen · H: Hilfe" _base_tool_status() # ------------------------------------------------------------- Bauen ------ # Bauteile sind normale Bibliotheks-Objekte (bau_*): das Bauen-Panel startet die # Platzierung in interaction.gd (Ghost rastet über BuildSnap). Kein eigener # Werkzeug-Modus mehr — Auswahl, Drehen, Kopieren, Löschen, Undo laufen über die # Objekt-Werkzeuge. ## F3 / Menü: Bauen-Tab zeigen (wechselt aus Terrain-/Spawn-Modus zurück zu Objekten). func _open_bauen_tab() -> void: if tool_mode != "objects": _set_tool_mode("objects") if ui_right_panel is TabContainer and bauen_mod != null: (ui_right_panel as TabContainer).current_tab = bauen_mod.get_index() _set_status("Bauen: Bauteil rechts anklicken, dann in die Karte klicken — Wände rasten an Zellkanten, Böden in die Zellmitte, alles aufs 2-m-Raster.") ## Bauteil im Bauen-Tab angeklickt → Platzieren mit Snap starten. func _on_bauteil_gewaehlt(oid: String) -> void: if tool_mode != "objects": _set_tool_mode("objects") interaction.call("start_placement", oid) func _sync_terrain_button() -> void: terrain_mod.refresh_panels() 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 4: btn = t.terrain_mode_flatten 5: btn = t.terrain_mode_ramp if btn == null or terrain_tool == null: return btn.set_pressed(true) var labels := ["Heben", "Senken", "Glätten", "Malen", "Einebnen", "Rampe"] 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–6 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" _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 · Rahmen: mehrere · R: drehen · Entf: löschen · H: Hilfe" _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 (core/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_bau_info() _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() if map_mod != null: map_mod.refresh_values() # Undo/Redo: Wetter/Himmel-Auswahl nachziehen _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") # --------------------------------------------------------------- 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) var poly_pct := 100.0 * poly / budget stats_objects.text = "%d Objekte · Poly %.0f %%" % [count, poly_pct] stats_objects.tooltip_text = "Objekte: %d / 5000\nPoly-Budget: %s / %s (%.0f %%)\nBauteile: %d" % [ count, _fmt_int(int(poly)), _fmt_int(budget), poly_pct, BauOps.teile_zahl(doc)] # Nur bei knappem Budget auffällig färben (sonst ruhiger Nebentext). stats_objects.add_theme_color_override("font_color", get_theme_color( "warning" if poly_pct >= 90.0 or count >= 4500 else "field", "Editor")) # 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² · %d FPS" % [str(res) if res > 0 else "—", fps] stats_terrain.tooltip_text = "Terrain: %s² Grid, %s Dreiecke (%.0f %% Quest-Budget)" % [ str(res) if res > 0 else "—", _fmt_int(tris), pct] 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 := "box_001" 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": "water_tower_004", "pos": [6.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0}) (doc["objects"] as Array).append({"id": "box_001", "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), "water_tower_004") _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": "water_tower_004", "pos": [10.0, 0.0, 6.0], "scale": 1.0, "rot_y_deg": 30.0}) (doc["objects"] as Array).append({"id": "box_001", "pos": [-8.0, 0.0, -4.0], "scale": 1.0, "rot_y_deg": 0.0}) (doc["objects"] as Array).append({"id": "acacia_005", "pos": [-20.0, 0.0, 18.0], "scale": 1.0, "rot_y_deg": 75.0}) (doc["objects"] as Array).append({"id": "rock_008", "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": "water_tower_004", "pos": [10.0, 0.0, 6.0], "scale": 1.0, "rot_y_deg": 0.0}) (doc["objects"] as Array).append({"id": "box_001", "pos": [14.0, 0.0, 9.0], "scale": 1.0, "rot_y_deg": 0.0}) (doc["objects"] as Array).append({"id": "box_002", "pos": [14.0, 0.0, 13.0], "scale": 1.0, "rot_y_deg": 45.0}) (doc["objects"] as Array).append({"id": "acacia_005", "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: baut programmatisch ein ## kleines Haus mit denselben Funktionen wie der Klick (BauSnap + BauOps), zeigt ## Ghost, Gebäude-Auswahl und Weiterbauen nach Speichern/Laden. 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("objects") interaction.call("start_placement", "bau_wand") interaction.call("demo_park_bau", Vector3(2.0, 0.0, 1.0)) _demo_camera(Vector3(3.0, 2.0, 8.0), 14.0, 32.0, 25.0) _set_status("Demo 1/3: Bauen — die Vorschau rastet auf die Zellkante ein.") await _demo_shot(out_dir + "/bauen_ghost.png") interaction.call("cancel_placement") var gid := BauOps.haus_demo(doc, history, Vector3(4.0, 0.0, 4.0)) _after_structure_change() _demo_camera(Vector3(4.0, 1.5, 4.0), 13.0, 30.0, 35.0) _set_status("Demo 2/3: Kleines Haus gebaut (Gruppe %d) — Fundament, Wände, Tür, Fenster, Giebel, Dach, Treppe." % (gid + 1)) await _demo_shot(out_dir + "/bauen_haus.png") _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() _demo_camera(Vector3(4.0, 1.5, 4.0), 13.0, 30.0, 35.0) _set_status("Demo 3/3: Gespeichert und wieder geladen — das Haus ist als Gruppe erhalten.") await _demo_shot(out_dir + "/bauen_weiter.png") print("BAUEN_DEMO_OK: 3 Screenshots in %s (%d Bauteile, Map OK)" % [out_dir, BauOps.teile_zahl(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 core/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") # -------------------------------- Fremdformat-Dialog (t_9814f18f) ----------- ## „Trotzdem laden (Prüfsumme erneuern)“ bestätigt: Datei NUR gegen die ## Kern-Validierung (V3–V16) prüfen, v2-Prüfsumme ins Dokument schreiben und ## normal öffnen. dirty=true: das Dokument weicht von der Datei ab — der ## nächste Speicher-Vorgang schreibt das v2-Schema in die Datei. func _on_resign_confirmed() -> void: var result := SvmapIO.load_from_file_resign(_resign_pending_path) if not result["ok"]: _show_errors("Map konnte NICHT geladen werden (auch ohne Prüfsummen-Prüfung):", result["errors"]) _set_status("Laden fehlgeschlagen: %s" % _resign_pending_path.get_file(), true) return doc = result["data"] SvmapIO.upgrade_material_layer(doc.get("terrain", {})) # feines Material-Raster BauOps.migriere_gebaeude(doc) # alte Bau-Sektion → Bauteil-Objekte current_path = _resign_pending_path dirty = true history.begin(doc) _clear_edit_state() _refresh_all() _test_start_reload() var map_name := str(doc.get("info", {}).get("name", "?")) _set_status("Map „%s“ geladen — Prüfsumme erneuert (v2). Speichere die Map, um die neue Prüfsumme zu übernehmen." % map_name) _refresh_file_label()