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:
parent
b20dff2b5d
commit
095af5ff3c
18 changed files with 1026 additions and 79 deletions
50
README.md
50
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`).
|
||||
|
||||
|
|
|
|||
69
docs/tasten.md
Normal file
69
docs/tasten.md
Normal 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
70
src/editor/fly_math.gd
Normal 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))
|
||||
1
src/editor/fly_math.gd.uid
Normal file
1
src/editor/fly_math.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://btbsxedv3fvw
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 —
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
69
src/editor/shortcut_keys.gd
Normal file
69
src/editor/shortcut_keys.gd
Normal 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
|
||||
1
src/editor/shortcut_keys.gd.uid
Normal file
1
src/editor/shortcut_keys.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ctglyo67gpgdc
|
||||
25
src/editor/shortcut_policy.gd
Normal file
25
src/editor/shortcut_policy.gd
Normal 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"
|
||||
1
src/editor/shortcut_policy.gd.uid
Normal file
1
src/editor/shortcut_policy.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://0sobeix0nlyg
|
||||
119
src/ui/main.gd
119
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,25 +1110,39 @@ 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":
|
||||
# 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.")
|
||||
get_viewport().set_input_as_handled()
|
||||
elif tool_mode == "spawns":
|
||||
"exit_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":
|
||||
"exit_bauen":
|
||||
_set_tool_mode("objects")
|
||||
_set_status("Bauen-Modus beendet — zurück zum Objekt-Werkzeug.")
|
||||
get_viewport().set_input_as_handled()
|
||||
elif interaction.placing:
|
||||
"cancel_placement":
|
||||
interaction.cancel_placement()
|
||||
_set_status("Platzieren abgebrochen.")
|
||||
elif interaction.selected_index != -1:
|
||||
"clear_selection":
|
||||
interaction.clear_selection()
|
||||
_set_status("Auswahl aufgehoben.")
|
||||
if esc != "none":
|
||||
get_viewport().set_input_as_handled()
|
||||
KEY_DELETE:
|
||||
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.
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -86,3 +228,8 @@ func _apply() -> void:
|
|||
) * distance
|
||||
position = target + offset
|
||||
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
351
tests/test_shortcuts.gd
Normal 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")
|
||||
1
tests/test_shortcuts.gd.uid
Normal file
1
tests/test_shortcuts.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cddsyesnw8bg2
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
19
tools/parse_check_shortcuts.sh
Normal file
19
tools/parse_check_shortcuts.sh
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue