scopedvr-map-editor/src/ui/main.gd
Hermes (godot-vr) 08a75ac430 Shortcuts 2/3 (t_55257792): R-Drehen + Pfeil-Raster + PgUp/Down + Strg+Bild↑ + Greifen + Terrain-Pinsel-Tasten.
- R = Auswahl 90° drehen (Szene um Auswahl-Mitte, Bauen-Bauteil um Fußpunkt, Gruppe komplett; Umschalt+R = -90°), Mausrad-Feindrehen 15° bleibt
- Pfeiltasten = 2-m-Raster-Verschiebung, Richtung = Kamera-Blickrichtung auf nächste Weltachse gerastet (MoveMath.nudge_delta, pure)
- Bild↑/↓ = ±0,25 m auf der GANZEN Auswahl (EIN Undo), Strg+Bild↑ = Fußpunkt aufs Terrain/Boden absetzen
- G = Greifen (Blender-Semantik): Auswahl hängt an der Maus, LMB setzt mit Raster-Snap, Esc rollt byte-genau zurück (ShortcutPolicy cancel_grab)
- Terrain-Pinsel: [ / ] = Preset-Zyklus Klein/Mittel/Groß (Slider/Label/Preset-Buttons wandern mit), 1-4 = Heben/Senken/Glätten/Malen (PAINT-Clamp-Fix, Palette mit Malen sichtbar); Ziffern NUR im Terrain-Werkzeug
- Strg halten = temporärer Snap-Zwang beim Bewegen/Greifen/Platzieren
- Tests: test_shortcuts.gd 157 Checks (R byte-genau, Pfeil-Raster, Lift-Stufen, Boden-Snap, Greifen+Esc, Pinsel-Zyklus, History-Eintrag pro Operation), test_gui_smoke.gd erweitert (38 Checks) und in run_tests.sh aufgenommen; F-Regression seit Karte 1 damit abgedeckt
- Suite 9/9 grün (696 Checks), Builder-Export 21/0, Parse-Check 11 Dateien grün
2026-10-16 17:53:10 +00:00

2369 lines
No EOL
94 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

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

