From 095af5ff3c11936b1e3144133bbc1617b1a0fe96 Mon Sep 17 00:00:00 2001 From: "Hermes (godot-vr)" Date: Fri, 16 Oct 2026 17:43:17 +0000 Subject: [PATCH] Shortcuts 1/3 (t_c46d31c2): Freelook-Flug + F-Focus + Esc-Policy + Entf-Mehrfachauswahl + Strg+D (+2 m X) + Strg+LMB-Mehrfachauswahl. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - InputMap-Actions mit physischen KEY_*-Codes (shortcut_keys.gd, idempotent) — DE-Layout/Windows-unabhängig - Freelook: RMT halten = Umsehen+Flug (WASD/EQ, Shift x3, Alt x0.3, Rad = Grundtempo dauerhaft), Shift+F toggle; Orbit bleibt Default, Flug-Exit ohne Kamera-Sprung (FlyMath pure) - F zentriert Auswahl (SelectionMath.focus_center); Esc = pure Prioritätskette (ShortcutPolicy) - Strg+LMB + Baum SELECT_MULTI: Mehrfachauswahl synchron Szene↔Baum; Entf/Strg+D wirken auf die ganze Auswahl (EIN Undo-Schritt) - docs/tasten.md = deutsche Source of truth (Karten 2/3 ergänzen) - Tests: test_shortcuts.gd NEU 70 Checks, Suite 571 grün, Builder-Export 21/0, Parse-Check grün --- README.md | 50 ++++- docs/tasten.md | 69 ++++++ src/editor/fly_math.gd | 70 ++++++ src/editor/fly_math.gd.uid | 1 + src/editor/interaction.gd | 88 +++++--- src/editor/object_ops.gd | 25 ++- src/editor/selection_math.gd | 23 ++ src/editor/shortcut_keys.gd | 69 ++++++ src/editor/shortcut_keys.gd.uid | 1 + src/editor/shortcut_policy.gd | 25 +++ src/editor/shortcut_policy.gd.uid | 1 + src/ui/main.gd | 139 +++++++++--- src/viewport/orbit_camera.gd | 159 +++++++++++++- tests/test_shortcuts.gd | 351 ++++++++++++++++++++++++++++++ tests/test_shortcuts.gd.uid | 1 + tests/test_ux.gd | 12 +- tools/parse_check_shortcuts.sh | 19 ++ tools/run_tests.sh | 2 +- 18 files changed, 1026 insertions(+), 79 deletions(-) create mode 100644 docs/tasten.md create mode 100644 src/editor/fly_math.gd create mode 100644 src/editor/fly_math.gd.uid create mode 100644 src/editor/shortcut_keys.gd create mode 100644 src/editor/shortcut_keys.gd.uid create mode 100644 src/editor/shortcut_policy.gd create mode 100644 src/editor/shortcut_policy.gd.uid create mode 100644 tests/test_shortcuts.gd create mode 100644 tests/test_shortcuts.gd.uid create mode 100644 tools/parse_check_shortcuts.sh diff --git a/README.md b/README.md index 725cae4..5e7ea6f 100644 --- a/README.md +++ b/README.md @@ -170,9 +170,11 @@ aufgebaut, nach dem Chef-Grundsatz (Klick statt Konsole, deutsche UI, Undo für alles): - **Duplizieren (`⧉ Duplizieren`-Button + Strg+D):** Die Kopie des - ausgewählten Objekts hängt diagonal 1 m versetzt daneben (mit Raster-Snap - eingerastet, wenn an) und ist DIREKT angewählt — sie lässt sich gleich + ausgewählten Objekts hängt +2 m in X versetzt daneben (Rasterversatz, + Karte t_c46d31c2; Füßpunkt-Höhe unverändert, mit Raster-Snap eingerastet, + wenn an) und ist DIREKT angewählt — sie lässt sich gleich weiterziehen/drehen/löschen. Drehung, Größe und Höhe werden übernommen. + Bei Mehrfachauswahl werden ALLE Objekte kopiert (EIN Undo-Schritt). Poly-Budget und Objekt-Limit werden VOR dem Kopieren geprüft (Klartext-Meldung, was zu tun ist). EIN Undo-Schritt. - **Anheben/Absenken (`▲ Anheben / ▼ Absenken` + Bild↑/Bild↓):** Objekte @@ -290,3 +292,47 @@ springen, ESC beendet das Test-Fenster sauber (kein Prozess-Kill nötig). ungültige Map blockiert, Client-Suche inkl. Konfig-Vorrang, Godot-Findung, exakte Argumentliste). Parse-Check umfasst `test_run.gd`. +## Status (M2, Karte t_c46d31c2 — Tastatur-Shortcuts 1/3: Navigation + Auswahl + Löschen FERTIG) + +Tastenkürzel-Karte 1 von 3 (Sly-Freigabe 16.10., finale Belegung). +**`docs/tasten.md` ist jetzt die deutsche Source of truth** für ALLE +Shortcut-Karten — Karten 2/3 ergänzen dort. + +- **Konventionen (alle Karten):** Tastaturcode-basiert — InputMap-Actions mit + PHYSISCHEN `KEY_*`-Konstanten (`src/editor/shortcut_keys.gd`, registriert in + `main._ready`, idempotent). Physische Codes = deutsche (QWERTZ) und + Windows-Layouts identisch, keine OS-Hardcodes. Jede Objekt-Operation = + EIN ActionHistory-Eintrag (Ausnahme: reine Kamera-Aktionen). +- **Freelook-Flug:** Rechte Maustaste GEDACHT halten = Umsehen + Flug + (W/S/A/D + E/Q, Umschalt = ×3, Alt = ×0,3, Mausrad = Grundgeschwindigkeit + DAUERHAFT). Umschalt+F = Flug dauerhaft togglen. Orbit bleibt Default — + beim Flug-Exit werden Ziel/Winkel/Abstand aus Flugposition/Blickrichtung + zurückgerechnet (kein Kamera-Sprung, getestet). Mathe pur in + `src/editor/fly_math.gd`, Kamera dünn (`src/viewport/orbit_camera.gd`). + Konflikt-Auflösung: RMT beim Platzieren gehört dem Ghost-Abbruch + (`rmb_filter`), Mausrad im Flug gehört dem Tempo (interaction blockt). +- **F** = Auswahl ins Bild zentrieren — Orbit-Ziel wandert auf den Auswahl- + Mittelpunkt (Anker + halbe Mesh-Höhe, `SelectionMath.focus_center`); im + Flug dreht sich nur die Blickrichtung. +- **Esc** = Prioritätskette Werkzeug verlassen → Ghost-Preview abbrechen → + Auswahl aufheben, als pure, headless-getestete Funktion + (`src/editor/shortcut_policy.gd`). +- **Entf (DELETE)** = Auswahl SOFORT löschen ohne Rückfrage (Sly-Yolo) — + wirkt auf Szene-Auswahl UND Objektbaum-Markierung (Baum und Szene sind + synchron), Strg+Z deckt es ab. War schon mehrfach-fähig, jetzt + getestet (EIN Undo-Schritt holt ALLE zurück). +- **Strg+D** = Auswahl duplizieren: Kopie(n) +2 m in X (Rasterversatz), + Füßpunkt unverändert; bei Mehrfachauswahl ALLE Objekte mit EINEM + Undo-Schritt, Budget/Limit gesammelt VORHER geprüft + (`ObjectOps.duplicate_objects`). +- **Strg+LMB** = Mehrfachauswahl umschalten (Objektbaum: SELECT_MULTI + + native Strg+Klick-Logik, spiegelt sich in die Viewport-Auswahl und + umgekehrt). Bestandsaktionen (Löschen, Duplizieren, Ziehen, Gruppieren) + wirken auf die GANZE Mehrfachauswahl. +- **Tests:** `tests/test_shortcuts.gd` NEU (70 Checks: InputMap-Actions + inkl. Idempotenz, Freelook-Mathe, Esc-Prioritäten, Duplizieren +2 m X + mit Undo-Roundtrip, Entf-Mehrfachauswahl gegen die ECHTE interaction.gd, + Focus-Mittelpunkt, Kamera Flug-Roundtrip). Alle 8 Suiten grün + (571 Checks), Builder-Export 21/0, Parse-Check aller geänderten Scripts + (`tools/parse_check_shortcuts.sh`). + diff --git a/docs/tasten.md b/docs/tasten.md new file mode 100644 index 0000000..7ce13ff --- /dev/null +++ b/docs/tasten.md @@ -0,0 +1,69 @@ +# Tastatur & Maus — Map-Editor + +Deutsche Tastenbelegung des ScopedVR Map-Editors. **Diese Datei ist die Source +of truth** für alle Shortcut-Karten (Karte 1/3: t_c46d31c2 — Navigation, +Auswahl, Löschen; Karten 2/3 ergänzen hier). + +Konventionen (alle Karten): + +- Tastaturcode-basiert: InputMap-Actions mit physischen `KEY_*`-Konstanten + (`src/editor/shortcut_keys.gd`). Die physische Taste liegt unabhängig vom + Tastaturlayout gleich — deutsches (QWERTZ) und Windows-Layout verhalten sich + identisch, keine OS-Hardcodes (PLAN.md Plattform-Regel). +- Jede Objekt-Operation = **EIN** ActionHistory-Eintrag (Strg+Z nimmt die + ganze Operation zurück). Ausnahme: reine Kamera-Aktionen (Orbit/Flug/Zoom) + betreffen die Map nicht und sind nicht in der Historie. + +## Navigation (Kamera) + +| Eingabe | Wirkung | +| --- | --- | +| Mittlere Maustaste ziehen | Orbit: Um die Karte drehen | +| Mittlere Maustaste + Umschalt ziehen | Pan: Karte verschieben | +| Mausrad | Zoom (aus, wenn Mausrad gerade ein Objekt dreht) | +| **Rechte Maustaste GEDACHT halten** | **Freelook-Flug:** Umsehen + W vor / S zurück / A links / D rechts; E hoch / Q runter | +| Umschalt (im Flug) | schneller fliegen (×3) | +| Alt (im Flug) | langsamer fliegen (×0,3) | +| Mausrad (im Flug) | Grundgeschwindigkeit dauerhaft ändern (2–300 m/s) | +| **Umschalt+F** | Freelook-Flug dauerhaft an/aus (Flug bleibt ohne gedrückte rechte Maustaste aktiv) | +| **F** | Auswahl ins Bild zentrieren (Focus Selection) | + +Der Orbit-Modus ist der Default: Beim Verlassen des Flugs springt die Kamera +nicht — Ziel/Winkel/Abstand werden aus der Flugposition zurückgerechnet. + +## Auswahl + +| Eingabe | Wirkung | +| --- | --- | +| Linksklick | Objekt auswählen (ersetzt die Auswahl) | +| **Strg+Klick** (Viewport **und** Objektbaum) | Objekt zur Auswahl hinzufügen/entfernen (Mehrfachauswahl) | +| Umschalt+Klick | wie Strg+Klick (Mehrfachauswahl umschalten) | +| Ziehen über leeren Boden | Rahmen-Aufziehen: alle Objekte im Rahmen wählen | +| Umschalt + Rahmen | Rahmen-Auswahl zur bestehenden Auswahl hinzufügen | +| Esc | Auswahl aufheben / aktives Werkzeug abbrechen / Ghost-Preview abbrechen | + +Alle Bestandsaktionen wirken auf die GANZE Mehrfachauswahl: Entf löscht alle, +Strg+D dupliziert alle, Ziehen verschiebt gemeinsam, Strg+G gruppieren / +Strg+Umschalt+G lösen. + +## Objekt-Operationen + +| Eingabe | Wirkung | +| --- | --- | +| **Entf (DELETE)** | Auswahl SOFORT löschen, ohne Rückfrage — Strg+Z holt alles zurück. Wirkt auf Szene-Auswahl UND Objektbaum-Markierung | +| **Strg+D** | Auswahl duplizieren: Kopie +2 m in X versetzt (Rasterversatz), Füßpunkt unverändert; bei Mehrfachauswahl ALLE Objekte mit EINEM Undo-Schritt | +| R / Mausrad (mit Auswahl) | Drehen im 15°-Raster | +| Bild↑ / Bild↓ | Objekt +0,25 m / −0,25 m anheben/absenken (Mehrfachdruck = EIN Undo-Schritt) | +| Strg+Z / Strg+Y | Rückgängig / Wiederherstellen | +| Strg+S | Karte speichern | +| Strg+G / Strg+Umschalt+G | Auswahl gruppieren / Gruppe lösen | + +## Werkzeuge + +| Eingabe | Wirkung | +| --- | --- | +| Esc (im Terrain-/Spawn-/Bauen-Werkzeug) | Werkzeug beenden — zurück zum Objekt-Werkzeug | +| Esc (beim Platzieren) | Ghost-Preview abbrechen | + +*(Karten 2/3 ergänzen hier: R-Drehen 90°, Pfeiltasten-Raster, Bild↑/↓-Feinheiten, +Greifen mit G, Terrain-Pinsel `[` `]` und Ziffern-Modi.)* \ No newline at end of file diff --git a/src/editor/fly_math.gd b/src/editor/fly_math.gd new file mode 100644 index 0000000..2d97178 --- /dev/null +++ b/src/editor/fly_math.gd @@ -0,0 +1,70 @@ +extends RefCounted +class_name FlyMath +## Freelook-Flug-Mathe (Karte t_c46d31c2, Karte 1/3) — bewusst pure Funktionen +## (kein Knoten, keine Kamera): speed_for/wheel_speed/move_delta/forward- und +## Euler-Umrechnung. tests/test_shortcuts.gd prüft headless; orbit_camera.gd +## ist nur der dünne Anwender. +## +## Euler-Konvention identisch zur Orbit-Kamera (_apply in orbit_camera.gd): +## Blickrichtung forward(yaw, pitch) = +## (−sin(yaw)·cos(pitch), −sin(pitch), −cos(yaw)·cos(pitch)) +## pitch positiv = Kamera schaut von OBEN herab (Orbit-Pitch 5…85°), +## im Flug erlaubt: −85…+85° (auch von unten hochschauen). + +const BASE_SPEED_MPS := 30.0 # Grundgeschwindigkeit (Mausrad ändert sie dauerhaft) +const FAST_MULT := 3.0 # Umschalt = schneller +const SLOW_MULT := 0.3 # Alt = langsamer +const WHEEL_MULT := 1.15 # Mausrad-Tick: +15 % / −13 % +const SPEED_MIN_MPS := 2.0 +const SPEED_MAX_MPS := 300.0 + +const PITCH_MAX_DEG := 85.0 # Flug-Blick grenzwertig (±) — kein Gimbal-Kippen + + +## Flug-Tempo: Umschalt dominiert, wenn (unüblicherweise) beide Modifier unten sind. +static func speed_for(base_mps: float, fast: bool, slow: bool) -> float: + if fast: + return base_mps * FAST_MULT + if slow: + return base_mps * SLOW_MULT + return base_mps + + +## Mausrad-Tick: Grundgeschwindigkeit DAUERHAFT ändern (nicht nur solange +## gedrückt) — up=true schneller, down=false langsamer, immer geklemmt. +static func wheel_speed(base_mps: float, up: bool) -> float: + var next := base_mps * WHEEL_MULT if up else base_mps / WHEEL_MULT + return clampf(next, SPEED_MIN_MPS, SPEED_MAX_MPS) + + +## Bewegungs-Delta für einen Frame. forward = BLICKRICHTUNG (3D, mit Pitch — +## W/S fliegen dorthin, wo man hinsieht), right = horizontal-rechts, und +## vertikal ist Welt-UP (E/Q heben/senken unabhängig vom Blick). +## Diagonal-Eingaben werden normalisiert (kein √2-Tempo). Rückgabe: Meter. +static func move_delta(forward: Vector3, right: Vector3, fwd_input: float, + side_input: float, vert_input: float, speed_mps: float, delta_s: float) -> Vector3: + var dir := forward * fwd_input + right * side_input + Vector3.UP * vert_input + if dir.length_squared() < 0.0000001: + return Vector3.ZERO + return dir.normalized() * speed_mps * delta_s + + +## Blickrichtung → (yaw_deg, pitch_deg) in der Orbit-Konvention (siehe oben). +static func euler_from_forward(forward: Vector3) -> Vector2: + var f := forward + if f.length_squared() < 0.0000001: + return Vector2(0.0, 0.0) + f = f.normalized() + var pitch := rad_to_deg(asin(clampf(-f.y, -1.0, 1.0))) + var yaw := rad_to_deg(atan2(-f.x, -f.z)) + return Vector2(yaw, pitch) + + +## (yaw_deg, pitch_deg) → Blickrichtung (Inverse von euler_from_forward). +static func forward_from_euler(yaw_deg: float, pitch_deg: float) -> Vector3: + var yaw := deg_to_rad(yaw_deg) + var pitch := deg_to_rad(clampf(pitch_deg, -PITCH_MAX_DEG, PITCH_MAX_DEG)) + return Vector3( + -sin(yaw) * cos(pitch), + -sin(pitch), + -cos(yaw) * cos(pitch)) \ No newline at end of file diff --git a/src/editor/fly_math.gd.uid b/src/editor/fly_math.gd.uid new file mode 100644 index 0000000..9885ab3 --- /dev/null +++ b/src/editor/fly_math.gd.uid @@ -0,0 +1 @@ +uid://btbsxedv3fvw diff --git a/src/editor/interaction.gd b/src/editor/interaction.gd index 9760f94..59854ff 100644 --- a/src/editor/interaction.gd +++ b/src/editor/interaction.gd @@ -5,8 +5,9 @@ extends Node3D ## Cursor, Klick setzt das Objekt (kein "überraschtes" Ergebnis). ## - Auswahl: angeklickte Objekte färben sich orange, Ziehen verschiebt, ## R-Taste ODER Mausrad dreht (15°-Raster), Entf löscht. -## - Mehrfachauswahl (Karte t_f6f29d86): Umschalt+Klick fügt hinzu/entfernt, -## Rahmen-Aufziehen über leeren Boden wählt alles im Rahmen. +## - Mehrfachauswahl (Karte t_f6f29d86, erweitert t_c46d31c2): Umschalt+Klick +## ODER Strg+Klick fügt hinzu/entfernt, Rahmen-Aufziehen über leeren Boden +## wählt alles im Rahmen. Löschen/Duplizieren wirken auf die GANZE Auswahl. ## - Gruppen: Objekte mit gleicher "group"-ID ziehen/drehen gemeinsam ## (GroupOps), Gruppieren/Lösen über main-Buttons (Strg+G / Strg+Umschalt+G). ## - Undo: Jede Aktion = EINE ActionHistory-Stufe (auch Gruppen-Ziehen). @@ -77,11 +78,19 @@ func setup(p_main, p_camera: Camera3D, p_builder) -> void: ## Kamera fragt: dreht das Mausrad gerade ein Objekt/eine Vorschau -## (dann zoomt das Rad NICHT)? +## (dann zoomt das Rad NICHT)? Im Freelook-Flug (t_c46d31c2) blockt das Rad +## zusätzlich ALLE Objekt-Reaktionen — dort ändert es die Grundgeschwindigkeit. func wheel_blocked() -> bool: + if _camera_flying(): + return true return placing or selected_index >= 0 +func _camera_flying() -> bool: + return camera != null and camera.has_method("fly_active") \ + and bool(camera.call("fly_active")) + + ## Höhen-Cache auffrischen (Karte t_4092e3a0): NACH Terrain-Edits, Undo/Redo, ## Laden/Neu — main ruft das in _after_structure_change. Ein Decode pro ## Änderung statt pro Mausbewegung (129×129-Quantisierung bleibt billig). @@ -317,11 +326,11 @@ func select_toggle(idx: int) -> void: selected_indices.erase(idx) if selected_index == idx: selected_index = selected_indices.back() if not selected_indices.is_empty() else -1 - main.call("_set_status", "%s aus der Auswahl genommen — noch %d Objekte markiert. Umschalt+Klick fügt wieder hinzu." % [label, selected_indices.size()]) + main.call("_set_status", "%s aus der Auswahl genommen — noch %d Objekte markiert. Umschalt-/Strg+Klick fügt wieder hinzu." % [label, selected_indices.size()]) else: selected_indices.append(idx) selected_index = idx - main.call("_set_status", "%s zur Auswahl dazugeholt — jetzt %d Objekte. Mehr umschalten, Rahmen aufziehen oder Strg+G zum Gruppieren." % [label, selected_indices.size()]) + main.call("_set_status", "%s zur Auswahl dazugeholt — jetzt %d Objekte. Mehr umschalten (Umschalt-/Strg+Klick), Rahmen aufziehen oder Strg+G zum Gruppieren." % [label, selected_indices.size()]) _apply_selection_view() main.call("_sync_selection_panel") @@ -339,6 +348,9 @@ func clear_selection(silent := false) -> void: ## Auswahl-Anzeige (Viewport-Färbung + Baum + Panel) aus dem aktuellen Stand. func _apply_selection_view() -> void: view_builder.call("set_selected_multi", selected_indices) + # Baum GANZ synchron (Karte t_c46d31c2): auch Abwählen per Strg+Klick im + # Viewport entfernt die Baum-Markierung wieder. + main.call("_mark_tree_selection_multi", selected_indices) if selected_index >= 0: main.call("_mark_tree_selection", selected_index) @@ -461,45 +473,52 @@ func delete_selected() -> void: ## (Karte t_7567a06f — alle drei auch bei Sly bedienbar: Button rechts + ## Tastenkürzel, alles über ActionHistory, Klartext-Status-Hilfe.) -## Strg+D / Button "Duplizieren": Kopie diagonal leicht versetzt (mit -## Raster-Snap eingerastet, wenn an), direkt angewählt, EIN Undo-Schritt. -## Poly-Budget und Objekt-Limit werden VOR dem Kopieren geprüft. +## Strg+D / Button "Duplizieren" (Karte t_c46d31c2): JEDE Kopie rastet +2 m in +## X versetzt ein (Füßpunkt-Höhe unverändert, mit Raster-Snap eingerastet, wenn +## an) — bei Mehrfachauswahl werden ALLE Objekte kopiert, EIN Undo-Schritt für +## alle Kopien, die Kopien sind direkt als neue Auswahl angewählt. Poly-Budget +## und Objekt-Limit werden VOR dem Kopieren gesammelt geprüft. func duplicate_selected() -> void: - if selected_index < 0: + var objects: Array = main.doc.get("objects", []) + var sources: Array = [] + for i in selected_indices: + if i is int and i >= 0 and i < objects.size(): + sources.append(i) + if sources.is_empty(): main.call("_set_status", "Nichts ausgewählt — klicke zuerst ein Objekt an, das du kopieren willst.", true) return - var objects: Array = main.doc.get("objects", []) - if selected_index >= objects.size(): - clear_selection(true) - return - if objects.size() >= SvmapIO.MAX_OBJECTS: + if objects.size() + sources.size() > SvmapIO.MAX_OBJECTS: main.call("_set_status", "Limit erreicht: In eine Map passen maximal %d Objekte — das Löschen von Objekten macht wieder Platz." % SvmapIO.MAX_OBJECTS, true) return - var obj: Dictionary = objects[selected_index] - var oid := str(obj.get("id", "")) var lib: Dictionary = main.manifest.get("objects", {}) - var entry: Dictionary = lib.get(oid, {}) - var scl := float(obj.get("scale", 1.0)) - var poly := float(entry.get("poly", 0)) * scl * scl + var poly_sum := 0.0 + for i in sources: + var src_obj: Dictionary = objects[i] + var scl := float(src_obj.get("scale", 1.0)) + var entry: Dictionary = lib.get(str(src_obj.get("id", "")), {}) + poly_sum += float(entry.get("poly", 0)) * scl * scl var used := SvmapDocument.poly_usage(main.doc, main.manifest) var budget := float(SvmapIO.MAX_TOTAL_POLY) - if used + poly > budget: - main.call("_set_status", "Für eine Kopie fehlt das Poly-Budget (%s von %s belegt) — Strg+Z nimmt etwas zurück, oder lösche ein anderes Objekt." % [ + if used + poly_sum > budget: + main.call("_set_status", "Für %s fehlt das Poly-Budget (%s von %s belegt) — Strg+Z nimmt etwas zurück, oder lösche ein anderes Objekt." % [ + "die Kopien" if sources.size() > 1 else "eine Kopie", main._fmt_int(int(used)), main._fmt_int(int(budget))], true) return var before: Dictionary = main.history.snapshot(["objects"]) - var new_index: int = ObjectOps.duplicate_object(main.doc, selected_index, bool(main.get("grid_snap"))) - if new_index < 0: + var new_indices: Array = ObjectOps.duplicate_objects(main.doc, sources, bool(main.get("grid_snap"))) + if new_indices.is_empty(): clear_selection(true) return - main.history.commit("%s duplizieren" % main.object_label(oid), - before, main.history.snapshot(["objects"])) + var label: String + if new_indices.size() == 1: + label = "%s duplizieren" % main.object_label(str((objects[sources[0]] as Dictionary).get("id", ""))) + else: + label = "%d Objekte duplizieren" % new_indices.size() + main.history.commit(label, before, main.history.snapshot(["objects"])) main.call("_after_structure_change") - # Kopie direkt anwählen — Sly kann sie gleich weiterziehen. - select_object(new_index) - var objects2: Array = main.doc.get("objects", []) - var label: String = main.object_label(str(objects2[new_index].get("id", oid))) - main.call("_set_status", "%s dupliziert: das neue Objekt ist direkt angewählt — Ziehen verschiebt es, Entf löscht es wieder." % label) + # Kopien direkt anwählen — Sly kann sie gleich weiterziehen. + select_many(new_indices) + main.call("_set_status", "%s: die Kopie(n) liegen +2 m versetzt und sind direkt angewählt — Ziehen verschiebt, Entf löscht, Strg+Z nimmt ALLE Kopien auf einmal zurück." % label) ## ▲/▼ / Strg+Bild↑/↓: Objekt in 0,25-m-Schritten anheben/absenken. @@ -625,6 +644,8 @@ func _unhandled_input(event: InputEvent) -> void: main.call("_set_status", "Platzieren abgebrochen.") get_viewport().set_input_as_handled() elif mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed: + if _camera_flying(): + return # Mausrad = Flug-Tempo (orbit_camera), kein Objekt-Drehen if placing: place_rot_deg = fmod(place_rot_deg + ROT_STEP_DEG + 3600.0, 360.0) _ghost.rotation.y = deg_to_rad(place_rot_deg) @@ -633,6 +654,8 @@ func _unhandled_input(event: InputEvent) -> void: rotate_selected(ROT_STEP_DEG) get_viewport().set_input_as_handled() elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed: + if _camera_flying(): + return # Mausrad = Flug-Tempo (orbit_camera), kein Objekt-Drehen if placing: place_rot_deg = fmod(place_rot_deg - ROT_STEP_DEG + 3600.0, 360.0) _ghost.rotation.y = deg_to_rad(place_rot_deg) @@ -649,8 +672,9 @@ func _on_left_press(mb: InputEventMouseButton) -> void: _press_pos = mb.position var hit := _pick_object(mb.position) if hit >= 0: - if mb.shift_pressed: - # Umschalt+Klick: zur Auswahl hinzufügen/entfernen — kein Ziehen. + if mb.shift_pressed or mb.is_command_or_control_pressed(): + # Umschalt+Klick / Strg+Klick (Karte t_c46d31c2): zur Auswahl + # hinzufügen/entfernen — kein Ziehen. select_toggle(hit) return if not selected_indices.has(hit): diff --git a/src/editor/object_ops.gd b/src/editor/object_ops.gd index 798a36d..ae9f6de 100644 --- a/src/editor/object_ops.gd +++ b/src/editor/object_ops.gd @@ -7,7 +7,8 @@ class_name ObjectOps const GRID_M := 1.0 # Raster-Snap-Weite (1 m — handhabbar für Objekte) const FLOAT_EPSILON := 0.01 # ab dieser Abweichung gilt ein Objekt als "schwebend" const LIFT_STEP_M := 0.25 # Anheben/Absenken: grober Rasterschritt (Karte t_7567a06f) -const DUPLICATE_OFFSET_M := Vector2(1.0, 1.0) # Kopie diagonal leicht versetzt +const DUPLICATE_OFFSET_M := Vector2(2.0, 0.0) # Kopie +2 m in X (Rasterversatz, + # Karte t_c46d31c2) ## ALLE Objekte auf die Terrain-Höhe setzen (Snap to Ground). Objekte hängen @@ -41,9 +42,11 @@ static func snap_coord(value: float, enabled: bool, grid_m := GRID_M) -> float: # Rest der Datei: die UI (interaction.gd/main.gd) snapshotet, ruft auf, commitet. ## Duplizieren: Kopie von objects[index] ans ENDE der Liste hängen. Die Kopie -## ist diagonal um DUPLICATE_OFFSET_M versetzt (mit Raster-Snap eingerastet, -## wenn an) und behält Drehung/Größe/Höhe. Rückgabe: neuer Index (-1 = ungültig). -## VOR dem Aufruf prüft die UI Budget/Objekt-Limit (hilfs-Meldung statt Fehler). +## ist um DUPLICATE_OFFSET_M versetzt (+2 m in X, Rasterversatz Karte +## t_c46d31c2; mit Raster-Snap eingerastet, wenn an) und behält Drehung/Größe/ +## Höhe — der Füßpunkt (pos.y) bleibt exakt. Rückgabe: neuer Index (-1 = +## ungültig). VOR dem Aufruf prüft die UI Budget/Objekt-Limit (hilfs-Meldung +## statt Fehler). static func duplicate_object(doc: Dictionary, index: int, grid_enabled: bool) -> int: var objects: Array = doc.get("objects", []) if index < 0 or index >= objects.size(): @@ -61,6 +64,20 @@ static func duplicate_object(doc: Dictionary, index: int, grid_enabled: bool) -> return objects.size() - 1 +## MEHRERE Objekte auf einmal duplizieren (Karte t_c46d31c2, Strg+D bei +## Mehrfachauswahl): jede Kopie über duplicate_object (+2 m in X), Reihenfolge +## der Quellen bleibt erhalten, alle Indizes sind Originale (nur angehängt). +## Rückgabe: Liste der NEUEN Indizes (in Quell-Reihenfolge). Die UI commitet +## EINEN Undo-Schritt für alle Kopien zusammen. +static func duplicate_objects(doc: Dictionary, indices: Array, grid_enabled: bool) -> Array: + var new_indices: Array = [] + for i in indices: + var ni := duplicate_object(doc, int(i), grid_enabled) + if ni >= 0: + new_indices.append(ni) + return new_indices + + ## Anheben/Absenken in LIFT_STEP_M-Schritten (delta_m positiv = anheben). ## Objekte dürfen vom Terrain abweichen — die Höhe wohnt direkt in object.pos.y ## (das svmap-Format erlaubt freie y-Werte innerhalb der Match-Grenzen, V9 — diff --git a/src/editor/selection_math.gd b/src/editor/selection_math.gd index 1428fd0..4fdde39 100644 --- a/src/editor/selection_math.gd +++ b/src/editor/selection_math.gd @@ -48,3 +48,26 @@ static func pick_in_rect(cam: Camera3D, world_positions: Array, rect: Rect2) -> static func rect_from(a: Vector2, b: Vector2) -> Rect2: return Rect2(Vector2(minf(a.x, b.x), minf(a.y, b.y)), Vector2(absf(b.x - a.x), absf(b.y - a.y))) + + +## Mittelpunkt der Auswahl für "F = Auswahl ins Bild zentrieren" +## (Karte t_c46d31c2): Mittelwert der Marker-Anker (+ halbe Mesh-Höhe — der +## optische Objekt-Mittelpunkt, nicht der Fußpunkt). Ungültige Indizes/Marker +## werden übersprungen; bei GAR NICHTS Gültigem: Ursprung (main meldet dann +## „nichts ausgewählt“ — der Guard sitzt beim Aufrufer). +static func focus_center(markers: Array, indices: Array) -> Vector3: + var sum := Vector3.ZERO + var count := 0 + for i in indices: + if not (i is int) or i < 0 or i >= markers.size(): + continue + var m: Variant = markers[i] + if m == null or not (m is Node3D) or not is_instance_valid(m): + continue + var node := m as Node3D + sum += node.global_position \ + + Vector3.UP * float(node.get_meta("half_height", 1.0)) * 0.5 + count += 1 + if count == 0: + return Vector3.ZERO + return sum / float(count) diff --git a/src/editor/shortcut_keys.gd b/src/editor/shortcut_keys.gd new file mode 100644 index 0000000..290c10a --- /dev/null +++ b/src/editor/shortcut_keys.gd @@ -0,0 +1,69 @@ +extends RefCounted +class_name ShortcutKeys +## InputMap-Actions der Editor-Tastatur-Shortcuts (Karte t_c46d31c2, Karte 1/3). +## +## Konvention (alle Shortcut-Karten): Tastaturcode-basiert — JEDER Action wird +## ein InputEventKey mit PHYSICAL keycode zugewiesen (KEY_*-Konstante). Die +## physische Taste liegt unabhängig vom Tastaturlayout an derselben Position +## (QWERTZ wie QWERTY bei W/A/S/D/E/Q/F), damit deutsche und Windows-Tastaturen +## identisch bedienen — keine OS-Hardcodes (PLAN.md Plattform-Regel). +## +## Registrierung per Code statt project.godot: KEY_*-Konstanten bleiben im +## Quelltext lesbar und der Test kann exakt dieselben Konstanten prüfen. +## register() ist idempotent (mehrfacher Aufruf = keine Doppel-Events). + +## Action → Liste von Events. Einträge: +## KEY_X = einfache physische Taste +## {"key": KEY_X, "shift": true} = Taste + Modifier (z. B. Umschalt+F) +const ACTIONS := { + # --- Freelook-Flug (Rechte Maustaste halten bzw. Umschalt+F) ------------- + "editor_freelook_toggle": [{"key": KEY_F, "shift": true}], + "editor_freelook": [MOUSE_BUTTON_RIGHT], + "editor_fly_forward": [KEY_W], + "editor_fly_back": [KEY_S], + "editor_fly_left": [KEY_A], + "editor_fly_right": [KEY_D], + "editor_fly_up": [KEY_E], + "editor_fly_down": [KEY_Q], + "editor_fly_fast": [KEY_SHIFT], + "editor_fly_slow": [KEY_ALT], + # --- Auswahl / Objekt-Operationen ---------------------------------------- + "editor_focus_selection": [KEY_F], + "editor_cancel": [KEY_ESCAPE], + "editor_delete_selection": [KEY_DELETE], + "editor_duplicate_selection": [{"key": KEY_D, "ctrl": true}], + # Strg+LMB = Mehrfachauswahl umschalten (Maus-Event, handler prüft den + # Modifier direkt am InputEventMouseButton — Action dokumentiert die Idee). + "editor_multi_select": [{"key": KEY_NONE, "mouse_left_ctrl": true}], +} + + +static func register() -> void: + for action in ACTIONS: + if InputMap.has_action(action): + continue + InputMap.add_action(action) + for entry in ACTIONS[action]: + InputMap.action_add_event(action, _make_event(entry)) + + +static func _make_event(entry) -> InputEvent: + if entry is Dictionary: + if entry.get("mouse_left_ctrl", false): + var mb := InputEventMouseButton.new() + mb.button_index = MOUSE_BUTTON_LEFT + mb.ctrl_pressed = true + return mb + var key := InputEventKey.new() + key.physical_keycode = int(entry.get("key", KEY_NONE)) + key.shift_pressed = bool(entry.get("shift", false)) + key.ctrl_pressed = bool(entry.get("ctrl", false)) + key.alt_pressed = bool(entry.get("alt", false)) + return key + if entry is int and entry >= MOUSE_BUTTON_NONE and entry <= MOUSE_BUTTON_RIGHT: + var mouse := InputEventMouseButton.new() + mouse.button_index = int(entry) + return mouse + var simple := InputEventKey.new() + simple.physical_keycode = int(entry) + return simple \ No newline at end of file diff --git a/src/editor/shortcut_keys.gd.uid b/src/editor/shortcut_keys.gd.uid new file mode 100644 index 0000000..3bf4d56 --- /dev/null +++ b/src/editor/shortcut_keys.gd.uid @@ -0,0 +1 @@ +uid://ctglyo67gpgdc diff --git a/src/editor/shortcut_policy.gd b/src/editor/shortcut_policy.gd new file mode 100644 index 0000000..ad046bd --- /dev/null +++ b/src/editor/shortcut_policy.gd @@ -0,0 +1,25 @@ +extends RefCounted +class_name ShortcutPolicy +## Esc-Verhalten des Map-Editors (Karte t_c46d31c2, Karte 1/3) — bewusst pure +## Funktion: main.gd fragt pro Esc-Taste ab WELCHE Zustandsänderung fällig ist +## und macht nur noch den Text. tests/test_shortcuts.gd prüft alle Kombos. +## +## Priorität (spec „Esc = Auswahl aufheben / Werkzeug abbrechen / Ghost abbrechen"): +## 1. aktives Sonder-Werkzeug verlassen (Terrain/Spawns/Bauen → Objekt-Werkzeug) +## 2. Ghost-Preview (Platzieren) abbrechen +## 3. Auswahl aufheben +## Rückgabe-Werte: +## "exit_terrain" | "exit_spawns" | "exit_bauen" | "cancel_placement" +## "clear_selection" | "none" +static func esc_action(tool_mode: String, placing: bool, has_selection: bool) -> String: + if tool_mode == "terrain": + return "exit_terrain" + if tool_mode == "spawns": + return "exit_spawns" + if tool_mode == "bauen": + return "exit_bauen" + if placing: + return "cancel_placement" + if has_selection: + return "clear_selection" + return "none" \ No newline at end of file diff --git a/src/editor/shortcut_policy.gd.uid b/src/editor/shortcut_policy.gd.uid new file mode 100644 index 0000000..8cde9b1 --- /dev/null +++ b/src/editor/shortcut_policy.gd.uid @@ -0,0 +1 @@ +uid://0sobeix0nlyg diff --git a/src/ui/main.gd b/src/ui/main.gd index bba4a47..a1ae8b3 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -16,6 +16,9 @@ extends Control ## 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") var manifest: Dictionary = {} var doc: Dictionary = {} # das Map-Dictionary (1:1 svmap-Format) @@ -123,6 +126,9 @@ 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() @@ -355,6 +361,7 @@ func _build_left_panel() -> PanelContainer: 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) @@ -431,6 +438,10 @@ func _build_center_viewport() -> SubViewportContainer: 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() @@ -1029,6 +1040,24 @@ func _do_redo() -> void: _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() @@ -1081,26 +1110,40 @@ func _unhandled_key_input(event: InputEvent) -> void: else: interaction.rotate_selected(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: - if tool_mode == "terrain": - _set_tool_mode("objects") - _set_status("Terrain-Modus beendet — zurück zum Objekt-Werkzeug.") + # Esc-Priorität über pure Funktion (Karte t_c46d31c2, testbar): + # Werkzeug verlassen → Ghost-Preview abbrechen → Auswahl aufheben. + var esc := ShortcutPolicy.esc_action(tool_mode, + interaction.placing, + interaction.selected_index != -1 or not interaction.selected_indices.is_empty()) + 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_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() - elif tool_mode == "spawns": - _set_tool_mode("objects") - _set_status("Spawn-Werkzeug beendet — zurück zum Objekt-Werkzeug.") - get_viewport().set_input_as_handled() - elif tool_mode == "bauen": - _set_tool_mode("objects") - _set_status("Bauen-Modus beendet — zurück zum Objekt-Werkzeug.") - get_viewport().set_input_as_handled() - elif interaction.placing: - interaction.cancel_placement() - _set_status("Platzieren abgebrochen.") - elif interaction.selected_index != -1: - interaction.clear_selection() - _set_status("Auswahl aufgehoben.") - get_viewport().set_input_as_handled() KEY_DELETE: if not ctrl: if interaction.placing: @@ -1512,6 +1555,9 @@ func _shake_button(btn: Control) -> void: ## 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 @@ -1521,14 +1567,27 @@ func _on_tree_selected() -> void: if item.has_meta("gebaeude_index"): _on_tree_selected_gebaeude(int(item.get_meta("gebaeude_index"))) return - if not item.has_meta("obj_index"): + 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 idx := int(item.get_meta("obj_index")) var objects: Array = doc.get("objects", []) - if idx < 0 or idx >= objects.size(): + var valid: Array = [] + for idx in indices: + if idx >= 0 and idx < objects.size(): + valid.append(idx) + if valid.is_empty(): return - interaction.select_object(idx, true) - _set_status("%s ausgewählt — ziehen zum Verschieben, R/Mausrad zum Drehen, Entf zum Löschen." % object_label(str(objects[idx].get("id", "")))) + 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). @@ -1538,22 +1597,46 @@ func _on_tree_selected_gebaeude(gi: int) -> void: build_tool.call("select_index", gi) -## Auswahl (aus dem Viewport) im Baum mitmarkieren. +## 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 - _mark_tree_selection_walk(content_tree.get_root(), idx) + 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, idx: int) -> void: +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: - item.select(0) content_tree.ensure_visible(item) return for child in item.get_children(): - _mark_tree_selection_walk(child, idx) + _ensure_tree_visible(child, idx) ## Rechte Panel-Sektion synchronisieren OHNE Statuszeilen-Text zu ändern diff --git a/src/viewport/orbit_camera.gd b/src/viewport/orbit_camera.gd index 8a37a7a..280851d 100644 --- a/src/viewport/orbit_camera.gd +++ b/src/viewport/orbit_camera.gd @@ -1,13 +1,26 @@ extends Camera3D -## Orbit-Kamera für den Editor-Viewport. -## Steuerung (Maus, keine Tastenkürzel nötig — Chef-Grundsatz): +## Orbit-Kamera + Freelook-Flug für den Editor-Viewport. +## Steuerung: ## Mittlere Maustaste ziehen = Drehen (Orbit) ## Mittlere Maustaste + Shift = Verschieben (Pan, parallel zum Boden) ## Mausrad = Zoom — AUSSER ein Objekt/eine Platzier-Vorschau ## ist aktiv: dann dreht das Mausrad das Objekt ## (interaction.gd meldet das per wheel_filter). +## Rechte Maustaste GEDACKT = Freelook-Flug (Karte t_c46d31c2): Umsehen + +## W vor / S zurück / A links / D rechts, +## E hoch / Q runter, Shift = schneller, +## Alt = langsamer. Mausrad ändert im Flug die +## GRUNDgeschwindigkeit dauerhaft. +## Umschalt+F (main.gd) = Freelook dauerhaft an/aus (Flug bleibt auch +## ohne gedrückte rechte Maustaste aktiv). +## F (main.gd) = Auswahl ins Bild zentrieren → focus_on(). +## Orbit bleibt der Default-Zustand: Beim Verlassen des Flugs werden Orbit- +## Ziel/Winkel/Abstand aus der Flugposition/Blickrichtung zurückgerechnet. +## Die Mathe lebt in src/editor/fly_math.gd (pure, headless testbar). ## Ziel-Punkt, Winkel und Abstand bleiben intern; Position wird jede Frame -## aus ihnen berechnet. +## aus ihnen berechnet (Orbit) bzw. im Flug direkt bewegt. + +const FlyMath := preload("res://src/editor/fly_math.gd") var target := Vector3.ZERO var yaw_deg := 45.0 @@ -17,33 +30,91 @@ var distance := 300.0 ## Von main.gd gesetzt: Callable → true, wenn das Mausrad gerade ein Objekt ## drehen soll (Platzier-Modus oder Auswahl) statt zu zoomen. var wheel_filter := Callable() +## Von main.gd gesetzt: Callable → true, wenn Rechtsklick belegt ist (z. B. +## Platzier-Abbruch) — dann startet der Freelook-Flug nicht. +var rmb_filter := Callable() +## Von main.gd gesetzt: Callable für Klartext-Status-Meldungen. +var status_cb := Callable() + +## Grundgeschwindigkeit des Flugs (Mausrad ändert sie dauerhaft, Karte 1/3). +var base_speed_mps := FlyMath.BASE_SPEED_MPS var _dragging := false var _panning := false +var _fly_held := false # Rechte Maustaste gehalten → Flug temporär aktiv +var _fly_locked := false # Umschalt+F: Flug dauerhaft (auch ohne RMT) +var _flying := false # Ergebnis-Flag: gerade im Flugmodus? +var _fly_yaw := 0.0 +var _fly_pitch := 0.0 func _ready() -> void: _apply() +func _process(delta: float) -> void: + if not _flying or not InputMap.has_action("editor_fly_forward"): + return + var f := Input.get_action_strength("editor_fly_forward") - Input.get_action_strength("editor_fly_back") + var s := Input.get_action_strength("editor_fly_right") - Input.get_action_strength("editor_fly_left") + var v := Input.get_action_strength("editor_fly_up") - Input.get_action_strength("editor_fly_down") + if f == 0.0 and s == 0.0 and v == 0.0: + return + var speed := FlyMath.speed_for(base_speed_mps, + Input.is_action_pressed("editor_fly_fast"), + Input.is_action_pressed("editor_fly_slow")) + var forward := FlyMath.forward_from_euler(_fly_yaw, _fly_pitch) + var right := FlyMath.forward_from_euler(_fly_yaw, 0.0).cross(Vector3.UP).normalized() + position += FlyMath.move_delta(forward, right, f, s, v, speed, delta) + _apply_fly_orientation() + + func _unhandled_input(event: InputEvent) -> void: if event is InputEventMouseButton: var mb := event as InputEventMouseButton - if mb.button_index == MOUSE_BUTTON_MIDDLE: + if mb.button_index == MOUSE_BUTTON_RIGHT: + if mb.pressed: + if _rmb_blocked(): + return + _enter_fly() + _fly_held = true + elif _fly_held: + _fly_held = false + if not _fly_locked: + _exit_fly() + elif mb.button_index == MOUSE_BUTTON_MIDDLE: + if _flying: + return # Im Flug: MMT-Ziehen bleibt unangetastet (kein Orbit-Sprung) _dragging = mb.pressed and not mb.shift_pressed _panning = mb.pressed and mb.shift_pressed elif mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed: + if _flying: + base_speed_mps = FlyMath.wheel_speed(base_speed_mps, true) + _status("Flug-Tempo: %.0f m/s (Grundgeschwindigkeit, bleibt dauerhaft)." % base_speed_mps) + return if _wheel_blocked(): return distance = maxf(5.0, distance * 0.9) _apply() elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed: + if _flying: + base_speed_mps = FlyMath.wheel_speed(base_speed_mps, false) + _status("Flug-Tempo: %.0f m/s (Grundgeschwindigkeit, bleibt dauerhaft)." % base_speed_mps) + return if _wheel_blocked(): return distance = minf(distance * 1.1, distance_max()) _apply() elif event is InputEventMouseMotion: var mm := event as InputEventMouseMotion + if _flying: + if _fly_held: + # Umsehen: volle 360° Yaw, Pitch −85…+85 (auch von unten hoch). + _fly_yaw = wrapf(_fly_yaw - mm.relative.x * 0.25, -180.0, 180.0) + _fly_pitch = clampf(_fly_pitch + mm.relative.y * 0.25, + -FlyMath.PITCH_MAX_DEG, FlyMath.PITCH_MAX_DEG) + _apply_fly_orientation() + return if _dragging: yaw_deg = wrapf(yaw_deg - mm.relative.x * 0.3, -180.0, 180.0) pitch_deg = clampf(pitch_deg + mm.relative.y * 0.3, 5.0, 85.0) @@ -63,12 +134,83 @@ func _wheel_blocked() -> bool: return wheel_filter.is_valid() and bool(wheel_filter.call()) +func _rmb_blocked() -> bool: + return rmb_filter.is_valid() and bool(rmb_filter.call()) + + func distance_max() -> float: return 6000.0 -## Kamera auf die Map zentrieren (nach Laden/Neu). +## Freelook dauerhaft an/aus (Umschalt+F, main.gd ruft an). +func toggle_fly_lock() -> void: + if _flying: + _fly_locked = false + if not _fly_held: + _exit_fly() + _status("Freelook-Flug aus — zurück zum Orbit (Mittlere Maustaste ziehen = drehen).") + else: + _fly_locked = true + _enter_fly() + _status("Freelook-Flug DAUERHAFT an: W/S/A/D fliegen, E/Q hoch/runter, Umschalt schneller, Alt langsamer — Umschalt+F zurück zum Orbit.") + + +## Im Flugmodus? (interaction.gd blockt z. B. Mausrad-Objektdrehung dann ab.) +func fly_active() -> bool: + return _flying + + +## Auswahl ins Bild zentrieren (F): Orbit → Ziel-Punkt auf den Auswahl-Mittelpunkt; +## Flug → Blickrichtung drehen, Position bleibt. +func focus_on(center: Vector3) -> void: + if _flying: + var to := center - position + if to.length_squared() < 0.01: + return + var ep := FlyMath.euler_from_forward(to.normalized()) + _fly_yaw = ep.x + _fly_pitch = clampf(ep.y, -FlyMath.PITCH_MAX_DEG, FlyMath.PITCH_MAX_DEG) + _apply_fly_orientation() + else: + target = center + _apply() + + +func _enter_fly() -> void: + if _flying: + return + _fly_yaw = yaw_deg + _fly_pitch = pitch_deg + _flying = true + _status("Freelook-Flug an: W/S/A/D fliegen, E/Q hoch/runter, Umschalt = schnell, Alt = langsam, Mausrad = Grund-Tempo. Rechte Maustaste loslassen = zurück zum Orbit.") + + +func _exit_fly() -> void: + if not _flying: + return + _flying = false + # Orbit-Zustand aus Flugposition/Blickrichtung zurückrechnen — kein Sprung. + var fwd := -transform.basis.z + if fwd.length_squared() < 0.0000001: + fwd = FlyMath.forward_from_euler(yaw_deg, pitch_deg) + var ep := FlyMath.euler_from_forward(fwd) + yaw_deg = ep.x + pitch_deg = clampf(ep.y, 5.0, 85.0) + distance = clampf(distance, 30.0, distance_max()) + target = position + fwd.normalized() * distance + _apply() + + +func _apply_fly_orientation() -> void: + var fwd := FlyMath.forward_from_euler(_fly_yaw, _fly_pitch) + look_at_from_position(position, position + fwd, Vector3.UP) + + +## Kamera auf die Map zentrieren (nach Laden/Neu) — Flugmodus dabei sauber beenden. func frame_map(size_m: float) -> void: + _fly_held = false + _fly_locked = false + _flying = false target = Vector3.ZERO distance = maxf(80.0, size_m * 0.6) yaw_deg = 45.0 @@ -85,4 +227,9 @@ func _apply() -> void: cos(yaw) * cos(pitch) ) * distance position = target + offset - look_at(target, Vector3.UP) \ No newline at end of file + look_at(target, Vector3.UP) + + +func _status(text: String) -> void: + if status_cb.is_valid(): + status_cb.call(text) \ No newline at end of file diff --git a/tests/test_shortcuts.gd b/tests/test_shortcuts.gd new file mode 100644 index 0000000..ad5ce33 --- /dev/null +++ b/tests/test_shortcuts.gd @@ -0,0 +1,351 @@ +extends SceneTree +## Editor-Tests (headless): Karte t_c46d31c2 — Tastatur-Shortcuts 1/3 +## (Freelook-Flug, Focus, Esc, Entf-Mehrfachauswahl, Strg+D, Strg+LMB). +## Ausführen: godot --headless --path . --script res://tests/test_shortcuts.gd +## Exit 0 = alles grün. Reine Mathe/Policy/Objekt-Tests + ECHTE interaction.gd +## gegen Doppel-Stub (Muster aus test_library.gd — kein UI nötig). + +var failures := 0 +var checks := 0 + + +func _init() -> void: + ShortcutKeys.register() + _test_inputmap_actions() + _test_fly_math() + _test_esc_policy() + _test_duplicate_offset() + # Node-basierte Tests (global_position, look_at) brauchen einen fertigen + # Baum — in _init ist root noch nicht "inside tree" (Gui-Smoke-Muster). + await process_frame + _test_multi_delete() + _test_focus_center() + _test_camera_fly_roundtrip() + if failures == 0: + print("ALL OK: %d checks" % checks) + quit(0) + else: + print("FAILED: %d/%d checks" % [failures, checks]) + quit(1) + + +func _check(cond: bool, label: String) -> void: + checks += 1 + if cond: + print(" ok: %s" % label) + else: + failures += 1 + print(" FAIL: %s" % label) + + +# --------------------------------------------------- Stubs (main / builder) -- + +## Minimales main-Doppel: interaction braucht doc/history/manifest + Status- +## und Sync-Aufrufe. Zählt Commits mit, damit „EIN Undo-Schritt" beweisbar ist. +class ProbeMain: + var doc: Dictionary = {} + var manifest: Dictionary = {} + var grid_snap := false + var dirty := false + var history: ActionHistory = ActionHistory.new() + var statuses: Array = [] + + func object_label(_oid: String) -> String: + return "Objekt" + + func _fmt_int(v: int) -> String: + return str(v) + + func _set_status(text: String, _err := false) -> void: + statuses.append(text) + + func _selection_status(_idx: int) -> void: + pass + + func _sync_selection_panel() -> void: + pass + + func _mark_tree_selection(_idx: int) -> void: + pass + + func _mark_tree_selection_multi(_indices: Array) -> void: + pass + + func _after_structure_change() -> void: + pass + + func _refresh_history_buttons() -> void: + pass + + func _refresh_stats() -> void: + pass + + func _refresh_file_label() -> void: + pass + + +## Builder-Doppel: interaction setzt nur set_selected_multi / liest Marker. +class ProbeBuilder extends Node3D: + var object_markers: Array = [] + var last_selection: Array = [] + + func set_selected_multi(indices: Array) -> void: + last_selection = indices.duplicate() + + +# ------------------------------------------------------------ InputMap ------- + +func _test_inputmap_actions() -> void: + print("Testgruppe: InputMap-Actions (ShortcutKeys)") + # Idempotenz: doppeltes Registrieren darf keine Doppel-Events erzeugen. + ShortcutKeys.register() + var fwd_count := 0 + for ev in InputMap.action_get_events("editor_fly_forward"): + fwd_count += 1 + _check(fwd_count == 1, "register() ist idempotent (keine Doppel-Events)") + var expected_keys := { + "editor_fly_forward": KEY_W, + "editor_fly_back": KEY_S, + "editor_fly_left": KEY_A, + "editor_fly_right": KEY_D, + "editor_fly_up": KEY_E, + "editor_fly_down": KEY_Q, + "editor_fly_fast": KEY_SHIFT, + "editor_fly_slow": KEY_ALT, + "editor_focus_selection": KEY_F, + "editor_cancel": KEY_ESCAPE, + "editor_delete_selection": KEY_DELETE, + } + for action in expected_keys: + if not InputMap.has_action(action): + _check(false, "Action existiert: %s" % action) + continue + var ok := false + for ev in InputMap.action_get_events(action): + if ev is InputEventKey and (ev as InputEventKey).physical_keycode == int(expected_keys[action]): + ok = true + _check(ok, "%s → physische Taste %s" % [action, OS.get_keycode_string(int(expected_keys[action]))]) + # Modifier-Kombos + var toggle_shift := false + for ev in InputMap.action_get_events("editor_freelook_toggle"): + if ev is InputEventKey and (ev as InputEventKey).physical_keycode == KEY_F \ + and (ev as InputEventKey).shift_pressed: + toggle_shift = true + _check(toggle_shift, "editor_freelook_toggle → Umschalt+F") + var dup_ctrl := false + for ev in InputMap.action_get_events("editor_duplicate_selection"): + if ev is InputEventKey and (ev as InputEventKey).physical_keycode == KEY_D \ + and (ev as InputEventKey).ctrl_pressed: + dup_ctrl = true + _check(dup_ctrl, "editor_duplicate_selection → Strg+D") + var multi := false + for ev in InputMap.action_get_events("editor_multi_select"): + if ev is InputEventMouseButton and (ev as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT \ + and (ev as InputEventMouseButton).ctrl_pressed: + multi = true + _check(multi, "editor_multi_select → Strg+LMB") + var freelook_rmb := false + for ev in InputMap.action_get_events("editor_freelook"): + if ev is InputEventMouseButton and (ev as InputEventMouseButton).button_index == MOUSE_BUTTON_RIGHT: + freelook_rmb = true + _check(freelook_rmb, "editor_freelook → Rechte Maustaste") + + +# ------------------------------------------------------------ FlyMath -------- + +func _test_fly_math() -> void: + print("Testgruppe: Freelook-Mathe (FlyMath)") + _check(absf(FlyMath.speed_for(30.0, false, false) - 30.0) < 0.0001, "Tempo normal = Grund") + _check(absf(FlyMath.speed_for(30.0, true, false) - 90.0) < 0.0001, "Umschalt = ×3") + _check(absf(FlyMath.speed_for(30.0, false, true) - 9.0) < 0.0001, "Alt = ×0,3") + _check(absf(FlyMath.speed_for(30.0, true, true) - 90.0) < 0.0001, "Umschalt dominiert bei beiden") + _check(absf(FlyMath.wheel_speed(30.0, true) - 34.5) < 0.0001, "Mausrad hoch: ×1,15 dauerhaft") + _check(absf(FlyMath.wheel_speed(30.0, false) - 30.0 / 1.15) < 0.0001, "Mausrad runter: ÷1,15 dauerhaft") + _check(FlyMath.wheel_speed(299.0, true) == FlyMath.SPEED_MAX_MPS, "Mausrad: Tempo-Grenze oben (300 m/s)") + _check(FlyMath.wheel_speed(2.0, false) == FlyMath.SPEED_MIN_MPS, "Mausrad: Tempo-Grenze unten (2 m/s)") + # Bewegung: W fliegt in Blickrichtung (3D), A/D horizontal, E/Q weltachsig. + var fwd := FlyMath.forward_from_euler(0.0, 30.0) # Blick nach -Z, leicht abwärts + var right := FlyMath.forward_from_euler(0.0, 0.0).cross(Vector3.UP).normalized() + var d := FlyMath.move_delta(fwd, right, 1.0, 0.0, 0.0, 10.0, 1.0) + _check(d.length() < 10.0001 and absf(d.y) > 0.1, "W fliegt 3D in Blickrichtung (mit Pitch-Anteil)") + var side := FlyMath.move_delta(fwd, right, 0.0, 1.0, 0.0, 10.0, 1.0) + _check(absf(side.y) < 0.0001 and absf(side.x - 10.0) < 0.0001, "D straft horizontal rechts (kein Höhen-Anteil)") + var up := FlyMath.move_delta(fwd, right, 0.0, 0.0, 1.0, 10.0, 1.0) + _check(absf((up - Vector3.UP * 10.0).length()) < 0.0001, "E hebt weltachsig (+10 m)") + var diag := FlyMath.move_delta(fwd, right, 1.0, 1.0, 0.0, 10.0, 1.0) + _check(absf(diag.length() - 10.0) < 0.0001, "Diagonale normalisiert (√2-Falle umgangen)") + _check(FlyMath.move_delta(fwd, right, 0.0, 0.0, 0.0, 10.0, 1.0) == Vector3.ZERO, "keine Eingabe → kein Delta") + # Euler ↔ Forward-Rundtrip (Orbit-Konvention: pitch + = von oben). + var ep := FlyMath.euler_from_forward(FlyMath.forward_from_euler(45.0, 35.0)) + _check(absf(ep.x - 45.0) < 0.0001 and absf(ep.y - 35.0) < 0.0001, "Euler↔Forward-Rundtrip (45°/35°)") + var down := FlyMath.euler_from_forward(Vector3(0.0, -1.0, 0.0)) + _check(absf(down.y - 90.0) < 0.0001, "Blick senkrecht nach unten → pitch +90 (Orbit-Clamp übernimmt)") + var flat := FlyMath.forward_from_euler(0.0, 0.0) + _check(absf((flat - Vector3(0.0, 0.0, -1.0)).length()) < 0.0001, "yaw 0/pitch 0 → Blick -Z") + + +# ------------------------------------------------------------ Esc-Policy ----- + +func _test_esc_policy() -> void: + print("Testgruppe: Esc-Zustandswechsel (ShortcutPolicy)") + _check(ShortcutPolicy.esc_action("terrain", false, false) == "exit_terrain", "Esc im Terrain-Werkzeug → Werkzeug verlassen") + _check(ShortcutPolicy.esc_action("spawns", false, false) == "exit_spawns", "Esc im Spawn-Werkzeug → Werkzeug verlassen") + _check(ShortcutPolicy.esc_action("bauen", false, false) == "exit_bauen", "Esc im Bauen-Modus → Werkzeug verlassen") + _check(ShortcutPolicy.esc_action("bauen", true, true) == "exit_bauen", "Werkzeug hat VORRANG vor Platzieren") + _check(ShortcutPolicy.esc_action("objects", true, true) == "cancel_placement", "Esc beim Platzieren → Ghost-Preview abbrechen") + _check(ShortcutPolicy.esc_action("objects", false, true) == "clear_selection", "Esc mit Auswahl → Auswahl aufheben") + _check(ShortcutPolicy.esc_action("objects", false, false) == "none", "Esc ohne nichts → kein Zustandswechsel") + + +# ------------------------------------------------------ Duplizieren (+2 m) --- + +func _test_duplicate_offset() -> void: + print("Testgruppe: Strg+D — Duplizieren +2 m X, Füßpunkt unverändert") + var doc := {"objects": [ + {"id": "crate_wood", "pos": [10.0, 5.0, 10.0], "scale": 1.0, "rot_y_deg": 30.0}, + {"id": "tree_pine", "pos": [30.0, 3.0, 20.0], "scale": 1.0, "rot_y_deg": 0.0}, + ]} + var hist := ActionHistory.new() + hist.begin(doc) + var before := hist.snapshot(["objects"]) + var ni: Array = ObjectOps.duplicate_objects(doc, [0, 1], false) + _check(ni == [2, 3], "Mehrfach-Duplizieren: neue Indizes in Quell-Reihenfolge") + var objects: Array = doc["objects"] + _check(float(objects[2]["pos"][0]) == 12.0 and float(objects[2]["pos"][2]) == 10.0, + "Kopie 1: +2 m in X, Z unverändert") + _check(float(objects[2]["pos"][1]) == 5.0, "Kopie 1: Füßpunkt-Y unverändert") + _check(absf(float(objects[2]["rot_y_deg"]) - 30.0) < 0.0001, "Kopie 1: Drehung übernommen") + _check(float(objects[3]["pos"][0]) == 32.0 and float(objects[3]["pos"][1]) == 3.0, + "Kopie 2: +2 m in X, Höhe unverändert") + # Raster-Snap: 11,6 rastet auf 12,2 m? Nein: auf ganze Meter → 14 (11.6+2=13.6→14). + objects[0]["pos"] = [11.6, 2.0, 9.2] + var idx := ObjectOps.duplicate_object(doc, 0, true) + _check(float(objects[idx]["pos"][0]) == 14.0 and float(objects[idx]["pos"][2]) == 9.0, + "Raster-Snap an: Kopie rastet auf ganze Meter (nach +2 m)") + # EIN Undo-Schritt für ALLE Kopien: + hist.commit("3 Objekte duplizieren", before, hist.snapshot(["objects"])) + hist.undo() + _check((doc["objects"] as Array).size() == 2, "Strg+Z: ALLE Kopien auf einmal weg") + hist.redo() + _check((doc["objects"] as Array).size() == 5, "Strg+Y: ALLE Kopien auf einmal zurück") + + +# ---------------------------------------------------- Del-Mehrfachauswahl ---- + +func _test_multi_delete() -> void: + print("Testgruppe: Entf auf Mehrfachauswahl (echte interaction.gd)") + var fake := ProbeMain.new() + fake.doc = {"objects": [ + {"id": "crate_wood", "pos": [0.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0}, + {"id": "tree_pine", "pos": [10.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0}, + {"id": "crate_wood", "pos": [20.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0}, + {"id": "tree_pine", "pos": [30.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0}, + ]} + fake.history.begin(fake.doc) + var vp := SubViewport.new() + vp.size = Vector2i(1280, 720) + root.add_child(vp) + var cam := Camera3D.new() + vp.add_child(cam) + cam.current = true + var builder := ProbeBuilder.new() + vp.add_child(builder) + var interaction := Node3D.new() + interaction.set_script(load("res://src/editor/interaction.gd")) + vp.add_child(interaction) + interaction.setup(fake, cam, builder) + + # Strg+Klick-Mehrfachauswahl-Semantik (select_toggle = add/remove): + interaction.select_object(0) + interaction.select_toggle(2) + _check(interaction.get("selected_indices") == [0, 2], "Umschalt/Strg+Klick: Objekt zur Auswahl hinzugefügt") + interaction.select_toggle(2) + _check(interaction.get("selected_indices") == [0], "Umschalt/Strg+Klick: wieder entfernt") + interaction.select_toggle(2) + _check(interaction.get("selected_indices") == [0, 2], "Mehrfachauswahl [0, 2] steht") + + # Entf: beide gelöscht, EIN Undo-Schritt holt beide zurück. + interaction.delete_selected() + var objects: Array = fake.doc["objects"] + _check(objects.size() == 2, "Entf: beide ausgewählten Objekte weg (2 bleiben)") + _check(str(objects[0]["id"]) == "tree_pine" and str(objects[1]["id"]) == "tree_pine", + "Entf: die RICHTIGEN Objekte (A und C) sind weg") + _check(interaction.get("selected_indices").is_empty(), "Entf: Auswahl geleert") + _check(builder.last_selection.is_empty(), "Entf: auch Viewport-Markierung geleert") + _check(fake.history.can_undo(), "Entf: genau EIN History-Eintrag") + fake.history.undo() + _check((fake.doc["objects"] as Array).size() == 4, "Strg+Z: beide Objekte auf einmal zurück") + + # Entf ohne Auswahl: hilfreiche Meldung, kein Crash, keine History-Änderung. + var can_undo_before := fake.history.can_undo() + var status_before: int = fake.statuses.size() + interaction.clear_selection(true) + interaction.delete_selected() + _check(fake.statuses.size() > status_before, "Entf ohne Auswahl: hilfreiche Meldung statt Stille") + _check(fake.history.can_undo() == can_undo_before, "Entf ohne Auswahl: nichts committet") + + # Mausrad-Block im Flugmodus: wheel_blocked meldet Kamera-Vorrang. + var orbit := Camera3D.new() + orbit.set_script(load("res://src/viewport/orbit_camera.gd")) + vp.add_child(orbit) + interaction.set("camera", orbit) + _check(not bool(interaction.call("wheel_blocked")), "Orbit + Auswahl: Mausrad dreht Objekt (normal)") + orbit.set("_flying", true) + _check(bool(interaction.call("wheel_blocked")), "Freelook-Flug: Mausrad gehört dem Flug-Tempo") + + +# ------------------------------------------------------------ Focus (F) ------ + +func _test_focus_center() -> void: + print("Testgruppe: F — Focus-Selection-Mittelpunkt (SelectionMath)") + var m1 := Node3D.new() + m1.position = Vector3(10.0, 0.0, 0.0) + m1.set_meta("half_height", 2.0) + var m2 := Node3D.new() + m2.position = Vector3(30.0, 4.0, 20.0) + m2.set_meta("half_height", 2.0) + root.add_child(m1) + root.add_child(m2) + var center := SelectionMath.focus_center([m1, m2], [0, 1]) + # Anker + halbe Mesh-Höhe: m1 → (10, 1, 0), m2 → (30, 5, 20); Mittel = (20, 3, 10). + _check((center - Vector3(20.0, 3.0, 10.0)).length() < 0.0001, + "Mittelpunkt = Mittel der Anker (+ halbe Mesh-Höhe)") + _check(SelectionMath.focus_center([m1, m2], [5, -1]) == Vector3.ZERO, + "ungültige Indizes → Ursprung (Guard sitzt beim Aufrufer)") + _check(SelectionMath.focus_center([null, m2], [0, 1]) == (m2.global_position \ + + Vector3.UP * 1.0), "kaputter Marker wird übersprungen") + + +# ------------------------------------------------- Kamera Flug-Roundtrip ----- + +func _test_camera_fly_roundtrip() -> void: + print("Testgruppe: Freelook-Flug an/aus (orbit_camera, kein Positions-Sprung)") + var cam := Camera3D.new() + cam.set_script(load("res://src/viewport/orbit_camera.gd")) + root.add_child(cam) + cam.set("target", Vector3.ZERO) + cam.set("distance", 100.0) + cam.set("yaw_deg", 30.0) + cam.set("pitch_deg", 40.0) + cam.call("_apply") + var pos_before: Vector3 = cam.position + _check(not bool(cam.call("fly_active")), "Orbit-Modus ist Default (kein Flug)") + cam.call("_enter_fly") + _check(bool(cam.call("fly_active")), "RMT gedrückt → Flugmodus aktiv") + cam.position += Vector3(25.0, -10.0, 5.0) # im Flug frei bewegt + cam.call("_exit_fly") + _check(not bool(cam.call("fly_active")), "RMT losgelassen → zurück zum Orbit") + var pos_after: Vector3 = cam.position + # Orbit-Ziel wurde aus der Flugposition zurückgerechnet — die KAMERA bleibt, + # nur Ziel/Winkel ändern sich: kein Teleport beim Umschalten. + _check((pos_after - (pos_before + Vector3(25.0, -10.0, 5.0))).length() < 0.01, + "Flug-Exit: Position bleibt (kein Sprung), Ziel wird zurückgerechnet") + # Focus in Orbit-Mode: Ziel wandert auf den Mittelpunkt. + cam.call("focus_on", Vector3(50.0, 2.0, -20.0)) + _check((cam.get("target") as Vector3 - Vector3(50.0, 2.0, -20.0)).length() < 0.0001, + "focus_on: Orbit-Ziel = Auswahl-Mittelpunkt") + # Umschalt+F-Verriegelung: Dauerflug an → aus. + cam.call("toggle_fly_lock") + _check(bool(cam.call("fly_active")), "Umschalt+F: Dauerflug an (ohne RMT)") + cam.call("toggle_fly_lock") + _check(not bool(cam.call("fly_active")), "Umschalt+F nochmal: zurück zum Orbit") \ No newline at end of file diff --git a/tests/test_shortcuts.gd.uid b/tests/test_shortcuts.gd.uid new file mode 100644 index 0000000..396ac37 --- /dev/null +++ b/tests/test_shortcuts.gd.uid @@ -0,0 +1 @@ +uid://cddsyesnw8bg2 diff --git a/tests/test_ux.gd b/tests/test_ux.gd index 7eed62c..968b3ec 100644 --- a/tests/test_ux.gd +++ b/tests/test_ux.gd @@ -256,22 +256,22 @@ func _test_object_ops() -> void: hist.begin(doc) var objects: Array = doc["objects"] - # --- Duplizieren:stelle diagonal versetzt, Raster-Snap, Undo fähig --- + # --- Duplizieren: +2 m X versetzt (Rasterversatz t_c46d31c2), Undo fähig --- _place(hist, doc, "crate_wood", [10.0, 0.0, 10.0]) objects[0]["rot_y_deg"] = 30.0 var new_idx := ObjectOps.duplicate_object(doc, 0, true) _check(new_idx == 1, "duplizieren: neuer Index 1") _check(objects.size() == 2, "duplizieren: 1 → 2 Objekte") _check(str(objects[1]["id"]) == "crate_wood", "duplizieren: gleiche ID") - _check(float(objects[1]["pos"][0]) == 11.0 and float(objects[1]["pos"][2]) == 11.0, - "duplizieren: diagonal +1 m versetzt (Raster-Snap 1 m)") + _check(float(objects[1]["pos"][0]) == 12.0 and float(objects[1]["pos"][2]) == 10.0, + "duplizieren: +2 m in X versetzt (Rasterversatz), Z bleibt") _check(absf(float(objects[1]["rot_y_deg"]) - 30.0) < 0.0001, "duplizieren: Drehung übernommen") _check(float(objects[1]["pos"][1]) == float(objects[0]["pos"][1]), "duplizieren: Höhe übernommen") - # Kopie ohne Raster: Snap aus → exakt +1/+1 auch auf 0,5-m-Fraktionen. + # Kopie ohne Raster: Snap aus → exakt +2 m in X auch auf 0,5-m-Fraktionen. objects[0]["pos"] = [10.4, 0.0, 10.4] var idx2 := ObjectOps.duplicate_object(doc, 0, false) - _check(absf(float(objects[idx2]["pos"][0]) - 11.4) < 0.0001, "duplizieren ohne Snap: Fraktion bleibt (+1.0)") + _check(absf(float(objects[idx2]["pos"][0]) - 12.4) < 0.0001, "duplizieren ohne Snap: Fraktion bleibt (+2.0 in X)") # Fraktionsobjekt wieder aufräumen für die History-Tests: objects.remove_at(idx2) @@ -284,7 +284,7 @@ func _test_object_ops() -> void: _check(objects.size() == 2, "duplizieren→undo: Kopie weg (2 bleiben)") hist.redo() _check(objects.size() == 3, "duplizieren→undo→redo: Kopie wieder da") - _check(absf(float(objects[2]["pos"][0]) - 11.0) < 0.0001, "duplizieren→redo: Position identisch") + _check(absf(float(objects[2]["pos"][0]) - 12.0) < 0.0001, "duplizieren→redo: Position identisch") # Ungültige Indizes schaden nichts: _check(ObjectOps.duplicate_object(doc, 99, true) == -1, "duplizieren: falscher Index → -1") diff --git a/tools/parse_check_shortcuts.sh b/tools/parse_check_shortcuts.sh new file mode 100644 index 0000000..56e130a --- /dev/null +++ b/tools/parse_check_shortcuts.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Parse-Check der Shortcuts-1-Karte (t_c46d31c2): alle geänderten/neuen Scripts. +cd "$(dirname "$0")/.." +GODOT=~/bin/godot4 +fail=0 +for f in src/ui/main.gd src/viewport/orbit_camera.gd src/editor/shortcut_keys.gd \ + src/editor/fly_math.gd src/editor/shortcut_policy.gd \ + src/editor/selection_math.gd src/editor/interaction.gd src/editor/object_ops.gd \ + tests/test_shortcuts.gd tests/test_ux.gd; do + out=$("$GODOT" --headless --path . --check-only --script "res://$f" 2>&1 | grep -iv "godot engine\|^$" || true) + if [ -n "$out" ]; then + echo "FAIL $f:" + echo "$out" + fail=1 + else + echo "OK $f" + fi +done +exit $fail \ No newline at end of file diff --git a/tools/run_tests.sh b/tools/run_tests.sh index e9ffa7e..d04a075 100755 --- a/tools/run_tests.sh +++ b/tools/run_tests.sh @@ -3,7 +3,7 @@ cd "$(dirname "$0")/.." GODOT=~/bin/godot4 rc=0 -for t in test_ux test_editor_svmap test_terrain test_library test_spawns test_testrun test_bauen; do +for t in test_ux test_shortcuts test_editor_svmap test_terrain test_library test_spawns test_testrun test_bauen; do "$GODOT" --headless --path . --script "res://tests/$t.gd" > "/tmp/$t.log" 2>&1 code=$? line=$(grep -E "ALL OK|FAILED" "/tmp/$t.log" | tail -1)