Shortcuts 1/3 (t_c46d31c2): Freelook-Flug + F-Focus + Esc-Policy + Entf-Mehrfachauswahl + Strg+D (+2 m X) + Strg+LMB-Mehrfachauswahl.

- 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
This commit is contained in:
Hermes (godot-vr) 2026-10-16 17:43:17 +00:00
parent b20dff2b5d
commit 095af5ff3c
18 changed files with 1026 additions and 79 deletions

View file

@ -170,9 +170,11 @@ aufgebaut, nach dem Chef-Grundsatz (Klick statt Konsole, deutsche UI,
Undo für alles): Undo für alles):
- **Duplizieren (`⧉ Duplizieren`-Button + Strg+D):** Die Kopie des - **Duplizieren (`⧉ Duplizieren`-Button + Strg+D):** Die Kopie des
ausgewählten Objekts hängt diagonal 1 m versetzt daneben (mit Raster-Snap ausgewählten Objekts hängt +2 m in X versetzt daneben (Rasterversatz,
eingerastet, wenn an) und ist DIREKT angewählt — sie lässt sich gleich 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. 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 Poly-Budget und Objekt-Limit werden VOR dem Kopieren geprüft
(Klartext-Meldung, was zu tun ist). EIN Undo-Schritt. (Klartext-Meldung, was zu tun ist). EIN Undo-Schritt.
- **Anheben/Absenken (`▲ Anheben / ▼ Absenken` + Bild↑/Bild↓):** Objekte - **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, ungültige Map blockiert, Client-Suche inkl. Konfig-Vorrang, Godot-Findung,
exakte Argumentliste). Parse-Check umfasst `test_run.gd`. 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`).

69
docs/tasten.md Normal file
View file

@ -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.)*

70
src/editor/fly_math.gd Normal file
View file

@ -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))

View file

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

View file

@ -5,8 +5,9 @@ extends Node3D
## Cursor, Klick setzt das Objekt (kein "überraschtes" Ergebnis). ## Cursor, Klick setzt das Objekt (kein "überraschtes" Ergebnis).
## - Auswahl: angeklickte Objekte färben sich orange, Ziehen verschiebt, ## - Auswahl: angeklickte Objekte färben sich orange, Ziehen verschiebt,
## R-Taste ODER Mausrad dreht (15°-Raster), Entf löscht. ## R-Taste ODER Mausrad dreht (15°-Raster), Entf löscht.
## - Mehrfachauswahl (Karte t_f6f29d86): Umschalt+Klick fügt hinzu/entfernt, ## - Mehrfachauswahl (Karte t_f6f29d86, erweitert t_c46d31c2): Umschalt+Klick
## Rahmen-Aufziehen über leeren Boden wählt alles im Rahmen. ## 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 ## - Gruppen: Objekte mit gleicher "group"-ID ziehen/drehen gemeinsam
## (GroupOps), Gruppieren/Lösen über main-Buttons (Strg+G / Strg+Umschalt+G). ## (GroupOps), Gruppieren/Lösen über main-Buttons (Strg+G / Strg+Umschalt+G).
## - Undo: Jede Aktion = EINE ActionHistory-Stufe (auch Gruppen-Ziehen). ## - 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 ## 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: func wheel_blocked() -> bool:
if _camera_flying():
return true
return placing or selected_index >= 0 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, ## Höhen-Cache auffrischen (Karte t_4092e3a0): NACH Terrain-Edits, Undo/Redo,
## Laden/Neu — main ruft das in _after_structure_change. Ein Decode pro ## Laden/Neu — main ruft das in _after_structure_change. Ein Decode pro
## Änderung statt pro Mausbewegung (129×129-Quantisierung bleibt billig). ## Änderung statt pro Mausbewegung (129×129-Quantisierung bleibt billig).
@ -317,11 +326,11 @@ func select_toggle(idx: int) -> void:
selected_indices.erase(idx) selected_indices.erase(idx)
if selected_index == idx: if selected_index == idx:
selected_index = selected_indices.back() if not selected_indices.is_empty() else -1 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: else:
selected_indices.append(idx) selected_indices.append(idx)
selected_index = 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() _apply_selection_view()
main.call("_sync_selection_panel") 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. ## Auswahl-Anzeige (Viewport-Färbung + Baum + Panel) aus dem aktuellen Stand.
func _apply_selection_view() -> void: func _apply_selection_view() -> void:
view_builder.call("set_selected_multi", selected_indices) 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: if selected_index >= 0:
main.call("_mark_tree_selection", selected_index) 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 + ## (Karte t_7567a06f — alle drei auch bei Sly bedienbar: Button rechts +
## Tastenkürzel, alles über ActionHistory, Klartext-Status-Hilfe.) ## Tastenkürzel, alles über ActionHistory, Klartext-Status-Hilfe.)
## Strg+D / Button "Duplizieren": Kopie diagonal leicht versetzt (mit ## Strg+D / Button "Duplizieren" (Karte t_c46d31c2): JEDE Kopie rastet +2 m in
## Raster-Snap eingerastet, wenn an), direkt angewählt, EIN Undo-Schritt. ## X versetzt ein (Füßpunkt-Höhe unverändert, mit Raster-Snap eingerastet, wenn
## Poly-Budget und Objekt-Limit werden VOR dem Kopieren geprüft. ## 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: 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) main.call("_set_status", "Nichts ausgewählt — klicke zuerst ein Objekt an, das du kopieren willst.", true)
return return
var objects: Array = main.doc.get("objects", []) if objects.size() + sources.size() > SvmapIO.MAX_OBJECTS:
if selected_index >= objects.size():
clear_selection(true)
return
if objects.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) 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 return
var obj: Dictionary = objects[selected_index]
var oid := str(obj.get("id", ""))
var lib: Dictionary = main.manifest.get("objects", {}) var lib: Dictionary = main.manifest.get("objects", {})
var entry: Dictionary = lib.get(oid, {}) var poly_sum := 0.0
var scl := float(obj.get("scale", 1.0)) for i in sources:
var poly := float(entry.get("poly", 0)) * scl * scl 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 used := SvmapDocument.poly_usage(main.doc, main.manifest)
var budget := float(SvmapIO.MAX_TOTAL_POLY) var budget := float(SvmapIO.MAX_TOTAL_POLY)
if used + poly > budget: if used + poly_sum > 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." % [ 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) main._fmt_int(int(used)), main._fmt_int(int(budget))], true)
return return
var before: Dictionary = main.history.snapshot(["objects"]) var before: Dictionary = main.history.snapshot(["objects"])
var new_index: int = ObjectOps.duplicate_object(main.doc, selected_index, bool(main.get("grid_snap"))) var new_indices: Array = ObjectOps.duplicate_objects(main.doc, sources, bool(main.get("grid_snap")))
if new_index < 0: if new_indices.is_empty():
clear_selection(true) clear_selection(true)
return return
main.history.commit("%s duplizieren" % main.object_label(oid), var label: String
before, main.history.snapshot(["objects"])) 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") main.call("_after_structure_change")
# Kopie direkt anwählen — Sly kann sie gleich weiterziehen. # Kopien direkt anwählen — Sly kann sie gleich weiterziehen.
select_object(new_index) select_many(new_indices)
var objects2: Array = main.doc.get("objects", []) 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)
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)
## ▲/▼ / Strg+Bild↑/↓: Objekt in 0,25-m-Schritten anheben/absenken. ## ▲/▼ / 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.") main.call("_set_status", "Platzieren abgebrochen.")
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
elif mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed: 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: if placing:
place_rot_deg = fmod(place_rot_deg + ROT_STEP_DEG + 3600.0, 360.0) place_rot_deg = fmod(place_rot_deg + ROT_STEP_DEG + 3600.0, 360.0)
_ghost.rotation.y = deg_to_rad(place_rot_deg) _ghost.rotation.y = deg_to_rad(place_rot_deg)
@ -633,6 +654,8 @@ func _unhandled_input(event: InputEvent) -> void:
rotate_selected(ROT_STEP_DEG) rotate_selected(ROT_STEP_DEG)
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed: 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: if placing:
place_rot_deg = fmod(place_rot_deg - ROT_STEP_DEG + 3600.0, 360.0) place_rot_deg = fmod(place_rot_deg - ROT_STEP_DEG + 3600.0, 360.0)
_ghost.rotation.y = deg_to_rad(place_rot_deg) _ghost.rotation.y = deg_to_rad(place_rot_deg)
@ -649,8 +672,9 @@ func _on_left_press(mb: InputEventMouseButton) -> void:
_press_pos = mb.position _press_pos = mb.position
var hit := _pick_object(mb.position) var hit := _pick_object(mb.position)
if hit >= 0: if hit >= 0:
if mb.shift_pressed: if mb.shift_pressed or mb.is_command_or_control_pressed():
# Umschalt+Klick: zur Auswahl hinzufügen/entfernen — kein Ziehen. # Umschalt+Klick / Strg+Klick (Karte t_c46d31c2): zur Auswahl
# hinzufügen/entfernen — kein Ziehen.
select_toggle(hit) select_toggle(hit)
return return
if not selected_indices.has(hit): if not selected_indices.has(hit):

View file

@ -7,7 +7,8 @@ class_name ObjectOps
const GRID_M := 1.0 # Raster-Snap-Weite (1 m — handhabbar für Objekte) 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 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 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 ## 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. # 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 ## Duplizieren: Kopie von objects[index] ans ENDE der Liste hängen. Die Kopie
## ist diagonal um DUPLICATE_OFFSET_M versetzt (mit Raster-Snap eingerastet, ## ist um DUPLICATE_OFFSET_M versetzt (+2 m in X, Rasterversatz Karte
## wenn an) und behält Drehung/Größe/Höhe. Rückgabe: neuer Index (-1 = ungültig). ## t_c46d31c2; mit Raster-Snap eingerastet, wenn an) und behält Drehung/Größe/
## VOR dem Aufruf prüft die UI Budget/Objekt-Limit (hilfs-Meldung statt Fehler). ## 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: static func duplicate_object(doc: Dictionary, index: int, grid_enabled: bool) -> int:
var objects: Array = doc.get("objects", []) var objects: Array = doc.get("objects", [])
if index < 0 or index >= objects.size(): 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 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). ## 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 ## 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 — ## (das svmap-Format erlaubt freie y-Werte innerhalb der Match-Grenzen, V9 —

View file

@ -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: static func rect_from(a: Vector2, b: Vector2) -> Rect2:
return Rect2(Vector2(minf(a.x, b.x), minf(a.y, b.y)), return Rect2(Vector2(minf(a.x, b.x), minf(a.y, b.y)),
Vector2(absf(b.x - a.x), absf(b.y - a.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)

View file

@ -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

View file

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

View file

@ -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"

View file

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

View file

@ -16,6 +16,9 @@ extends Control
## 5. Deutsch & groß: Schrift ≥ 14 px, klickbare Flächen ≥ 40 px Höhe. ## 5. Deutsch & groß: Schrift ≥ 14 px, klickbare Flächen ≥ 40 px Höhe.
const APP_TITLE := "ScopedVR Map-Editor" 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 manifest: Dictionary = {}
var doc: Dictionary = {} # das Map-Dictionary (1:1 svmap-Format) 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: 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() manifest = _load_manifest()
_build_ui() _build_ui()
_start_with_new_map() _start_with_new_map()
@ -355,6 +361,7 @@ func _build_left_panel() -> PanelContainer:
content_tree.size_flags_vertical = Control.SIZE_EXPAND_FILL content_tree.size_flags_vertical = Control.SIZE_EXPAND_FILL
content_tree.custom_minimum_size = Vector2(0, 160) content_tree.custom_minimum_size = Vector2(0, 160)
content_tree.allow_reselect = true 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.hide_root = true
content_tree.item_selected.connect(_on_tree_selected) content_tree.item_selected.connect(_on_tree_selected)
vbox.add_child(content_tree) vbox.add_child(content_tree)
@ -431,6 +438,10 @@ func _build_center_viewport() -> SubViewportContainer:
world.add_child(interaction) world.add_child(interaction)
interaction.setup(self, camera, view_builder) interaction.setup(self, camera, view_builder)
camera.set("wheel_filter", Callable(interaction, "wheel_blocked")) 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-Pinsel (Karte t_9aba04f1) — eigener Eingabe-Knoten im Weltraum.
terrain_tool = Node3D.new() terrain_tool = Node3D.new()
@ -1029,6 +1040,24 @@ func _do_redo() -> void:
_set_status("Wiederhergestellt: %s." % desc) _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: func _refresh_history_buttons() -> void:
undo_btn.disabled = not history.can_undo() undo_btn.disabled = not history.can_undo()
redo_btn.disabled = not history.can_redo() redo_btn.disabled = not history.can_redo()
@ -1081,25 +1110,39 @@ func _unhandled_key_input(event: InputEvent) -> void:
else: else:
interaction.rotate_selected(delta) interaction.rotate_selected(delta)
get_viewport().set_input_as_handled() 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: KEY_ESCAPE:
if tool_mode == "terrain": # 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_tool_mode("objects")
_set_status("Terrain-Modus beendet — zurück zum Objekt-Werkzeug.") _set_status("Terrain-Modus beendet — zurück zum Objekt-Werkzeug.")
get_viewport().set_input_as_handled() "exit_spawns":
elif tool_mode == "spawns":
_set_tool_mode("objects") _set_tool_mode("objects")
_set_status("Spawn-Werkzeug beendet — zurück zum Objekt-Werkzeug.") _set_status("Spawn-Werkzeug beendet — zurück zum Objekt-Werkzeug.")
get_viewport().set_input_as_handled() "exit_bauen":
elif tool_mode == "bauen":
_set_tool_mode("objects") _set_tool_mode("objects")
_set_status("Bauen-Modus beendet — zurück zum Objekt-Werkzeug.") _set_status("Bauen-Modus beendet — zurück zum Objekt-Werkzeug.")
get_viewport().set_input_as_handled() "cancel_placement":
elif interaction.placing:
interaction.cancel_placement() interaction.cancel_placement()
_set_status("Platzieren abgebrochen.") _set_status("Platzieren abgebrochen.")
elif interaction.selected_index != -1: "clear_selection":
interaction.clear_selection() interaction.clear_selection()
_set_status("Auswahl aufgehoben.") _set_status("Auswahl aufgehoben.")
if esc != "none":
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
KEY_DELETE: KEY_DELETE:
if not ctrl: if not ctrl:
@ -1512,6 +1555,9 @@ func _shake_button(btn: Control) -> void:
## Der Baum ruft zurück, wenn der Nutzer dort ein Objekt auswählt. ## 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: func _on_tree_selected() -> void:
if _tree_select_lock: if _tree_select_lock:
return return
@ -1521,14 +1567,27 @@ func _on_tree_selected() -> void:
if item.has_meta("gebaeude_index"): if item.has_meta("gebaeude_index"):
_on_tree_selected_gebaeude(int(item.get_meta("gebaeude_index"))) _on_tree_selected_gebaeude(int(item.get_meta("gebaeude_index")))
return 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 return
var idx := int(item.get_meta("obj_index"))
var objects: Array = doc.get("objects", []) 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 return
interaction.select_object(idx, true) if valid.size() == 1:
_set_status("%s ausgewählt — ziehen zum Verschieben, R/Mausrad zum Drehen, Entf zum Löschen." % object_label(str(objects[idx].get("id", "")))) 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). ## 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) 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: func _mark_tree_selection(idx: int) -> void:
_mark_tree_selection_multi([idx])
func _mark_tree_selection_multi(indices: Array) -> void:
_tree_select_lock = true _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 _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: if item == null:
return return
if item.has_meta("obj_index") and int(item.get_meta("obj_index")) == idx: if item.has_meta("obj_index") and int(item.get_meta("obj_index")) == idx:
item.select(0)
content_tree.ensure_visible(item) content_tree.ensure_visible(item)
return return
for child in item.get_children(): 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 ## Rechte Panel-Sektion synchronisieren OHNE Statuszeilen-Text zu ändern

View file

@ -1,13 +1,26 @@
extends Camera3D extends Camera3D
## Orbit-Kamera für den Editor-Viewport. ## Orbit-Kamera + Freelook-Flug für den Editor-Viewport.
## Steuerung (Maus, keine Tastenkürzel nötig — Chef-Grundsatz): ## Steuerung:
## Mittlere Maustaste ziehen = Drehen (Orbit) ## Mittlere Maustaste ziehen = Drehen (Orbit)
## Mittlere Maustaste + Shift = Verschieben (Pan, parallel zum Boden) ## Mittlere Maustaste + Shift = Verschieben (Pan, parallel zum Boden)
## Mausrad = Zoom — AUSSER ein Objekt/eine Platzier-Vorschau ## Mausrad = Zoom — AUSSER ein Objekt/eine Platzier-Vorschau
## ist aktiv: dann dreht das Mausrad das Objekt ## ist aktiv: dann dreht das Mausrad das Objekt
## (interaction.gd meldet das per wheel_filter). ## (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 ## 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 target := Vector3.ZERO
var yaw_deg := 45.0 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 ## Von main.gd gesetzt: Callable → true, wenn das Mausrad gerade ein Objekt
## drehen soll (Platzier-Modus oder Auswahl) statt zu zoomen. ## drehen soll (Platzier-Modus oder Auswahl) statt zu zoomen.
var wheel_filter := Callable() 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 _dragging := false
var _panning := 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: func _ready() -> void:
_apply() _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: func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseButton: if event is InputEventMouseButton:
var mb := event as 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 _dragging = mb.pressed and not mb.shift_pressed
_panning = mb.pressed and mb.shift_pressed _panning = mb.pressed and mb.shift_pressed
elif mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.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(): if _wheel_blocked():
return return
distance = maxf(5.0, distance * 0.9) distance = maxf(5.0, distance * 0.9)
_apply() _apply()
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed: 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(): if _wheel_blocked():
return return
distance = minf(distance * 1.1, distance_max()) distance = minf(distance * 1.1, distance_max())
_apply() _apply()
elif event is InputEventMouseMotion: elif event is InputEventMouseMotion:
var mm := event as 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: if _dragging:
yaw_deg = wrapf(yaw_deg - mm.relative.x * 0.3, -180.0, 180.0) 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) 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()) 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: func distance_max() -> float:
return 6000.0 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: func frame_map(size_m: float) -> void:
_fly_held = false
_fly_locked = false
_flying = false
target = Vector3.ZERO target = Vector3.ZERO
distance = maxf(80.0, size_m * 0.6) distance = maxf(80.0, size_m * 0.6)
yaw_deg = 45.0 yaw_deg = 45.0
@ -86,3 +228,8 @@ func _apply() -> void:
) * distance ) * distance
position = target + offset position = target + offset
look_at(target, Vector3.UP) look_at(target, Vector3.UP)
func _status(text: String) -> void:
if status_cb.is_valid():
status_cb.call(text)

351
tests/test_shortcuts.gd Normal file
View file

@ -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")

View file

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

View file

@ -256,22 +256,22 @@ func _test_object_ops() -> void:
hist.begin(doc) hist.begin(doc)
var objects: Array = doc["objects"] 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]) _place(hist, doc, "crate_wood", [10.0, 0.0, 10.0])
objects[0]["rot_y_deg"] = 30.0 objects[0]["rot_y_deg"] = 30.0
var new_idx := ObjectOps.duplicate_object(doc, 0, true) var new_idx := ObjectOps.duplicate_object(doc, 0, true)
_check(new_idx == 1, "duplizieren: neuer Index 1") _check(new_idx == 1, "duplizieren: neuer Index 1")
_check(objects.size() == 2, "duplizieren: 1 → 2 Objekte") _check(objects.size() == 2, "duplizieren: 1 → 2 Objekte")
_check(str(objects[1]["id"]) == "crate_wood", "duplizieren: gleiche ID") _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, _check(float(objects[1]["pos"][0]) == 12.0 and float(objects[1]["pos"][2]) == 10.0,
"duplizieren: diagonal +1 m versetzt (Raster-Snap 1 m)") "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(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") _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] objects[0]["pos"] = [10.4, 0.0, 10.4]
var idx2 := ObjectOps.duplicate_object(doc, 0, false) 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: # Fraktionsobjekt wieder aufräumen für die History-Tests:
objects.remove_at(idx2) objects.remove_at(idx2)
@ -284,7 +284,7 @@ func _test_object_ops() -> void:
_check(objects.size() == 2, "duplizieren→undo: Kopie weg (2 bleiben)") _check(objects.size() == 2, "duplizieren→undo: Kopie weg (2 bleiben)")
hist.redo() hist.redo()
_check(objects.size() == 3, "duplizieren→undo→redo: Kopie wieder da") _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: # Ungültige Indizes schaden nichts:
_check(ObjectOps.duplicate_object(doc, 99, true) == -1, "duplizieren: falscher Index → -1") _check(ObjectOps.duplicate_object(doc, 99, true) == -1, "duplizieren: falscher Index → -1")

View file

@ -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

View file

@ -3,7 +3,7 @@
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
GODOT=~/bin/godot4 GODOT=~/bin/godot4
rc=0 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 "$GODOT" --headless --path . --script "res://tests/$t.gd" > "/tmp/$t.log" 2>&1
code=$? code=$?
line=$(grep -E "ALL OK|FAILED" "/tmp/$t.log" | tail -1) line=$(grep -E "ALL OK|FAILED" "/tmp/$t.log" | tail -1)