extends Control
## ScopedVR Map-Editor — Hauptfenster (M2 UX-Fundament, Karte t_40d855d0).
##
## Alles UI wird hier per Code gebaut (eine einzige .tscn mit Root-Knoten).
## Struktur: Top-Bar (Menü + Rückgängig/Wiederholen) | links Objekt-Bibliothek
## | Mitte 3D-Viewport | rechts Map-Eigenschaften + Auswahl | unten Statusleiste.
##
## UX-Fundament dieser Karte (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 (orange), Ziehen verschiebt,
## R ODER Mausrad dreht (15°-Raster), Entf löscht.
## 4. Helfende Meldungen: Klartext + DER NÄCHSTE SCHRITT, keine Godot-Texte.
## 5. Deutsch & groß: Schrift ≥ 14 px, klickbare Flächen ≥ 40 px Höhe.
const APP_TITLE := "ScopedVR Map-Editor"
# Shortcut-Infrastruktur (Karte t_c46d31c2): InputMap-Actions + Esc-Priorität.
const ShortcutKeys := preload("res://src/editor/shortcut_keys.gd")
const ShortcutPolicy := preload("res://src/editor/shortcut_policy.gd")
# Shortcuts-Mathe Karte 2/3 (t_55257792): Pfeil-Raster + Pinsel-Presets.
const MoveMath := preload("res://src/editor/move_math.gd")
var manifest: Dictionary = {}
var doc: Dictionary = {} # das Map-Dictionary (1:1 svmap-Format)
var current_path := "" # leer = noch nie gespeichert
var dirty := false
var grid_snap := false # Raster-Snap (1 m) an/aus — Objekt-Werkzeuge links
var history := ActionHistory.new()
var interaction # src/editor/interaction.gd (script-erweitert → untypisiert)
var terrain_tool # src/editor/terrain_tool.gd (Pinsel, Karte t_9aba04f1)
var spawn_tool # src/editor/spawn_tool.gd (Zonen, Karte t_c4162c5d)
var build_tool # src/editor/build_tool.gd (Bauen-Modus, Karte t_97ea59c5)
var tool_mode := "objects" # "objects" | "terrain" | "spawns" | "bauen" (Modular-Builder)
# UI-Referenzen
var content_tree: Tree
var stats_objects: Label
var stats_terrain: Label
var status_label: Label
var file_label: Label
var help_label: Label
var view_builder: Node3D
var camera: Camera3D
var library_box: VBoxContainer
var library_buttons: Dictionary = {} # id → Button
var undo_btn: Button
var redo_btn: Button
var grid_btn: Button # „⊞ Gitter“ (Karte t_7ac70014)
var _grid_on := false # Nutzer-Wahl für das Maßstabsgitter
# Auswahl-Panel
var sel_label: Label
var sel_rotate_btn: Button
var sel_delete_btn: Button
var sel_dup_btn: Button # Objekt-Ops (Karte t_7567a06f)
var sel_mirror_x_btn: Button
var sel_mirror_z_btn: Button
var sel_raise_btn: Button
var sel_lower_btn: Button
var sel_group_btn: Button # „Gruppieren“ (Karte t_f6f29d86)
var sel_ungroup_btn: Button # „Gruppe lösen“ (Karte t_f6f29d86)
# Rahmen-Ausziehen (Rubberband) — Overlay über dem 3D-Viewport
var rubber_overlay: Control
var rubber_rect := Rect2() # leer = Overlay aus
# Eigenschaften-Panel
var prop_name: LineEdit
var prop_author: LineEdit
var prop_desc: LineEdit
var prop_version: SpinBox
var prop_weather: OptionButton
var terrain_info: Label
# Terrain-Werkzeug-Panel (Karte t_9aba04f1)
var terrain_edit_btn: Button
var terrain_panel: VBoxContainer
var terrain_mode_raise: Button
var terrain_mode_lower: Button
var terrain_mode_smooth: Button
var terrain_mode_paint: Button
var terrain_palette: VBoxContainer
var terrain_mat_buttons: Array = [] # Gras/Sand/Fels (ButtonGroup)
var terrain_size_btn_small: Button
var terrain_size_btn_medium: Button
var terrain_size_btn_large: Button
var terrain_radius_slider: HSlider
var terrain_radius_label: Label
var terrain_strength_slider: HSlider
var terrain_strength_label: Label
# Spawn-Zonen-Werkzeug (Karte t_c4162c5d) — großer deutscher Button + Panel
var spawn_edit_btn: Button
var spawn_panel: VBoxContainer
var spawn_team_blue: Button
var spawn_team_red: Button
var spawn_radius_slider: HSlider
var spawn_radius_label: Label
var spawn_status: Label # Live-Validierung (IMMER sichtbar, Loader-Regeln)
var spawn_sel_label: Label # Welche Zone ist an?
var spawn_delete_btn: Button
# Bauen-Werkzeug (Karte t_97ea59c5) — großer deutscher Button + Bauteil-Panel
var build_edit_btn: Button
var build_panel: VBoxContainer
var build_kind_buttons: Dictionary = {} # kind_id → Button
# Map-Check (Karte prüfen)
var check_btn: Button
var check_dialog: AcceptDialog
var check_text: Label
# Karte testen (Karte t_b84af40f)
var test_btn: Button
# Dialoge
var new_dialog: AcceptDialog
var open_dialog: FileDialog
var save_dialog: FileDialog
var error_dialog: AcceptDialog
var error_text: Label
var _loading_ui_values := false # verhindert dirty beim Auffüllen der Felder
var _tree_select_lock := false # verhindert Rückkopplung Auswahl ↔ Baum
func _ready() -> void:
# InputMap-Actions der Tastatur-Shortcuts (Karte t_c46d31c2) — VOR UI/Kamera,
# damit die Kamera-Polls (Flug W/A/S/D/E/Q) die Actions sofort kennen.
ShortcutKeys.register()
manifest = _load_manifest()
_build_ui()
_start_with_new_map()
_maybe_start_demo_mode()
func _load_manifest() -> Dictionary:
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(SvmapIO.MANIFEST_PATH))
if parsed is Dictionary:
return parsed
push_error("Bibliothek library/objects.json konnte nicht geladen werden!")
return {}
# ------------------------------------------------------------------ UI-Bau --
func _build_ui() -> void:
var theme := Theme.new()
theme.default_font_size = 16
self.theme = theme
var vbox := VBoxContainer.new()
vbox.set_anchors_preset(Control.PRESET_FULL_RECT)
vbox.add_theme_constant_override("separation", 4)
add_child(vbox)
# --- Top-Bar: Menü + Rückgängig/Wiederholen ---
var top := PanelContainer.new()
var top_h := HBoxContainer.new()
top_h.add_theme_constant_override("separation", 10)
top.add_child(top_h)
vbox.add_child(top)
var file_menu := MenuButton.new()
file_menu.text = "Datei"
file_menu.flat = false
file_menu.custom_minimum_size = Vector2(90, 40)
var popup := file_menu.get_popup()
popup.add_item("Neue Map…", 0)
popup.add_item("Öffnen…", 1)
popup.add_separator()
popup.add_item("Speichern (Strg+S)", 2)
popup.add_item("Speichern unter…", 3)
popup.add_separator()
popup.add_item("▶ Karte testen", 5)
popup.add_separator()
popup.add_item("Beenden", 4)
popup.id_pressed.connect(_on_menu_action)
top_h.add_child(file_menu)
undo_btn = Button.new()
undo_btn.text = "↩ Rückgängig"
undo_btn.custom_minimum_size = Vector2(140, 40)
undo_btn.tooltip_text = "Letzte Aktion zurücknehmen (Strg+Z)"
undo_btn.pressed.connect(_do_undo)
top_h.add_child(undo_btn)
redo_btn = Button.new()
redo_btn.text = "↪ Wiederholen"
redo_btn.custom_minimum_size = Vector2(150, 40)
redo_btn.tooltip_text = "Zurückgenommene Aktion wiederherstellen (Strg+Y)"
redo_btn.pressed.connect(_do_redo)
top_h.add_child(redo_btn)
# Maßstabsgitter-Toggle (Karte t_7ac70014): über sichtbarem Terrain ist das
# Gitter standardmäßig AUS (das Terrain ist die Bodendarstellung); wer das
# 100-m-Raster als Planungshilfe will, schaltet es hier ohne Rebuild zu.
grid_btn = Button.new()
grid_btn.text = "⊞ Gitter"
grid_btn.toggle_mode = true
grid_btn.button_pressed = false
grid_btn.custom_minimum_size = Vector2(120, 40)
grid_btn.tooltip_text = "Maßstabsgitter (100-m-Raster) im Viewport ein-/ausblenden. Über sichtbarem Terrain ist es standardmäßig aus."
grid_btn.toggled.connect(_on_grid_toggled)
top_h.add_child(grid_btn)
# Map-Fertig-Check (Karte t_c4162c5d): Klartext-Liste VOR dem Export.
check_btn = Button.new()
check_btn.text = "✅ Karte prüfen"
check_btn.custom_minimum_size = Vector2(170, 40)
check_btn.tooltip_text = "Prüft, ob die Map bereit für den Export ist — sagen wir VORHER, was fehlt"
check_btn.pressed.connect(_on_check_map)
top_h.add_child(check_btn)
# Karte testen (Karte t_b84af40f): Map SOFORT als Spiel zum Durchlaufen.
test_btn = Button.new()
test_btn.text = "▶ Karte testen"
test_btn.custom_minimum_size = Vector2(190, 40)
test_btn.tooltip_text = "Startet die Map SOFORT als Spiel zum Durchlaufen — eigenes Fenster, der Editor bleibt offen. ESC im Spiel = zurück zum Editor."
test_btn.pressed.connect(_on_test_run)
top_h.add_child(test_btn)
var title_label := Label.new()
title_label.text = " %s" % APP_TITLE
title_label.add_theme_font_size_override("font_size", 20)
top_h.add_child(title_label)
var spacer := Control.new()
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
top_h.add_child(spacer)
file_label = Label.new()
file_label.text = ""
file_label.modulate = Color(1, 1, 1, 0.6)
top_h.add_child(file_label)
# --- Hauptbereich ---
var split := HSplitContainer.new()
split.size_flags_vertical = Control.SIZE_EXPAND_FILL
split.size_flags_horizontal = Control.SIZE_EXPAND_FILL
split.split_offset = -320
vbox.add_child(split)
split.add_child(_build_left_panel())
split.add_child(_build_center_viewport())
split.add_child(_build_right_panel())
# --- Statusleiste: Hilfs-Meldungen + Budget ---
var status_panel := PanelContainer.new()
var status_h := HBoxContainer.new()
status_h.add_theme_constant_override("separation", 16)
status_panel.add_child(status_h)
vbox.add_child(status_panel)
status_label = Label.new()
status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
status_label.clip_text = true # langer Text darf das Layout NICHT sprengen
status_h.add_child(status_label)
stats_objects = Label.new()
stats_objects.modulate = Color(1, 1, 1, 0.75)
status_h.add_child(stats_objects)
# Terrain-Anteil + Editor-FPS (Perf-Zähler, Karte t_9aba04f1)
stats_terrain = Label.new()
stats_terrain.modulate = Color(1, 1, 1, 0.75)
status_h.add_child(stats_terrain)
var fps_timer := Timer.new()
fps_timer.wait_time = 0.5
fps_timer.autostart = true
fps_timer.timeout.connect(_refresh_stats)
add_child(fps_timer)
# --- Dialoge ---
new_dialog = preload("res://src/ui/new_map_dialog.gd").new()
new_dialog.confirmed.connect(_on_new_map_confirmed)
add_child(new_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 = Vector2i(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 = Vector2i(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 = Vector2(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 = Vector2(620, 0)
check_dialog.add_child(check_text)
add_child(check_dialog)
func _build_left_panel() -> PanelContainer:
var panel := PanelContainer.new()
panel.custom_minimum_size = Vector2(280, 0)
var vbox := VBoxContainer.new()
vbox.add_theme_constant_override("separation", 6)
panel.add_child(vbox)
vbox.add_child(_header_label("Objekt-Bibliothek"))
vbox.add_child(_muted_label("Objekt anklicken, dann in die Karte klicken — der Modus bleibt an bis Rechtsklick/ESC."))
var scroll := ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.custom_minimum_size = Vector2(0, 280)
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
vbox.add_child(scroll)
library_box = VBoxContainer.new()
library_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
library_box.add_theme_constant_override("separation", 4)
scroll.add_child(library_box)
_fill_library()
vbox.add_child(_separator())
vbox.add_child(_header_label("Objekt-Werkzeuge"))
var snap_btn := Button.new()
snap_btn.text = "⚡ Alles auf den Boden setzen"
snap_btn.custom_minimum_size = Vector2(0, 44)
snap_btn.tooltip_text = "Setzt ALLE Objekte auf die Terrain-Höhe — z. B. nach dem Terrain-Formen, wenn etwas in der Luft hängt"
snap_btn.pressed.connect(_on_snap_to_ground)
vbox.add_child(snap_btn)
var grid_check := CheckButton.new()
grid_check.text = "Raster-Snap (1 m)"
grid_check.custom_minimum_size = Vector2(0, 40)
grid_check.tooltip_text = "Neue und verschobene Objekte rasten auf ein 1-Meter-Raster ein"
grid_check.toggled.connect(_on_grid_snap_changed)
vbox.add_child(grid_check)
vbox.add_child(_muted_label("Raster-Snap rastet Platzieren und Ziehen auf ganze Meter."))
vbox.add_child(_separator())
vbox.add_child(_header_label("Map-Inhalt"))
content_tree = Tree.new()
content_tree.size_flags_vertical = Control.SIZE_EXPAND_FILL
content_tree.custom_minimum_size = Vector2(0, 160)
content_tree.allow_reselect = true
content_tree.select_mode = Tree.SELECT_MULTI # Strg+Klick im Baum = Mehrfachauswahl (t_c46d31c2)
content_tree.hide_root = true
content_tree.item_selected.connect(_on_tree_selected)
vbox.add_child(content_tree)
return panel
func _fill_library() -> void:
library_buttons.clear()
# Gruppen-Browser (Karte t_3a998ae4): Klapp-Überschriften je Gruppe,
# darunter die Objekte mit Thumbnail (128-px-Render, runterskaliert).
for group in LibraryBrowser.groups_in_order(manifest):
var g_label: String = group["label"]
var g_ids: Array = group["ids"]
if g_ids.is_empty():
continue
var header := Button.new()
header.text = "▾ %s (%d)" % [g_label, g_ids.size()]
header.toggle_mode = true
header.button_pressed = true
header.alignment = HORIZONTAL_ALIGNMENT_LEFT
header.custom_minimum_size = Vector2(240, 40)
library_box.add_child(header)
var group_box := VBoxContainer.new()
group_box.add_theme_constant_override("separation", 4)
library_box.add_child(group_box)
header.toggled.connect(func(on: bool) -> void:
group_box.visible = on
header.text = "%s %s (%d)" % ["▾" if on else "▸", g_label, g_ids.size()])
for oid in g_ids:
_add_library_button(str(oid), group_box)
func _add_library_button(oid: String, parent: Container) -> void:
var lib: Dictionary = manifest.get("objects", {})
var entry: Dictionary = lib.get(oid, {})
var poly := int(entry.get("poly", 0))
var btn := Button.new()
btn.text = "%s · %d Polys" % [LibraryBrowser.object_label(oid), poly]
btn.icon = LibraryBrowser.load_thumb(oid)
btn.custom_minimum_size = Vector2(240, 44)
btn.alignment = HORIZONTAL_ALIGNMENT_LEFT
btn.toggle_mode = true
btn.button_pressed = false
btn.pressed.connect(_on_library_pressed.bind(oid))
parent.add_child(btn)
library_buttons[oid] = btn
func _build_center_viewport() -> SubViewportContainer:
var container := SubViewportContainer.new()
container.stretch = true
container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
container.size_flags_vertical = Control.SIZE_EXPAND_FILL
var viewport := SubViewport.new()
viewport.own_world_3d = true
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
container.add_child(viewport)
var world := Node3D.new()
viewport.add_child(world)
view_builder = Node3D.new()
view_builder.set_script(load("res://src/viewport/map_view_builder.gd"))
world.add_child(view_builder)
camera = Camera3D.new()
camera.set_script(load("res://src/viewport/orbit_camera.gd"))
camera.current = true
world.add_child(camera)
interaction = Node3D.new()
interaction.set_script(load("res://src/editor/interaction.gd"))
world.add_child(interaction)
interaction.setup(self, camera, view_builder)
camera.set("wheel_filter", Callable(interaction, "wheel_blocked"))
# Freelook-Flug-Verdrahtung (Karte t_c46d31c2): RMT gehört beim Platzieren
# dem Ghost-Abbruch, Status-Meldungen der Kamera laufen ins Statusfeld.
camera.set("rmb_filter", Callable(self, "_fly_rmb_blocked"))
camera.set("status_cb", Callable(self, "_set_status"))
# Terrain-Pinsel (Karte t_9aba04f1) — eigener Eingabe-Knoten im Weltraum.
terrain_tool = Node3D.new()
terrain_tool.set_script(load("res://src/editor/terrain_tool.gd"))
world.add_child(terrain_tool)
terrain_tool.setup(self, camera, view_builder, interaction)
# Spawn-Zonen-Werkzeug (Karte t_c4162c5d) — ditto eigener Eingabe-Knoten.
spawn_tool = Node3D.new()
spawn_tool.set_script(load("res://src/editor/spawn_tool.gd"))
world.add_child(spawn_tool)
spawn_tool.setup(self, camera, view_builder, interaction)
# Bauen-Werkzeug (Karte t_97ea59c5) — ditto eigener Eingabe-Knoten.
build_tool = Node3D.new()
build_tool.set_script(load("res://src/editor/build_tool.gd"))
world.add_child(build_tool)
build_tool.setup(self, camera, view_builder, interaction)
# Bedien-Hinweis im Viewport (Overlay)
help_label = Label.new()
help_label.text = "Linksklick: auswählen & ziehen · Umschalt+Klick: Auswahl erweitern · Rahmen aufziehen: mehrere wählen · Strg+G: gruppieren\nR / Mausrad: drehen · Strg+D: duplizieren · Bild↑/↓: anheben/absenken · Spiegeln rechts · Entf: löschen · Strg+Z: zurück"
help_label.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
help_label.offset_left = 12
help_label.offset_bottom = -8
help_label.offset_top = -46
help_label.modulate = Color(1, 1, 1, 0.65)
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
rubber_overlay.draw_rect(rubber_rect, Color(0.45, 0.8, 1.0, 0.16), true)
rubber_overlay.draw_rect(rubber_rect, Color(0.45, 0.8, 1.0, 0.9), false, 2.0)
func _build_right_panel() -> PanelContainer:
var panel := PanelContainer.new()
panel.custom_minimum_size = Vector2(320, 0)
var scroll := ScrollContainer.new()
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
panel.add_child(scroll)
var vbox := VBoxContainer.new()
vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
vbox.add_theme_constant_override("separation", 8)
scroll.add_child(vbox)
# --- Auswahl (Anfassen/Greifen) ---
vbox.add_child(_header_label("Auswahl"))
sel_label = Label.new()
sel_label.text = "Nichts ausgewählt.\nKlicke im Viewport ein Objekt an."
sel_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
vbox.add_child(sel_label)
var sel_btns := HBoxContainer.new()
sel_btns.add_theme_constant_override("separation", 8)
vbox.add_child(sel_btns)
sel_rotate_btn = Button.new()
sel_rotate_btn.text = "Drehen (R)"
sel_rotate_btn.custom_minimum_size = Vector2(140, 40)
sel_rotate_btn.tooltip_text = "Ausgewähltes Objekt 15° drehen (auch: Mausrad)"
sel_rotate_btn.pressed.connect(func() -> void: interaction.rotate_selected(15.0))
sel_btns.add_child(sel_rotate_btn)
sel_delete_btn = Button.new()
sel_delete_btn.text = "Löschen (Entf)"
sel_delete_btn.custom_minimum_size = Vector2(140, 40)
sel_delete_btn.tooltip_text = "Ausgewähltes Objekt löschen (Strg+Z holt es zurück)"
sel_delete_btn.pressed.connect(interaction.delete_selected)
sel_btns.add_child(sel_delete_btn)
# Objekt-Ops (Karte t_7567a06f): Duplizieren · Spiegeln · Anheben/Absenken.
var dup_row := HBoxContainer.new()
dup_row.add_theme_constant_override("separation", 8)
vbox.add_child(dup_row)
sel_dup_btn = _sel_op_button("⧉ Duplizieren (Strg+D)",
"Legt eine Kopie daneben — leicht versetzt, direkt angewählt. Strg+D geht auch.",
func() -> void: interaction.duplicate_selected())
dup_row.add_child(sel_dup_btn)
var mirror_row := HBoxContainer.new()
mirror_row.add_theme_constant_override("separation", 8)
vbox.add_child(mirror_row)
sel_mirror_x_btn = _sel_op_button("⇄ Links↔Rechts",
"Spiegelt das Objekt an seiner Stelle (links↔rechts, X-Achse) — die Blickrichtung wird mitgespiegelt.",
func() -> void: interaction.mirror_selected("x"))
mirror_row.add_child(sel_mirror_x_btn)
sel_mirror_z_btn = _sel_op_button("⇅ Vor↔Zurück",
"Spiegelt das Objekt an seiner Stelle (vor↔zurück, Z-Achse) — die Blickrichtung wird mitgespiegelt.",
func() -> void: interaction.mirror_selected("z"))
mirror_row.add_child(sel_mirror_z_btn)
var lift_row := HBoxContainer.new()
lift_row.add_theme_constant_override("separation", 8)
vbox.add_child(lift_row)
sel_raise_btn = _sel_op_button("▲ Anheben (Bild↑)",
"Objekt 0,25 m anheben — es schwebt dann, auch wenn der Boden darunter anders ist. Bild↑ geht auch.",
func() -> void: interaction.lift_selected(ObjectOps.LIFT_STEP_M))
lift_row.add_child(sel_raise_btn)
sel_lower_btn = _sel_op_button("▼ Absenken (Bild↓)",
"Objekt 0,25 m absenken — stoppt am Boden (nichts wird in Hügel begraben). Bild↓ geht auch.",
func() -> void: interaction.lift_selected(-ObjectOps.LIFT_STEP_M))
lift_row.add_child(sel_lower_btn)
# Gruppen-Buttons (Karte t_f6f29d86): Mehrfachauswahl → Gruppe
var group_btns := HBoxContainer.new()
group_btns.add_theme_constant_override("separation", 8)
vbox.add_child(group_btns)
sel_group_btn = Button.new()
sel_group_btn.text = "🔗 Gruppieren"
sel_group_btn.custom_minimum_size = Vector2(150, 40)
sel_group_btn.tooltip_text = "Ausgewählte Objekte zu einer Gruppe verbinden (Strg+G) — sie ziehen und drehen dann gemeinsam"
sel_group_btn.pressed.connect(func() -> void: interaction.group_selection())
group_btns.add_child(sel_group_btn)
sel_ungroup_btn = Button.new()
sel_ungroup_btn.text = "⛓ Gruppe lösen"
sel_ungroup_btn.custom_minimum_size = Vector2(150, 40)
sel_ungroup_btn.tooltip_text = "Die Gruppe der Auswahl wieder auflösen (Strg+Umschalt+G) — Objekte bleiben, nur die Bindung löst sich"
sel_ungroup_btn.pressed.connect(func() -> void: interaction.ungroup_selection())
group_btns.add_child(sel_ungroup_btn)
vbox.add_child(_muted_label("Mehrfachauswahl: Umschalt+Klick oder Rahmen aufziehen. Eine Gruppe wandert und dreht gemeinsam — auch nach Speichern/Laden."))
vbox.add_child(_separator())
# --- Map-Eigenschaften ---
vbox.add_child(_header_label("Map-Eigenschaften"))
vbox.add_child(_field_label("Name"))
prop_name = LineEdit.new()
prop_name.max_length = 64
vbox.add_child(prop_name)
vbox.add_child(_field_label("Autor"))
prop_author = LineEdit.new()
vbox.add_child(prop_author)
vbox.add_child(_field_label("Beschreibung"))
prop_desc = LineEdit.new()
vbox.add_child(prop_desc)
vbox.add_child(_field_label("Version"))
prop_version = SpinBox.new()
prop_version.min_value = 1
prop_version.max_value = 99999
prop_version.step = 1
vbox.add_child(prop_version)
vbox.add_child(_field_label("Wetter"))
prop_weather = OptionButton.new()
prop_weather.add_item("Mittag (sonnig, klar)")
prop_weather.add_item("Abend (warmes Licht)")
prop_weather.add_item("Regen (grau, kühl)")
prop_weather.add_item("Nacht (dunkel)")
vbox.add_child(prop_weather)
vbox.add_child(_separator())
vbox.add_child(_header_label("Terrain"))
terrain_info = Label.new()
terrain_info.text = ""
terrain_info.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
vbox.add_child(terrain_info)
# --- Terrain-Werkzeug (Karte t_9aba04f1): großer deutscher Button ---
terrain_edit_btn = Button.new()
terrain_edit_btn.text = "⛰ Terrain formen"
terrain_edit_btn.custom_minimum_size = Vector2(0, 48)
terrain_edit_btn.toggle_mode = true
terrain_edit_btn.tooltip_text = "Boden heben, senken und glätten — direkt in der Karte malen"
terrain_edit_btn.pressed.connect(_on_terrain_btn_pressed)
vbox.add_child(terrain_edit_btn)
terrain_panel = VBoxContainer.new()
terrain_panel.add_theme_constant_override("separation", 6)
terrain_panel.visible = false
vbox.add_child(terrain_panel)
terrain_panel.add_child(_field_label("Pinsel-Werkzeug:"))
var mode_row := HBoxContainer.new()
mode_row.add_theme_constant_override("separation", 6)
terrain_panel.add_child(mode_row)
var mode_group := ButtonGroup.new()
terrain_mode_raise = _terrain_mode_button("Heben ⬆", mode_group)
terrain_mode_lower = _terrain_mode_button("Senken ⬇", mode_group)
terrain_mode_smooth = _terrain_mode_button("Glätten ≈", mode_group)
terrain_mode_paint = _terrain_mode_button("Malen 🖌", mode_group)
terrain_mode_raise.button_pressed = true
terrain_mode_raise.toggled.connect(_on_terrain_mode_changed)
terrain_mode_lower.toggled.connect(_on_terrain_mode_changed)
terrain_mode_smooth.toggled.connect(_on_terrain_mode_changed)
terrain_mode_paint.toggled.connect(_on_terrain_mode_changed)
mode_row.add_child(terrain_mode_raise)
mode_row.add_child(terrain_mode_lower)
mode_row.add_child(terrain_mode_smooth)
mode_row.add_child(terrain_mode_paint)
# Material-Palette (Karte t_603ec1a8): nur sichtbar im Malen-Modus.
terrain_palette = VBoxContainer.new()
terrain_palette.add_theme_constant_override("separation", 4)
terrain_palette.visible = false
terrain_panel.add_child(terrain_palette)
terrain_palette.add_child(_field_label("Material:"))
var mat_row := HBoxContainer.new()
mat_row.add_theme_constant_override("separation", 6)
terrain_palette.add_child(mat_row)
var mat_group := ButtonGroup.new()
var mat_labels := ["🌿 Gras", "🏖 Sand", "🪨 Fels"]
for mat_id in SvmapIO.MATERIAL_IDS.size():
var mat_btn := Button.new()
mat_btn.text = str(mat_labels[mat_id])
mat_btn.custom_minimum_size = Vector2(0, 40)
mat_btn.toggle_mode = true
mat_btn.button_group = mat_group
mat_btn.tooltip_text = "Malen: %s" % str(SvmapIO.MATERIAL_IDS[mat_id])
if mat_id == 0:
mat_btn.button_pressed = true
mat_btn.toggled.connect(_on_terrain_material_changed.bind(mat_id))
terrain_mat_buttons.append(mat_btn)
mat_row.add_child(mat_btn)
# Pinselgrößen-Presets (Karte t_603ec1a8): drei große Buttons + freier Slider.
terrain_panel.add_child(_field_label("Pinselgröße:"))
var size_row := HBoxContainer.new()
size_row.add_theme_constant_override("separation", 6)
terrain_panel.add_child(size_row)
var size_group := ButtonGroup.new()
terrain_size_btn_small = _terrain_size_button("Klein\n(4 m)", size_group, 4.0)
terrain_size_btn_medium = _terrain_size_button("Mittel\n(12 m)", size_group, 12.0)
terrain_size_btn_large = _terrain_size_button("Groß\n(32 m)", size_group, 32.0)
# Kein Signal: Der Handler greift auf terrain_radius_slider zu, der erst
# unten erzeugt wird — beim Panel-Bau wäre das ein Crash (GUI-Start-Bug
# 15.10.). Der Slider startet unten passend mit 12 m (= Mittel).
terrain_size_btn_medium.set_pressed_no_signal(true)
size_row.add_child(terrain_size_btn_small)
size_row.add_child(terrain_size_btn_medium)
size_row.add_child(terrain_size_btn_large)
terrain_radius_label = _field_label("Pinsel-Größe: 12 m")
terrain_panel.add_child(terrain_radius_label)
terrain_radius_slider = HSlider.new()
terrain_radius_slider.min_value = 2.0
terrain_radius_slider.max_value = 64.0
terrain_radius_slider.step = 1.0
terrain_radius_slider.value = 12.0
terrain_radius_slider.custom_minimum_size = Vector2(0, 32)
terrain_radius_slider.value_changed.connect(_on_terrain_radius_changed)
terrain_panel.add_child(terrain_radius_slider)
terrain_strength_label = _field_label("Stärke: 8 m pro Sekunde")
terrain_panel.add_child(terrain_strength_label)
terrain_strength_slider = HSlider.new()
terrain_strength_slider.min_value = 1.0
terrain_strength_slider.max_value = 20.0
terrain_strength_slider.step = 0.5
terrain_strength_slider.value = 8.0
terrain_strength_slider.custom_minimum_size = Vector2(0, 32)
terrain_strength_slider.value_changed.connect(_on_terrain_strength_changed)
terrain_panel.add_child(terrain_strength_slider)
terrain_panel.add_child(_muted_label(
"Linksklick-Ziehen formt bzw. malt (je nach Werkzeug), Rechtsklick-Ziehen macht das Gegenteil — im Malen-Modus malt Rechtsklick Gras zurück. "
+ "Jeder Strich ist EIN Rückgängig-Schritt (Strg+Z). ESC beendet den Terrain-Modus."))
# --- Spawn-Zonen (Karte t_c4162c5d): Live-Status IMMER sichtbar ---
vbox.add_child(_separator())
vbox.add_child(_header_label("Spawn-Check"))
spawn_status = Label.new()
spawn_status.text = ""
spawn_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
spawn_status.custom_minimum_size = Vector2(0, 44)
vbox.add_child(spawn_status)
vbox.add_child(_muted_label("Der Check sagt VOR dem Export, was der Server-Loader verlangen wird (mindestens 4 Zonen pro Team, bekannte Objekt-IDs)."))
# --- Spawn-Zonen-Werkzeug: großer Button + Bedien-Panel ---
spawn_edit_btn = Button.new()
spawn_edit_btn.text = "🚩 Spawn-Zonen malen"
spawn_edit_btn.custom_minimum_size = Vector2(0, 48)
spawn_edit_btn.toggle_mode = true
spawn_edit_btn.tooltip_text = "Startzonen für Team Blau und Rot auf den Boden malen (TDM)"
spawn_edit_btn.pressed.connect(_on_spawn_btn_pressed)
vbox.add_child(spawn_edit_btn)
spawn_panel = VBoxContainer.new()
spawn_panel.add_theme_constant_override("separation", 6)
spawn_panel.visible = false
vbox.add_child(spawn_panel)
spawn_panel.add_child(_field_label("Team:"))
var team_row := HBoxContainer.new()
team_row.add_theme_constant_override("separation", 6)
spawn_panel.add_child(team_row)
var team_group := ButtonGroup.new()
spawn_team_blue = _spawn_team_button("🔵 Team Blau", team_group, 0)
spawn_team_red = _spawn_team_button("🔴 Team Rot", team_group, 1)
spawn_team_blue.button_pressed = true
team_row.add_child(spawn_team_blue)
team_row.add_child(spawn_team_red)
spawn_radius_label = _field_label("Zonen-Radius: 6 m")
spawn_panel.add_child(spawn_radius_label)
spawn_radius_slider = HSlider.new()
spawn_radius_slider.min_value = SpawnOps.MIN_ZONE_RADIUS
spawn_radius_slider.max_value = SpawnOps.MAX_ZONE_RADIUS
spawn_radius_slider.step = 1.0
spawn_radius_slider.value = SpawnOps.DEFAULT_ZONE_RADIUS
spawn_radius_slider.custom_minimum_size = Vector2(0, 32)
spawn_radius_slider.value_changed.connect(_on_spawn_radius_changed)
spawn_panel.add_child(spawn_radius_slider)
spawn_sel_label = Label.new()
spawn_sel_label.text = "Keine Zone ausgewählt."
spawn_sel_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
spawn_panel.add_child(spawn_sel_label)
spawn_delete_btn = Button.new()
spawn_delete_btn.text = "Zone löschen (Entf)"
spawn_delete_btn.custom_minimum_size = Vector2(0, 40)
spawn_delete_btn.tooltip_text = "Die ausgewählte Spawn-Zone löschen (Strg+Z holt sie zurück)"
spawn_delete_btn.pressed.connect(_on_spawn_delete_pressed)
spawn_panel.add_child(spawn_delete_btn)
spawn_panel.add_child(_muted_label(
"Klick setzt eine Zone, Klick+Ziehen verschiebt sie. Jede Zone ist "
+ "EIN Rückgängig-Schritt (Strg+Z). ESC beendet das Spawn-Werkzeug."))
# --- Bauen (Karte t_97ea59c5): Modular-Builder-Pieces im App-Skelett ---
vbox.add_child(_separator())
build_edit_btn = Button.new()
build_edit_btn.text = "🏠 Bauen (Häuser)"
build_edit_btn.custom_minimum_size = Vector2(0, 48)
build_edit_btn.toggle_mode = true
build_edit_btn.tooltip_text = "Fundamente, Wände, Dächer, Treppen und Türen bauen — im 2-Meter-Raster, wie Bauklötze"
build_edit_btn.pressed.connect(_on_build_btn_pressed)
vbox.add_child(build_edit_btn)
build_panel = VBoxContainer.new()
build_panel.add_theme_constant_override("separation", 6)
build_panel.visible = false
vbox.add_child(build_panel)
build_panel.add_child(_field_label("Bauteil:"))
var build_grid := GridContainer.new()
build_grid.columns = 2
build_grid.add_theme_constant_override("h_separation", 6)
build_grid.add_theme_constant_override("v_separation", 6)
build_panel.add_child(build_grid)
var build_group := ButtonGroup.new()
for kind_id in BuildOps.KINDS.keys():
var kind_btn := Button.new()
kind_btn.text = str(BuildOps.KINDS[kind_id]["label"])
kind_btn.toggle_mode = true
kind_btn.button_group = build_group
kind_btn.custom_minimum_size = Vector2(148, 44)
kind_btn.clip_text = true
kind_btn.tooltip_text = "%s — %s" % [str(BuildOps.KINDS[kind_id]["label"]), str(BuildOps.KINDS[kind_id].get("info", ""))]
kind_btn.pressed.connect(_on_build_kind_pressed.bind(str(kind_id)))
build_kind_buttons[str(kind_id)] = kind_btn
build_grid.add_child(kind_btn)
(build_kind_buttons["fundament"] as Button).set_pressed_no_signal(true)
build_panel.add_child(_muted_label(
"Linksklick setzt das Bauteil aufs 2-m-Raster — auch AUF andere Teile "
+ "(Wände aufs Fundament stapeln, Dach auf die Wände). Jedes Bauteil ist "
+ "EIN Rückgängig-Schritt (Strg+Z). ESC beendet den Bauen-Modus."))
# Signale (Änderungen → Dokument + Ansicht)
prop_name.text_changed.connect(_on_prop_changed.bind("name"))
prop_author.text_changed.connect(_on_prop_changed.bind("author"))
prop_desc.text_changed.connect(_on_prop_changed.bind("description"))
prop_version.value_changed.connect(_on_version_changed)
prop_weather.item_selected.connect(_on_weather_changed)
return panel
func _sel_op_button(text: String, tip: String, action: Callable) -> Button:
var btn := Button.new()
btn.text = text
btn.custom_minimum_size = Vector2(140, 40)
btn.tooltip_text = tip
btn.pressed.connect(action)
return btn
func _header_label(text: String) -> Label:
var label := Label.new()
label.text = text
label.add_theme_font_size_override("font_size", 19)
label.add_theme_color_override("font_color", Color(0.55, 0.8, 1.0))
return label
func _field_label(text: String) -> Label:
var label := Label.new()
label.text = text
label.modulate = Color(1, 1, 1, 0.7)
return label
func _muted_label(text: String) -> Label:
var label := Label.new()
label.text = text
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.modulate = Color(1, 1, 1, 0.55)
return label
func _separator() -> HSeparator:
return HSeparator.new()
# ------------------------------------------------------------ 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
new_dialog.popup_centered()
1: # Öffnen
open_dialog.popup_centered()
2: # Speichern
_save()
3: # Speichern unter
save_dialog.popup_centered()
4: # Beenden
get_tree().quit()
5: # Karte testen
_on_test_run()
## „▶ Karte testen" (Karte t_b84af40f): aktuelle Map (auch ungespeichert!)
## nach Temp exportieren und den scopedvr-client als EIGENES Fenster starten.
## Der Editor bleibt offen; das Spiel beendet sich selbst sauber (ESC).
func _on_test_run() -> void:
if doc.is_empty():
_set_status("Erst eine Map anlegen (Datei → Neue Map), dann testen.", true)
return
var cfg := TestRun.load_cfg()
var client_dir := TestRun.find_client(ProjectSettings.globalize_path("res://"), cfg)
if client_dir.is_empty():
_show_errors("Karte testen — das Spiel wurde nicht gefunden:", [
"Der ScopedVR-Client (Ordner „scopedvr-client“) wurde nicht neben dem Editor gefunden.",
"So geht es: Den Client-Ordner neben den Editor-Ordner legen (gleicher Überordner),",
"ODER in der Datei scopedvr_editor.cfg im Ordner „ScopedVR Map Editor“ (Godot-App-Daten) eintragen:",
" [test]",
" client_dir=\"/pfad/zu/scopedvr-client\"",
" godot_path=\"/pfad/zu/godot\" (nur nötig, wenn der Editor NICHT über Godot gestartet wird)",
])
_set_status("Karte testen: Spiel nicht gefunden — siehe Fenster.", true)
return
var godot := TestRun.find_godot(cfg)
if godot.is_empty():
_show_errors("Karte testen — Godot-Programm nicht gefunden:", [
"Der Editor läuft gerade nicht über ein Godot-Programm, deshalb weiß er nicht,",
"wie er das Spiel-Fenster starten soll. Trage in scopedvr_editor.cfg ein:",
" [test]",
" godot_path=\"/pfad/zu/godot\"",
])
_set_status("Karte testen: Godot-Programm nicht gefunden — siehe Fenster.", true)
return
var exported := TestRun.export_temp_map(doc)
if not bool(exported["ok"]):
_show_errors("Karte testen — die Map ist noch nicht lauffähig:", exported["errors"])
_set_status("Testlauf nicht möglich — siehe Fenster für die fehlenden Punkte (z. B. Spawn-Zonen).", true)
return
var map_name := str(doc.get("info", {}).get("name", "?"))
var args := TestRun.launch_args(client_dir, str(exported["path"]))
var pid := OS.create_process(godot, args)
if pid <= 0:
_show_errors("Karte testen — Start fehlgeschlagen:", [
"Das Spiel-Fenster konnte nicht gestartet werden (Programm: %s)." % godot])
_set_status("Karte testen: Start fehlgeschlagen.", true)
return
_set_status("▶ Karte „%s“ läuft im Test-Fenster — WASD laufen, ESC zurück zum Editor. Der Editor bleibt offen." % map_name)
func _on_new_map_confirmed() -> void:
doc = SvmapDocument.create_new(new_dialog.get_map_name(), new_dialog.get_size_m(), new_dialog.get_weather())
current_path = ""
dirty = false
history.begin(doc)
_clear_edit_state()
_refresh_all()
_set_status("Neue Map „%s“ erstellt (%.0f × %.0f m, Wetter: %s). Wähle links ein Objekt, um zu bauen!" % [
new_dialog.get_map_name(), new_dialog.get_size_m(), new_dialog.get_size_m(), new_dialog.get_weather()])
func _on_open_selected(path: String) -> void:
var result := SvmapIO.load_from_file(path)
if not result["ok"]:
_show_errors("Map konnte NICHT geladen werden:\n(Falls sie in einem anderen Programm geändert wurde, prüfe die Datei.)", result["errors"])
_set_status("Laden fehlgeschlagen: %s — versuche eine .svmap-Datei aus dem Map-Editor oder vom Server." % path.get_file(), true)
return
doc = result["data"]
current_path = path
dirty = false
history.begin(doc)
_clear_edit_state()
_refresh_all()
var map_name := str(doc.get("info", {}).get("name", "?"))
_set_status("Map „%s“ geladen (%d Objekte). Klicke ein Objekt in der Karte an, um es anzupassen." % [map_name, SvmapDocument.object_count(doc)])
func _clear_edit_state() -> void:
if interaction != null:
interaction.call("clear_selection", true)
interaction.call("cancel_placement")
func _save() -> void:
if current_path.is_empty():
save_dialog.popup_centered()
return
_save_to(current_path)
func _on_save_selected(path: String) -> void:
if not path.get_extension().is_empty() and path.get_extension() != "svmap":
path += ".svmap"
_save_to(path)
func _save_to(path: String) -> void:
# Felder stehen im Dokument (Live-Bindung) — vor dem Speichern noch einmal
# sicherstellen, dass Name/Autor nicht leer sind (verständliche Meldung statt V4-Kauderwelsch).
var name_v := str(doc.get("info", {}).get("name", "")).strip_edges()
if name_v.is_empty():
_show_errors("Map konnte nicht gespeichert werden:", ["Die Map braucht einen Namen — trag ihn rechts unter „Map-Eigenschaften“ ein."])
return
var result := SvmapIO.save_to_file(path, doc)
if not result["ok"]:
_show_errors("Map konnte nicht gespeichert werden (ungültige Map-Daten):", result["errors"])
_set_status("Speichern fehlgeschlagen.", true)
return
current_path = path
dirty = false
_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()
# --------------------------------------------------------------- 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())
## Rechtsklick-Filter der Kamera: während des Platzierens gehört die rechte
## Maustaste dem Ghost-Abbruch (interaction) — der Flug startet dann nicht.
func _fly_rmb_blocked() -> bool:
return interaction.placing
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()
match key:
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:
_save()
get_viewport().set_input_as_handled()
KEY_G:
if ctrl:
# Gruppieren/Lösen (Karte t_f6f29d86)
if event.shift_pressed:
interaction.ungroup_selection()
else:
interaction.group_selection()
get_viewport().set_input_as_handled()
elif tool_mode == "objects" and not interaction.placing:
# G = Greifen (Karte t_55257792, Blender-Semantik): Auswahl
# hängt an der Maus, Linksklick setzt neu, Esc bricht ab.
interaction.grab_begin()
get_viewport().set_input_as_handled()
KEY_R:
if not ctrl:
# R = Vierteldrehung 90° (Karte t_55257792): Bauen-Modus dreht
# Vorschau/Auswahl (wie bisher), Objekt-Auswahl UND Ghost drehen
# jetzt auch in 90°-Schritten (Umschalt+R = zurück). Feindrehen
# 15° bleibt am Mausrad.
var r_delta := -90.0 if event.shift_pressed else 90.0
if tool_mode == "bauen":
build_tool.call("rotate_input")
get_viewport().set_input_as_handled()
return
if interaction.placing:
interaction.place_rot_deg = wrapf(interaction.place_rot_deg + r_delta, 0.0, 360.0)
else:
interaction.rotate_selected(r_delta)
get_viewport().set_input_as_handled()
KEY_F:
if not ctrl:
if event.shift_pressed:
# Umschalt+F: Freelook-Flug dauerhaft an/aus (Karte t_c46d31c2).
camera.call("toggle_fly_lock")
get_viewport().set_input_as_handled()
else:
# F: Auswahl ins Bild zentrieren.
_focus_selection()
get_viewport().set_input_as_handled()
KEY_ESCAPE:
# Esc-Priorität über pure Funktion (Karte t_c46d31c2, testbar):
# Werkzeug verlassen → Greifen zurückrollen → Ghost-Preview
# abbrechen → Auswahl aufheben (Greifen: Karte t_55257792).
var esc := ShortcutPolicy.esc_action(tool_mode,
interaction.placing,
interaction.selected_index != -1 or not interaction.selected_indices.is_empty(),
bool(interaction.get("_grabbing")))
match esc:
"exit_terrain":
_set_tool_mode("objects")
_set_status("Terrain-Modus beendet — zurück zum Objekt-Werkzeug.")
"exit_spawns":
_set_tool_mode("objects")
_set_status("Spawn-Werkzeug beendet — zurück zum Objekt-Werkzeug.")
"exit_bauen":
_set_tool_mode("objects")
_set_status("Bauen-Modus beendet — zurück zum Objekt-Werkzeug.")
"cancel_grab":
interaction.grab_cancel()
"cancel_placement":
interaction.cancel_placement()
_set_status("Platzieren abgebrochen.")
"clear_selection":
interaction.clear_selection()
_set_status("Auswahl aufgehoben.")
if esc != "none":
get_viewport().set_input_as_handled()
KEY_DELETE:
if not ctrl:
if interaction.placing:
return
if tool_mode == "spawns":
spawn_tool.call("delete_selected")
elif tool_mode == "bauen":
build_tool.call("delete_selected")
else:
interaction.delete_selected()
get_viewport().set_input_as_handled()
KEY_D:
if ctrl and tool_mode == "objects":
interaction.duplicate_selected()
get_viewport().set_input_as_handled()
KEY_PAGEUP:
if interaction.placing:
return
if ctrl:
# Strg+Bild↑ (Karte t_55257792): Auswahl/Bauteil auf den Boden.
if tool_mode == "bauen":
build_tool.call("snap_selected_to_floor")
get_viewport().set_input_as_handled()
elif tool_mode == "objects" and interaction.selected_index >= 0:
interaction.snap_selection_to_floor()
get_viewport().set_input_as_handled()
elif not ctrl:
if tool_mode == "bauen":
build_tool.call("lift_selected", ObjectOps.LIFT_STEP_M)
get_viewport().set_input_as_handled()
elif tool_mode == "objects" and interaction.selected_index >= 0:
interaction.lift_selected(ObjectOps.LIFT_STEP_M)
get_viewport().set_input_as_handled()
KEY_PAGEDOWN:
if not ctrl and not interaction.placing:
if tool_mode == "bauen":
build_tool.call("lift_selected", -ObjectOps.LIFT_STEP_M)
get_viewport().set_input_as_handled()
elif tool_mode == "objects" and interaction.selected_index >= 0:
interaction.lift_selected(-ObjectOps.LIFT_STEP_M)
get_viewport().set_input_as_handled()
KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT:
# Pfeiltasten (Karte t_55257792): Auswahl im 2-m-Raster verschieben,
# Richtung = Kamera-Ausrichtung nächste Weltachse (MoveMath). Eine
# Zelle pro Tastendruck; beim Platzieren bleibt der Ghost herrschend.
if interaction.placing:
return
var fwd := 0
var side := 0
match key:
KEY_UP:
fwd = 1
KEY_DOWN:
fwd = -1
KEY_RIGHT:
side = 1
KEY_LEFT:
side = -1
var delta := MoveMath.nudge_delta(
float(camera.call("view_yaw_deg")), fwd, side)
if tool_mode == "bauen":
build_tool.call("nudge_selected", delta)
get_viewport().set_input_as_handled()
elif tool_mode == "objects" and interaction.selected_index >= 0:
interaction.nudge_selection(delta)
get_viewport().set_input_as_handled()
KEY_BRACKETLEFT, KEY_BRACKETRIGHT:
# Terrain-Pinsel [ / ] (Karte t_55257792): Größe durch die 3 Presets
# zyklisch — nur im Terrain-Werkzeug (Ziffern-Freiheit, Punkt 6).
if tool_mode == "terrain":
_terrain_brush_size_step(key == KEY_BRACKETRIGHT)
get_viewport().set_input_as_handled()
KEY_1, KEY_2, KEY_3, KEY_4:
# Terrain-Pinsel-Modi 1–4 (Karte t_55257792): Heben/Senken/Glätten/
# Malen — NUR im Terrain-Werkzeug (Konfliktfreiheit, Punkt 6; im
# Objekt-/Bauen-Modus bleiben die Ziffern unbenutzt).
if tool_mode == "terrain":
_terrain_brush_mode_key(int(key) - int(KEY_1))
get_viewport().set_input_as_handled()
# --------------------------------------------------------- Bibliothek ------
func _on_library_pressed(oid: String) -> void:
if tool_mode != "objects":
_set_tool_mode("objects") # Bibliothek-Klick wechselt automatisch zurück
if interaction.placing and interaction.place_oid == oid:
# Nochmal geklickt → Modus beenden (An/Aus ohne Umwege).
interaction.cancel_placement()
_set_status("Platzieren abgebrochen.")
return
for other_oid in library_buttons:
(library_buttons[other_oid] as Button).button_pressed = false
library_buttons[oid].button_pressed = true
interaction.start_placement(oid)
## Der Bibliothek-Button des aktiven Platzier-Objekts leuchtet auf.
func _highlight_library(oid: String) -> void:
for other_oid in library_buttons:
var btn := library_buttons[other_oid] as Button
btn.button_pressed = other_oid == oid
btn.modulate = Color(1, 1, 1) if oid == other_oid else Color(1, 1, 1, 0.85)
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)
func _terrain_mode_button(text: String, group: ButtonGroup) -> Button:
var btn := Button.new()
btn.text = text
btn.toggle_mode = true
btn.button_group = group
btn.custom_minimum_size = Vector2(0, 40)
return btn
func _on_terrain_btn_pressed() -> void:
if terrain_edit_btn.button_pressed:
_set_tool_mode("terrain")
else:
_set_tool_mode("objects")
## Werkzeug-Modus wechseln: "objects" (Platzieren/Auswahl), "terrain" (Pinsel),
## "spawns" (Spawn-Zonen malen).
func _set_tool_mode(mode: String) -> void:
if mode == tool_mode:
_sync_terrain_button()
_sync_spawn_button()
_sync_build_button()
return
if mode == "terrain":
_leave_spawn_mode()
_leave_build_mode()
_enter_terrain_mode()
elif mode == "spawns":
_leave_terrain_mode()
_leave_build_mode()
_enter_spawn_mode()
elif mode == "bauen":
_leave_terrain_mode()
_leave_spawn_mode()
_enter_build_mode()
else:
_leave_terrain_mode()
_leave_spawn_mode()
_leave_build_mode()
_enter_objects_mode()
func _enter_terrain_mode() -> void:
interaction.call("cancel_placement")
interaction.call("clear_selection", true)
if not terrain_tool.call("activate"):
# Abgelehnt (z. B. Auflösungs-Limit) — Button zurück, Meldung steht.
_sync_terrain_button()
return
tool_mode = "terrain"
terrain_panel.visible = true
help_label.text = "Linksklick-Ziehen: Boden formen oder malen · Rechtsklick-Ziehen: Gegenteil / Gras zurück ·\n1/2/3/4: Heben/Senken/Glätten/Malen · [ / ]: Pinselgröße Klein/Mittel/Groß ·\nPinselgröße & Material rechts · Mittlere Maustaste: Kamera · Strg+Z: letzter Strich weg"
terrain_edit_btn.button_pressed = true
terrain_tool.call("set_brush_mode", _selected_terrain_mode())
_set_status("Terrain-Modus an: LINKSKLICK-Ziehen formt den Boden, im Malen-Modus färbt er das Gelände (Gras/Sand/Fels). Regler rechts stellen Pinsel und Stärke ein.")
func _leave_terrain_mode() -> void:
if terrain_tool != null:
terrain_tool.call("deactivate")
if terrain_panel != null:
terrain_panel.visible = false
_sync_terrain_button()
func _enter_spawn_mode() -> void:
interaction.call("cancel_placement")
interaction.call("clear_selection", true)
tool_mode = "spawns"
spawn_panel.visible = true
help_label.text = "Linksklick: Zone setzen · Klick+Ziehen: Zone verschieben · Entf: löschen ·\nTeam/Radius rechts · Mittlere Maustaste: Kamera · Strg+Z: zurück"
spawn_edit_btn.button_pressed = true
spawn_tool.call("activate")
spawn_tool.call("set_team", _selected_spawn_team())
spawn_tool.call("set_radius", float(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(_selected_spawn_team()))
func _leave_spawn_mode() -> void:
if spawn_tool != null:
spawn_tool.call("deactivate")
if spawn_panel != null:
spawn_panel.visible = false
_sync_spawn_button()
func _enter_objects_mode() -> void:
tool_mode = "objects"
help_label.text = "Linksklick: auswählen & ziehen · Umschalt+Klick: Auswahl erweitern · Rahmen aufziehen: mehrere wählen · Strg+G: gruppieren\nR: 90° drehen (Mausrad: 15° fein) · Pfeiltasten: 2-m-Raster · G: greifen (Esc: zurück) · Strg+D: duplizieren · Bild↑/↓: anheben/absenken\nStrg+Bild↑: auf Boden setzen · Strg halten: Snap-Zwang · Spiegeln rechts · Entf: löschen · Strg+Z: zurück"
_base_tool_status()
# ------------------------------------------------------ Bauen-Werkzeug ------
# (Karte t_97ea59c5 — Modular-Builder-Pieces direkt im App-Skelett; die
# Godot-Editor-UI des Plugins bleibt unangetastet im Addon aktivierbar.)
func _on_build_btn_pressed() -> void:
if build_edit_btn.button_pressed:
_set_tool_mode("bauen")
else:
_set_tool_mode("objects")
func _on_build_kind_pressed(kind_id: String) -> void:
if tool_mode != "bauen":
_set_tool_mode("bauen")
for other in build_kind_buttons:
(build_kind_buttons[other] as Button).button_pressed = false
(build_kind_buttons[kind_id] as Button).button_pressed = true
build_tool.call("set_kind", kind_id)
func _enter_build_mode() -> void:
interaction.call("cancel_placement")
interaction.call("clear_selection", true)
tool_mode = "bauen"
build_panel.visible = true
help_label.text = "Linksklick: Bauteil setzen (auch auf andere Teile = stapeln) · Umschalt+Klick: Bauteil auswählen ·\nR: drehen (90°) · Pfeiltasten: im 2-m-Raster verschieben · Bild↑/↓: anheben/absenken · Strg+Bild↑: auf Boden · Entf: löschen · Mittlere Maustaste: Kamera · Strg+Z: zurück"
build_edit_btn.button_pressed = true
build_tool.call("activate")
_set_status("Bauen-Modus an: Bauteil rechts anklicken, dann in die Karte klicken — alles rastet aufs 2-m-Raster.")
func _leave_build_mode() -> void:
if build_tool != null:
build_tool.call("deactivate")
if build_panel != null:
build_panel.visible = false
_sync_build_button()
func _sync_build_button() -> void:
build_edit_btn.button_pressed = tool_mode == "bauen"
func _selected_terrain_mode() -> int:
if terrain_mode_lower.button_pressed:
return TerrainField.Mode.LOWER
if terrain_mode_smooth.button_pressed:
return TerrainField.Mode.SMOOTH
if terrain_mode_paint.button_pressed:
return TerrainField.Mode.PAINT
return TerrainField.Mode.RAISE
func _sync_terrain_button() -> void:
terrain_edit_btn.button_pressed = tool_mode == "terrain"
## Mode-Buttons (ButtonGroup) → Pinsel-Modus; Palette nur im Malen-Modus.
func _on_terrain_mode_changed(_pressed: bool) -> void:
if terrain_palette != null:
terrain_palette.visible = _selected_terrain_mode() == TerrainField.Mode.PAINT
if terrain_tool != null:
terrain_tool.call("set_brush_mode", _selected_terrain_mode())
## Material-Palette (Karte t_603ec1a8): gewähltes Material an den Pinsel.
func _on_terrain_material_changed(pressed: bool, mat_id: int) -> void:
if pressed and terrain_tool != null:
terrain_tool.call("set_paint_material", mat_id)
## Pinselgrößen-Presets (Karte t_603ec1a8): setzen den Radius-Slider
## (der Label-Text + Ring-Optik hängen am value_changed-Signal).
func _terrain_size_button(text: String, group: ButtonGroup, radius_m: float) -> Button:
var btn := Button.new()
btn.text = text
btn.toggle_mode = true
btn.button_group = group
btn.custom_minimum_size = Vector2(0, 48)
btn.tooltip_text = "Pinselradius %d m" % int(radius_m)
btn.toggled.connect(_on_terrain_size_preset.bind(radius_m))
return btn
func _on_terrain_size_preset(pressed: bool, radius_m: float) -> void:
# Null-Check als Schutznetz: beim Panel-Bau existiert der Slider noch
# nicht (Programmatic-Pressed darf den Handler nie früh treffen).
if pressed and terrain_radius_slider != null:
terrain_radius_slider.value = radius_m
## Terrain-Pinsel-Größe per [ / ] (Karte t_55257792): durch die 3 Presets
## Klein/Mittel/Groß zyklisch — der Preset-Button wird mit gesetzt (set_pressed
## feuert toggled → Slider + Label + Pinssel-Ring wandern mit).
func _terrain_brush_size_step(up: bool) -> void:
if terrain_radius_slider == null:
return
var next := MoveMath.brush_next_preset(float(terrain_radius_slider.value), up)
var btn: Button = null
match next:
4.0:
btn = terrain_size_btn_small
12.0:
btn = terrain_size_btn_medium
32.0:
btn = terrain_size_btn_large
if btn != null:
btn.set_pressed(true) # feuert _on_terrain_size_preset → Slider/Label
else:
terrain_radius_slider.value = next
terrain_tool.call("set_radius", next)
_set_status("Pinsel-Größe: %d m (mit [ / ] durch Klein/Mittel/Groß zyklisch)." % int(next))
## Terrain-Pinsel-Modus per Ziffer 1–4 (Karte t_55257792): Heben/Senken/
## Glätten/Malen — der Modus-Button wird mit gesetzt (toggled feuert die
## bestehende Sync-Kette inkl. Material-Palette im Malen-Modus). Malen startet
## mit der zuletzt gewählten Materialfarbe (paint_material bleibt stehen).
func _terrain_brush_mode_key(mode_idx: int) -> void:
var btn: Button = null
match mode_idx:
0:
btn = terrain_mode_raise
1:
btn = terrain_mode_lower
2:
btn = terrain_mode_smooth
3:
btn = terrain_mode_paint
if btn == null or terrain_tool == null:
return
btn.set_pressed(true)
var labels := ["Heben", "Senken", "Glätten", "Malen"]
var extra := ""
if mode_idx == 3:
var mats: Array = SvmapIO.MATERIAL_IDS
extra = " (Material: %s — mit der Palette rechts wechseln)" % str(mats[clampi(int(terrain_tool.get("paint_material")), 0, mats.size() - 1)])
_set_status("Pinsel-Modus: %s%s — Linksklick-Ziehen formt/malt jetzt so (Ziffern 1–4 wechseln)." % [str(labels[mode_idx]), extra])
func _on_terrain_radius_changed(value: float) -> void:
terrain_radius_label.text = "Pinsel-Größe: %d m" % int(value)
if terrain_tool != null:
terrain_tool.call("set_radius", value)
func _on_terrain_strength_changed(value: float) -> void:
terrain_strength_label.text = "Stärke: %.1f m pro Sekunde" % value
if terrain_tool != null:
terrain_tool.call("set_strength", value)
## Ein Pinsel-Strich ist fertig (Doc + History schon aktualisiert vom Tool).
func _on_terrain_stroke_committed() -> void:
dirty = true
_refresh_file_label()
_refresh_stats()
_refresh_history_buttons()
view_builder.call("rebuild_terrain_from_doc", doc)
var desc: String = history.undo_desc()
_set_status("%s — Strg+Z macht den ganzen Strich rückgängig." % desc)
## Terrain-Modus nach Laden/Neu/Undo mit dem Doc synchron halten (Cache frisch,
## Auflösungs-Limit prüfen — sonst dezenter Rückschalter).
func _recheck_terrain_mode() -> void:
if tool_mode != "terrain" or terrain_tool == null:
return
if not terrain_tool.call("refresh_from_doc"):
terrain_tool.call("deactivate")
tool_mode = "objects"
terrain_panel.visible = false
_sync_terrain_button()
help_label.text = "Linksklick: auswählen & ziehen · Umschalt+Klick: Auswahl erweitern · Rahmen aufziehen: mehrere wählen · Strg+G: gruppieren\nR: 90° drehen (Mausrad: 15° fein) · Pfeiltasten: 2-m-Raster · G: greifen (Esc: zurück) · Strg+D: duplizieren · Bild↑/↓: anheben/absenken\nStrg+Bild↑: auf Boden setzen · Strg halten: Snap-Zwang · Spiegeln rechts · Entf: löschen · Strg+Z: zurück"
_set_status("Terrain-Modus aus: Diese Map passt nicht ins Editor-MVP (Terrain-Auflösung über %d) — die Objekte bleiben normal bearbeitbar." % TerrainField.EDITOR_MAX_RESOLUTION, true)
# ---------------------------------------------------- Spawn-Zonen-Werkzeug ---
# (Karte t_c4162c5d — Spawn-Zonen malen, Live-Check, Map-Fertig-Check)
func _spawn_team_button(text: String, group: ButtonGroup, team: int) -> Button:
var btn := Button.new()
btn.text = text
btn.toggle_mode = true
btn.button_group = group
btn.custom_minimum_size = Vector2(0, 40)
btn.pressed.connect(_on_spawn_team_changed.bind(team))
return btn
func _on_spawn_btn_pressed() -> void:
if spawn_edit_btn.button_pressed:
_set_tool_mode("spawns")
else:
_set_tool_mode("objects")
func _selected_spawn_team() -> int:
return 1 if spawn_team_red.button_pressed else 0
func _sync_spawn_button() -> void:
spawn_edit_btn.button_pressed = tool_mode == "spawns"
func _on_spawn_team_changed(team: int) -> void:
if spawn_tool != null and tool_mode == "spawns":
spawn_tool.call("set_team", team)
func _on_spawn_radius_changed(value: float) -> void:
spawn_radius_label.text = "Zonen-Radius: %d m" % int(value)
if spawn_tool != null and tool_mode == "spawns":
spawn_tool.call("set_radius", value)
func _on_spawn_delete_pressed() -> void:
if tool_mode == "spawns":
spawn_tool.call("delete_selected")
## Panel-Zeile „Welche Zone ist angewählt?“ — WIRD vom Spawn-Tool gerufen.
func _sync_spawn_selection() -> void:
if spawn_tool == null:
return
var team: int = spawn_tool.get("selected_team")
var pid: String = str(spawn_tool.get("selected_id"))
if team < 0 or pid == "":
spawn_sel_label.text = "Keine Zone ausgewählt.\nKlicke im Viewport auf einen Kreis."
spawn_delete_btn.disabled = true
return
spawn_sel_label.text = "Ausgewählt: %s (%s)\nVerschieben: klicken+ziehen · Radius: Regler oben" % [
pid, SpawnOps.team_label(team)]
spawn_delete_btn.disabled = false
## LIVE-Validierung (Loader-Regeln) — immer sichtbar, nach JEDEM Struktur-Wechsel.
func _refresh_spawn_status() -> void:
if doc.is_empty() or spawn_status == null:
return
spawn_status.text = SpawnOps.live_status(doc, manifest)
## 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(check_btn)
_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)
## Der Baum ruft zurück, wenn der Nutzer dort ein Objekt auswählt.
## SELECT_MULTI (Karte t_c46d31c2): einfacher Klick = Ersatz-Auswahl,
## Strg+Klick addiert/entfernt nativ — ALLE markierten Objekt-Zeilen spiegeln
## sich in die Viewport-Auswahl (Baum und Szene sind immer synchron).
func _on_tree_selected() -> void:
if _tree_select_lock:
return
var item := content_tree.get_selected()
if item == null:
return
if item.has_meta("gebaeude_index"):
_on_tree_selected_gebaeude(int(item.get_meta("gebaeude_index")))
return
var indices: Array = []
var walk := content_tree.get_next_selected(null)
while walk != null:
if walk.has_meta("obj_index"):
indices.append(int(walk.get_meta("obj_index")))
walk = content_tree.get_next_selected(walk)
if indices.is_empty():
return
var objects: Array = doc.get("objects", [])
var valid: Array = []
for idx in indices:
if idx >= 0 and idx < objects.size():
valid.append(idx)
if valid.is_empty():
return
if valid.size() == 1:
interaction.select_object(valid[0], true)
_set_status("%s ausgewählt — ziehen zum Verschieben, R/Mausrad zum Drehen, Entf zum Löschen. Strg+Klick markiert weitere dazu." % object_label(str(objects[valid[0]].get("id", ""))))
else:
interaction.select_many(valid)
_set_status("%d Objekte im Baum markiert — Entf löscht ALLE, Strg+D dupliziert ALLE (jeweils EIN Undo-Schritt)." % valid.size())
## Baum: Bauteil-Klick → Bauen-Modus + Teil anwählen (Karte t_97ea59c5).
func _on_tree_selected_gebaeude(gi: int) -> void:
if tool_mode != "bauen":
_set_tool_mode("bauen")
build_tool.call("select_index", gi)
## Auswahl (aus dem Viewport) im Baum mitmarkieren — MEHRFACH-fähig
## (Karte t_c46d31c2): ALLE gewählten Objekt-Zeilen werden selektiert, Zeilen,
## die nicht mehr gewählt sind, werden abgewählt. Primär-Objekt bleibt sichtbar.
func _mark_tree_selection(idx: int) -> void:
_mark_tree_selection_multi([idx])
func _mark_tree_selection_multi(indices: Array) -> void:
_tree_select_lock = true
var wanted := {}
for i in indices:
wanted[int(i)] = true
_mark_tree_selection_walk(content_tree.get_root(), wanted)
var primary: Variant = interaction.get("selected_index") if interaction != null else -1
if primary is int and primary >= 0:
_ensure_tree_visible(content_tree.get_root(), primary)
_tree_select_lock = false
func _mark_tree_selection_walk(item: TreeItem, wanted: Dictionary) -> void:
if item == null:
return
if item.has_meta("obj_index"):
var idx := int(item.get_meta("obj_index"))
if wanted.has(idx):
item.select(0)
elif item.is_selected(0):
item.deselect(0)
for child in item.get_children():
_mark_tree_selection_walk(child, wanted)
func _ensure_tree_visible(item: TreeItem, idx: int) -> void:
if item == null:
return
if item.has_meta("obj_index") and int(item.get_meta("obj_index")) == idx:
content_tree.ensure_visible(item)
return
for child in item.get_children():
_ensure_tree_visible(child, idx)
## Rechte Panel-Sektion synchronisieren OHNE Statuszeilen-Text zu ändern
## (sonst überschreibt ein Stummsync die gerade gesetzte Hilfs-Meldung).
## Mehrfachauswahl/Gruppen-fähig (Karte t_f6f29d86): Panel listet ALLE
## gewählten Objekte auf + zeigt die Gruppen-Bindung an.
func _sync_selection_panel() -> void:
var objects: Array = doc.get("objects", [])
var sel: Array = interaction.selected_indices
var valid: Array = []
for i in sel:
if i is int and i >= 0 and i < objects.size():
valid.append(i)
var has_sel := not valid.is_empty()
for btn in [sel_rotate_btn, sel_delete_btn, sel_dup_btn, sel_mirror_x_btn,
sel_mirror_z_btn, sel_raise_btn, sel_lower_btn]:
btn.disabled = not has_sel
var has_group := false
for i in valid:
if GroupOps.group_id(objects[i]) != GroupOps.UNGROUPED:
has_group = true
break
sel_group_btn.disabled = valid.size() < 2
sel_ungroup_btn.disabled = not has_group
if not has_sel:
sel_label.text = "Nichts ausgewählt.\nKlicke im Viewport ein Objekt an."
return
if valid.size() == 1:
var idx: int = valid[0]
var obj: Dictionary = objects[idx]
var pos: Array = obj.get("pos", [0.0, 0.0, 0.0])
var lift := ObjectOps.lift_offset(doc, idx)
var lift_text := "am Boden" if absf(lift) < 0.02 else ("%+.2f m über dem Boden" % lift)
var group_line := ""
var gid := GroupOps.group_id(obj)
if gid != GroupOps.UNGROUPED:
group_line = "\nGruppe %d — zieht & dreht GEMEINSAM (Lösen: Button unten)" % (gid + 1)
sel_label.text = "%s (Objekt %d von %d)\nPosition: %.0f, %.0f m · Höhe: %s · Richtung: %.0f°%s\nZiehen: verschieben · R oder Mausrad: drehen (15°)\nBild↑/↓: anheben/absenken · Strg+D: duplizieren\nSpiegeln/Löschen: Buttons oben · Strg+Z: zurück" % [
object_label(str(obj.get("id", ""))), idx + 1, objects.size(),
float(pos[0]), float(pos[2]), lift_text, float(obj.get("rot_y_deg", 0.0)), group_line]
return
# Mehrfachauswahl: Liste aller gewählten Objekte (max. 8 Zeilen, dann „…“).
var names := PackedStringArray()
for i in valid:
if names.size() < 8:
names.append(object_label(str((objects[i] as Dictionary).get("id", "?"))))
var more := ""
if valid.size() > 8:
more = "\n… und %d weitere" % (valid.size() - 8)
sel_label.text = "%d Objekte ausgewählt:\n%s%s\nGemeinsam: ziehen (Gruppe wandert zusammen), drehen um die Mitte, Entf löscht alle." % [
valid.size(), " · ".join(names), more]
## Hilfs-Meldung zur frischen Auswahl (Klartext + Budget-Warnung + nächster Schritt).
func _selection_status(idx: int) -> void:
var objects: Array = doc.get("objects", [])
var sel: Array = interaction.selected_indices
if (idx < 0 or idx >= objects.size()) and sel.is_empty():
_base_tool_status()
return
var used := int(SvmapDocument.poly_usage(doc, manifest))
var budget := int(SvmapIO.MAX_TOTAL_POLY)
var budget_warning := ""
if used > budget * 80 / 100:
budget_warning = " ⚠ Poly-Budget zu %.0f %% voll — bald passt kein Objekt mehr rein." % (100.0 * used / budget)
if sel.size() > 1:
_set_status("%d Objekte ausgewählt — ziehen bewegt ALLE gemeinsam, R/Mausrad dreht sie um die Mitte, Entf löscht alle, Strg+G gruppiert.%s" % [sel.size(), budget_warning])
return
if idx < 0 or idx >= objects.size():
_base_tool_status()
return
var obj: Dictionary = objects[idx]
var gid := GroupOps.group_id(obj)
var group_hint := ""
if gid != GroupOps.UNGROUPED:
group_hint = " (Gruppe %d — die zieht GEMEINSAM)" % (gid + 1)
_set_status("%s%s ausgewählt — ziehen zum Verschieben, R oder Mausrad zum Drehen, Bild↑/↓ anheben/absenken, Strg+D duplizieren, Entf zum Löschen.%s" % [
object_label(str(obj.get("id", ""))), group_hint, budget_warning])
# ------------------------------------------------------- Eigenschaften -----
func _on_prop_changed(text: String, field: String) -> void:
if _loading_ui_values or doc.is_empty():
return
var info: Dictionary = doc["info"]
match field:
"name":
info["name"] = text
"author":
info["author"] = text
"description":
info["description"] = text
dirty = true
_refresh_file_label()
func _on_version_changed(value: float) -> void:
if _loading_ui_values or doc.is_empty():
return
doc["info"]["version"] = int(value)
dirty = true
_refresh_file_label()
func _on_weather_changed(index: int) -> void:
if _loading_ui_values or doc.is_empty():
return
var weathers := ["mittag", "abend", "regen", "nacht"]
doc["weather"] = weathers[index]
dirty = true
_refresh_file_label()
# Licht/Stimmung im Viewport sofort anpassen
view_builder.call("rebuild", doc, manifest)
_sync_grid_to_builder()
_set_status("Wetter geändert auf „%s“." % weathers[index])
# ------------------------------------------------------- Struktur-Änderung --
## Nach undo/redo/platzieren/löschen: Struktur hat sich geändert → Ansicht neu.
func _after_structure_change() -> void:
dirty = true
_refresh_file_label()
_refresh_stats()
_refresh_history_buttons()
_refresh_spawn_status()
if interaction != null:
interaction.call("clear_selection", true)
view_builder.call("rebuild", doc, manifest)
_sync_grid_to_builder()
_refresh_tree()
_recheck_terrain_mode()
if interaction != null:
interaction.call("refresh_terrain_cache") # t_4092e3a0: Objekt-Cursor trifft das Gelände
if tool_mode == "spawns" and spawn_tool != null:
spawn_tool.call("refresh_from_doc")
if tool_mode == "bauen" and build_tool != null:
build_tool.call("refresh_from_doc")
# --------------------------------------------------------------- Anzeige ---
func _refresh_all() -> void:
_loading_ui_values = true
var info: Dictionary = doc.get("info", {})
prop_name.text = str(info.get("name", ""))
prop_author.text = str(info.get("author", ""))
prop_desc.text = str(info.get("description", ""))
prop_version.value = float(info.get("version", 1))
var weathers := ["mittag", "abend", "regen", "nacht"]
var weather := str(doc.get("weather", "mittag"))
prop_weather.select(maxi(0, weathers.find(weather)))
var terrain: Dictionary = doc.get("terrain", {})
terrain_info.text = "Größe: %.0f × %.0f m\nAuflösung: %d × %d Höhenpunkte\nHöhenbereich: %.0f bis %.0f m" % [
float(terrain.get("size_x_m", 0.0)), float(terrain.get("size_z_m", 0.0)),
int(terrain.get("resolution", 0)),
int(terrain.get("resolution", 0)),
float(terrain.get("min_height_m", 0.0)), float(terrain.get("max_height_m", 0.0))]
_loading_ui_values = false
_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()
camera.call("frame_map", float(doc.get("terrain", {}).get("size_x_m", 1024.0)))
# --- 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)
func _refresh_tree() -> void:
content_tree.clear()
var root := content_tree.create_item()
var objects: Array = doc.get("objects", [])
var obj_item := content_tree.create_item(root)
obj_item.set_text(0, "Objekte (%d)" % objects.size())
for i in objects.size():
var obj = objects[i]
var item := content_tree.create_item(obj_item)
if obj is Dictionary:
item.set_text(0, "%s #%d" % [object_label(str(obj.get("id", "?"))), i + 1])
item.set_meta("obj_index", i)
else:
item.set_text(0, "ungültig #%d" % (i + 1))
var zones: Dictionary = doc.get("spawn_zones", {})
var spawn_item := content_tree.create_item(root)
spawn_item.set_text(0, "Spawn-Zonen")
for team_key in ["0", "1"]:
if not zones.has(team_key):
continue
var zone: Dictionary = zones[team_key]
var team_item := content_tree.create_item(spawn_item)
var team_name := "Team Blau" if team_key == "0" else "Team Rot"
team_item.set_text(0, "%s (%d Punkte)" % [team_name, (zone.get("points", []) as Array).size()])
for p in (zone.get("points", []) as Array):
if p is Dictionary:
var point_item := content_tree.create_item(team_item)
point_item.set_text(0, "%s (%.0f, %.0f)" % [
str(p.get("id", "?")), float(p.get("pos", [0, 0, 0])[0]), float(p.get("pos", [0, 0, 0])[2])])
# Gebäude (Karte t_97ea59c5): Bauteile im Baum — Klick wählt das Teil an.
var geb_list: Array = BuildOps.pieces(doc)
if not geb_list.is_empty():
var geb_item := content_tree.create_item(root)
geb_item.set_text(0, "Gebäude (%d Bauteile)" % geb_list.size())
for i in geb_list.size():
var teil: Dictionary = geb_list[i]
var g_child := content_tree.create_item(geb_item)
g_child.set_text(0, "%s #%d" % [BuildOps.kind_label(str(teil.get("id", "?"))), i + 1])
g_child.set_meta("gebaeude_index", i)
func _refresh_stats() -> void:
var count := SvmapDocument.object_count(doc)
var poly := SvmapDocument.poly_usage(doc, manifest)
var budget := int(SvmapIO.MAX_TOTAL_POLY)
stats_objects.text = "Objekte: %d / 5000 · Poly-Budget: %s / %s (%.0f %%) · Bauteile: %d" % [
count, _fmt_int(int(poly)), _fmt_int(budget), 100.0 * poly / budget,
BuildOps.piece_count(doc)]
# Terrain-Anteil am Quest-3-Budget (res 513 = 524.288 Dreiecke, map_format.md §4)
var tris := 0
var res := 0
if view_builder != null and view_builder.get("terrain_view") != null:
tris = int(view_builder.terrain_view.call("triangle_count"))
res = int(view_builder.terrain_view.call("resolution"))
var quest_budget := 524288
var pct := 100.0 * float(tris) / float(quest_budget)
var fps := Engine.get_frames_per_second()
stats_terrain.text = " · Terrain: %s² Grid, %s Dreiecke (%.0f %% Quest-Budget) · %d FPS" % [
str(res) if res > 0 else "—", _fmt_int(tris), pct, fps]
func _refresh_file_label() -> void:
var map_name := str(doc.get("info", {}).get("name", "Ohne Namen"))
if current_path.is_empty():
file_label.text = "„%s“ (noch nicht gespeichert)%s" % [map_name, " •" if dirty else ""]
else:
file_label.text = "„%s“ — %s%s" % [map_name, current_path.get_file(), " •" if dirty else ""]
get_window().title = "%s — %s" % [APP_TITLE, map_name]
func _set_status(text: String, is_error := false) -> void:
status_label.text = text
status_label.add_theme_color_override("font_color",
Color(1.0, 0.6, 0.5) if is_error else Color(0.75, 0.95, 0.75))
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
# ---------------------------------------------------- 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 not args.has("--ghost-demo"):
return
var oid := "crate_wood"
var x := 0.0
var z := 0.0
for i in args.size():
if args[i] == "--ghost-demo":
if i + 1 < args.size():
x = float(args[i + 1])
if i + 2 < args.size():
z = float(args[i + 2])
if i + 3 < args.size():
oid = args[i + 3]
var pos := Vector3(x, SvmapDocument.terrain_height_at(doc, x, z), z)
interaction.demo_park_ghost(pos, oid)
# Demo-Aufbau: zwei Objekte setzen und eins orange auswählen — zeigt
# Ghost-Preview UND Auswahl-Markierung in einem Smoke-Screenshot.
if args.has("--ghost-demo-select"):
var before: Dictionary = history.snapshot(["objects"])
(doc["objects"] as Array).append({"id": "watchtower_wood", "pos": [6.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0})
(doc["objects"] as Array).append({"id": "crate_wood", "pos": [0.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0})
history.commit("Demo: Objekte gesetzt", before, history.snapshot(["objects"]))
interaction.call("clear_selection", true)
_refresh_all()
interaction.select_object(1, true)
else:
_refresh_history_buttons()
# Kamera nah heran, damit die Vorschau im Screenshot gut sichtbar ist.
camera.target = pos + Vector3(0.0, 0.6, 0.0)
camera.distance = 14.0
camera.pitch_deg = 32.0
camera.yaw_deg = 40.0
camera.call("_apply")
# ------------------------------------------------ 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_radius_slider.value = 45.0
terrain_strength_slider.value = 10.0
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_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 (Rechtsklick-Ziehen senkt, ohne umzuschalten).")
await _demo_shot(out_dir + "/terrain_lower.png")
# 3) Hügelkuppe glätten (blauer Ring).
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_mode_raise.button_pressed = true
terrain_radius_slider.value = 30.0
terrain_tool.call("demo_stroke", 0.0, 150.0, TerrainField.Mode.RAISE, 0.9)
terrain_tool.call("demo_park_brush", 0.0, 150.0)
_demo_camera(Vector3(0.0, 8.0, 150.0), 120.0, 35.0, 0.0)
_set_status("Demo 4/5: Test-Hügel für den Undo-Vergleich geformt.")
await _demo_shot(out_dir + "/terrain_before_undo.png")
_do_undo()
_set_status("Demo 5/5: Strg+Z — der GANZE Strich ist zurück, der Boden ist wieder flach.")
await _demo_shot(out_dir + "/terrain_after_undo.png")
print("TERRAIN_DEMO_OK: 5 Screenshots in %s" % out_dir)
func _demo_camera(target: Vector3, dist: float, pitch: float, yaw: float) -> void:
camera.target = target
camera.distance = dist
camera.pitch_deg = pitch
camera.yaw_deg = yaw
camera.call("_apply")
func _demo_shot(path: String) -> void:
for i in 5:
await get_tree().process_frame
await RenderingServer.frame_post_draw
var img := get_viewport().get_texture().get_image()
img.save_png(path)
print("DEMO_SHOT: %s (%d×%d)" % [path, img.get_width(), img.get_height()])
var tv = view_builder.get("terrain_view")
if tv != null and is_instance_valid(tv):
var mi = tv.get("_mi")
if mi != null and is_instance_valid(mi) and mi.mesh != null:
var aabb: AABB = mi.mesh.get_aabb()
print("DEMO_AABB: pos=%s size=%s tris=%d" % [aabb.position, aabb.size, tv.triangle_count()])
print("DEMO_CAM: cam=%s target=%s" % [camera.global_position, camera.target])
## `godot ... -- --library-demo [ordner]` — Desktop-Smoke (Karte t_3a998ae4):
## Gruppen-Bibliothek + platzierte Objekte + Snap-to-Ground programmatisch
## durchspielen, PNGs speichern. Die App bleibt offen.
func _start_library_demo(args: PackedStringArray) -> void:
var out_dir := "/tmp/scopedvr_library"
var idx := args.find("--library-demo")
if idx >= 0 and idx + 1 < args.size() and not args[idx + 1].begins_with("--"):
out_dir = args[idx + 1]
DirAccess.make_dir_recursive_absolute(out_dir)
# 1) Bibliothek-Browser: Gruppen sichtbar, Ghost-Preview aktiv.
_set_status("Demo 1/3: Bibliothek in Gruppen — ein Objekt anklicken setzt eine Vorschau in die Karte.")
interaction.demo_park_ghost(Vector3(10.0, SvmapDocument.terrain_height_at(doc, 10.0, 6.0), 6.0), "watchtower_wood")
_demo_camera(Vector3(0.0, 2.0, 0.0), 90.0, 35.0, 40.0)
await _demo_shot(out_dir + "/library_browser.png")
# 2) Objekte platziert (versch. Gruppen), eins orange ausgewählt.
var before: Dictionary = history.snapshot(["objects"])
(doc["objects"] as Array).append({"id": "watchtower_wood", "pos": [10.0, 0.0, 6.0], "scale": 1.0, "rot_y_deg": 30.0})
(doc["objects"] as Array).append({"id": "crate_wood", "pos": [-8.0, 0.0, -4.0], "scale": 1.0, "rot_y_deg": 0.0})
(doc["objects"] as Array).append({"id": "tree_pine", "pos": [-20.0, 0.0, 18.0], "scale": 1.0, "rot_y_deg": 75.0})
(doc["objects"] as Array).append({"id": "rock_large", "pos": [24.0, 0.0, -18.0], "scale": 1.0, "rot_y_deg": 0.0})
history.commit("Demo: Objekte gesetzt", before, history.snapshot(["objects"]))
interaction.call("cancel_placement")
_clear_edit_state()
_refresh_all()
interaction.select_object(0, true)
_demo_camera(Vector3(0.0, 2.0, 0.0), 110.0, 40.0, -35.0)
_set_status("Demo 2/3: Objekte platziert (Wachturm orange ausgewählt) — ziehen, drehen, löschen wie gehabt.")
await _demo_shot(out_dir + "/library_placed.png")
# 3) Snap to Ground: Objekte künstlich in die Luft hängen, dann 1 Klick.
var float_before: Dictionary = history.snapshot(["objects"])
for obj in (doc["objects"] as Array):
var pos: Array = obj["pos"]
obj["pos"] = [float(pos[0]), float(pos[1]) + 7.0, float(pos[2])]
history.commit("Demo: Objekte schweben", float_before, history.snapshot(["objects"]))
_refresh_all()
_demo_camera(Vector3(0.0, 6.0, 0.0), 110.0, 30.0, -35.0)
_set_status("Demo 3/3a: Objekte SCHWEBEN nach dem Terrain-Formen — ein Klick auf „Alles auf den Boden setzen\" …")
await _demo_shot(out_dir + "/library_floating.png")
_on_snap_to_ground()
_demo_camera(Vector3(0.0, 4.0, 0.0), 110.0, 38.0, -35.0)
_set_status("Demo 3/3b: … und schon stehen ALLE wieder auf dem Boden (Strg+Z nimmt den ganzen Klick zurück).")
await _demo_shot(out_dir + "/library_after_snap.png")
print("LIBRARY_DEMO_OK: 4 Screenshots in %s" % out_dir)
if args.has("--library-demo-quit"):
get_tree().quit() # Batch-Smoke (xvfb): automatisch beenden, sobald die Shots da sind
## `godot ... -- --spawns-demo [ordner]` — Desktop-Smoke (Karte t_c4162c5d):
## Spawn-Zonen-Workflow programmatisch durchspielen, PNGs speichern:
## Zonen-Übersicht → Live-Check schlägt Alarm bei fehlenden Zonen → Undo rettet →
## Map-Check-Dialog meldet bereit. Die App bleibt offen (Sly sieht das live).
func _start_spawns_demo(args: PackedStringArray) -> void:
var out_dir := "/tmp/scopedvr_spawns"
var idx := args.find("--spawns-demo")
if idx >= 0 and idx + 1 < args.size() and not args[idx + 1].begins_with("--"):
out_dir = args[idx + 1]
DirAccess.make_dir_recursive_absolute(out_dir)
# 1) Übersicht: Default-Zonen beider Teams (Kreise) + rote Ghost-Vorschau.
_set_tool_mode("spawns")
spawn_team_red.button_pressed = true
spawn_tool.call("demo_park_ghost", 260.0, 120.0, 1, 12.0)
_demo_camera(Vector3(160.0, 140.0, 0.0), 700.0, 48.0, -90.0)
_set_status("Demo 1/4: Spawn-Zonen — blaue Kreise = Team Blau, rote = Team Rot, halbtransparent folgt der Ghost der Maus als Vorschau.")
await _demo_shot(out_dir + "/spawns_zones.png")
# 2) ALLE roten Zonen löschen → Live-Check springt auf ❌ (BEVOR exportiert wird).
_clear_edit_state()
var before: Dictionary = SpawnOps.snapshot_blocks(history, doc)
for p in SpawnOps.points(doc, 1).duplicate():
SpawnOps.remove_point(doc, 1, str(p["id"]))
history.commit("Demo: alle roten Zonen gelöscht", before,
SpawnOps.snapshot_blocks(history, doc))
_after_structure_change()
_set_status("Demo 2/4: ALLE roten Zonen weg — der Spawn-Check (rechts) zeigt sofort ❌: Export wäre blockiert.")
await _demo_shot(out_dir + "/spawns_check.png")
# 3) Strg+Z — alle vier roten Zonen sind zurück (EIN Undo-Schritt).
_do_undo()
_set_status("Demo 3/4: Strg+Z — alle 4 roten Zonen sind auf EINMAL zurück.")
await _demo_shot(out_dir + "/spawns_undo.png")
# 4) Karte prüfen: Klartext-Liste im Dialog + Status „Alles bereit“.
_on_check_map()
_set_status("Demo 4/4: „Karte prüfen“ — alles ✅ … beim Speichern wird die .svmap automatisch signiert (sha256).")
await _demo_shot(out_dir + "/spawns_ready.png")
print("SPAWNS_DEMO_OK: 4 Screenshots in %s" % out_dir)
if args.has("--spawns-demo-quit"):
get_tree().quit() # Batch-Smoke (xvfb): automatisch beenden, sobald die Shots da sind
## `godot ... -- --groups-demo [ordner]` — Desktop-Smoke (Karte t_f6f29d86):
## Auswahl-System programmatisch durchspielen, PNGs speichern:
## Mehrfachauswahl (Rahmen simuliert) → gruppieren → Gruppe gemeinsam ziehen →
## gemeinsames Drehen → Undo-Nachweis. Die App bleibt offen (Sly sieht live).
func _start_groups_demo(args: PackedStringArray) -> void:
var out_dir := "/tmp/scopedvr_groups"
var idx := args.find("--groups-demo")
if idx >= 0 and idx + 1 < args.size() and not args[idx + 1].begins_with("--"):
out_dir = args[idx + 1]
DirAccess.make_dir_recursive_absolute(out_dir)
# Aufbau: 4 Objekte in zwei Wohnclustern (2 nahe beieinander = spätere Gruppe).
var before: Dictionary = history.snapshot(["objects"])
(doc["objects"] as Array).append({"id": "watchtower_wood", "pos": [10.0, 0.0, 6.0], "scale": 1.0, "rot_y_deg": 0.0})
(doc["objects"] as Array).append({"id": "crate_wood", "pos": [14.0, 0.0, 9.0], "scale": 1.0, "rot_y_deg": 0.0})
(doc["objects"] as Array).append({"id": "crate_metal", "pos": [14.0, 0.0, 13.0], "scale": 1.0, "rot_y_deg": 45.0})
(doc["objects"] as Array).append({"id": "tree_pine", "pos": [-30.0, 0.0, 24.0], "scale": 1.0, "rot_y_deg": 0.0})
history.commit("Demo: Objekte gesetzt", before, history.snapshot(["objects"]))
_refresh_all()
# 1) Mehrfachauswahl: Objekte 0–2 markiert (wie nach Rahmen-Ausziehen).
interaction.call("select_many", [0, 1, 2], true)
_demo_camera(Vector3(0.0, 2.0, 0.0), 130.0, 40.0, -35.0)
_set_status("Demo 1/4: Mehrfachauswahl — 3 Objekte orange (Umschalt+Klick oder Rahmen aufziehen). Der Baum links bleibt außen vor.")
await _demo_shot(out_dir + "/groups_select.png")
# 2) Gruppieren: EIN Klick, die 3 gehören ab jetzt zusammen.
interaction.call("group_selection")
_set_status("Demo 2/4: „Gruppieren“ — die 3 Objekte sind jetzt Gruppe 1. Der Baum links ist NICHT Teil der Gruppe.")
await _demo_shot(out_dir + "/groups_grouped.png")
# 3) Gruppe gemeinsam ziehen (exakt der Drag-Release-Pfad) + gemeinsames Drehen.
interaction.call("demo_drag_selection", Vector3(24.0, 0.0, -18.0))
interaction.call("rotate_selected", 30.0)
_demo_camera(Vector3(20.0, 2.0, 0.0), 130.0, 42.0, -20.0)
_set_status("Demo 3/4: Gruppe GEMEINSAM verschoben und um die eigene Mitte gedreht — die Anordnung bleibt erhalten, der Baum stand still.")
await _demo_shot(out_dir + "/groups_moved.png")
# 4) Undo-Nachweis: Strg+Z nimmt die Drehung zurück (EIN Schritt), die
# zweite Stufe würde den Zug zurücknehmen — jede Aktion = EIN Undo-Schritt.
_do_undo()
_demo_camera(Vector3(20.0, 2.0, 0.0), 130.0, 42.0, -20.0)
_set_status("Demo 4/4: Strg+Z — die Drehung ist zurück (Zug & Drehen sind getrennte Schritte); die Gruppe bleibt gebunden.")
await _demo_shot(out_dir + "/groups_undo.png")
print("GROUPS_DEMO_OK: 4 Screenshots in %s" % out_dir)
if args.has("--groups-demo-quit"):
get_tree().quit() # Batch-Smoke (xvfb): automatisch beenden, sobald die Shots da sind
## `godot ... -- --bauen-demo [ordner]` — Desktop-Smoke (Karte t_97ea59c5):
## baut programmatisch ein kleines Haus (Fundament+Wände+Giebel+Dach+Tür-Wand
## +Treppe), zeigt Auswahl + Ghost, speichert/lädt die Map zum Beweis. Die App
## bleibt offen (Sly sieht das Ergebnis live).
func _start_bauen_demo(args: PackedStringArray) -> void:
var out_dir := "/tmp/scopedvr_bauen"
var idx := args.find("--bauen-demo")
if idx >= 0 and idx + 1 < args.size() and not args[idx + 1].begins_with("--"):
out_dir = args[idx + 1]
DirAccess.make_dir_recursive_absolute(out_dir)
_set_tool_mode("bauen")
var bt = build_tool # untypisiert (script-erweitert) — kein := möglich
# 1) Ghost-Vorschau am Cursor + erstes Fundament.
bt.call("demo_park_ghost", "wand", Vector3(2.0, 0.2, 2.0), 0.0)
_demo_camera(Vector3(3.0, 2.0, 8.0), 14.0, 32.0, 25.0)
_set_status("Demo 1/3: Bauen-Modus — Bauteil rechts anklicken, die gelbe Vorschau rastet aufs 2-m-Raster.")
await _demo_shot(out_dir + "/bauen_ghost.png")
# 2) Kleines Haus komplett gesetzt (jedes Teil = EIN Undo-Schritt):
# Fundament, 3 Wände, Tür-Wand, 2 Giebelwände, Dach, Treppe.
bt.call("demo_place", "fundament", Vector3(3.0, 0.0, 3.0), 0.0)
bt.call("demo_place", "wand", Vector3(2.0, 0.2, 2.0), 0.0)
bt.call("demo_place", "wand", Vector3(2.0, 0.2, 4.0), 0.0)
bt.call("demo_place", "wand", Vector3(2.0, 0.2, 4.0), 90.0)
bt.call("demo_place", "tuer", Vector3(4.0, 0.2, 4.0), 90.0)
bt.call("demo_place", "giebelwand", Vector3(2.0, 2.2, 2.0), 0.0)
bt.call("demo_place", "giebelwand", Vector3(2.0, 2.2, 4.0), 0.0)
bt.call("demo_place", "dach_giebel", Vector3(3.0, 2.2, 3.0), 0.0)
bt.call("demo_place", "treppe", Vector3(8.0, 0.0, 7.0), 0.0)
_after_structure_change()
bt.call("select_index", 7) # Dach ausgewählt (orangener Rahmen)
_demo_camera(Vector3(9.0, 4.5, 10.0), 16.0, 35.0, 40.0)
_set_status("Demo 2/3: Kleines Haus gebaut — Fundament, Wände, Tür-Öffnung, Giebelwände, Dach, Treppe. Dach ist ausgewählt (orange).")
await _demo_shot(out_dir + "/bauen_haus.png")
# 3) Speichern → Laden → weiterbauen (Abnahme-Flow): Map roundtrippen,
# dann eine zusätzliche Wand — sie muss nach dem Laden sauber passen.
_save_to(out_dir + "/bauen_demo.svmap")
var reload := SvmapIO.load_from_file(out_dir + "/bauen_demo.svmap")
if bool(reload["ok"]):
doc = reload["data"]
history.begin(doc)
_clear_edit_state()
_refresh_all()
var before: Dictionary = history.snapshot(["gebaeude"])
BuildOps.ensure_block(doc).append(BuildOps.make_piece("fenster", Vector3(2.0, 0.2, 4.0), 90.0))
history.commit("Wand mit Fenster gesetzt", before, history.snapshot(["gebaeude"]))
_after_structure_change()
bt.call("demo_park_ghost", "fenster", Vector3(6.0, 0.2, 4.0), 90.0)
_demo_camera(Vector3(9.0, 4.5, 10.0), 16.0, 35.0, 40.0)
_set_status("Demo 3/3: Gespeichert, geladen und WEITERGEBAUT (Fenster-Wand dazwischen) — Strg+Z nimmt nur das neue Teil zurück.")
await _demo_shot(out_dir + "/bauen_weiter.png")
print("BAUEN_DEMO_OK: 3 Screenshots in %s (%d Bauteile, Map OK)" % [
out_dir, BuildOps.piece_count(doc)])
if args.has("--bauen-demo-quit"):
get_tree().quit() # Batch-Smoke (xvfb): automatisch beenden, sobald die Shots da sind