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):
|
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
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).
|
## 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):
|
||||||
|
|
|
||||||
|
|
@ -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 —
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
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
|
||||||
139
src/ui/main.gd
139
src/ui/main.gd
|
|
@ -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,26 +1110,40 @@ 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):
|
||||||
_set_tool_mode("objects")
|
# Werkzeug verlassen → Ghost-Preview abbrechen → Auswahl aufheben.
|
||||||
_set_status("Terrain-Modus beendet — zurück zum Objekt-Werkzeug.")
|
var esc := ShortcutPolicy.esc_action(tool_mode,
|
||||||
|
interaction.placing,
|
||||||
|
interaction.selected_index != -1 or not interaction.selected_indices.is_empty())
|
||||||
|
match esc:
|
||||||
|
"exit_terrain":
|
||||||
|
_set_tool_mode("objects")
|
||||||
|
_set_status("Terrain-Modus beendet — zurück zum Objekt-Werkzeug.")
|
||||||
|
"exit_spawns":
|
||||||
|
_set_tool_mode("objects")
|
||||||
|
_set_status("Spawn-Werkzeug beendet — zurück zum Objekt-Werkzeug.")
|
||||||
|
"exit_bauen":
|
||||||
|
_set_tool_mode("objects")
|
||||||
|
_set_status("Bauen-Modus beendet — zurück zum Objekt-Werkzeug.")
|
||||||
|
"cancel_placement":
|
||||||
|
interaction.cancel_placement()
|
||||||
|
_set_status("Platzieren abgebrochen.")
|
||||||
|
"clear_selection":
|
||||||
|
interaction.clear_selection()
|
||||||
|
_set_status("Auswahl aufgehoben.")
|
||||||
|
if esc != "none":
|
||||||
get_viewport().set_input_as_handled()
|
get_viewport().set_input_as_handled()
|
||||||
elif tool_mode == "spawns":
|
|
||||||
_set_tool_mode("objects")
|
|
||||||
_set_status("Spawn-Werkzeug beendet — zurück zum Objekt-Werkzeug.")
|
|
||||||
get_viewport().set_input_as_handled()
|
|
||||||
elif tool_mode == "bauen":
|
|
||||||
_set_tool_mode("objects")
|
|
||||||
_set_status("Bauen-Modus beendet — zurück zum Objekt-Werkzeug.")
|
|
||||||
get_viewport().set_input_as_handled()
|
|
||||||
elif interaction.placing:
|
|
||||||
interaction.cancel_placement()
|
|
||||||
_set_status("Platzieren abgebrochen.")
|
|
||||||
elif interaction.selected_index != -1:
|
|
||||||
interaction.clear_selection()
|
|
||||||
_set_status("Auswahl aufgehoben.")
|
|
||||||
get_viewport().set_input_as_handled()
|
|
||||||
KEY_DELETE:
|
KEY_DELETE:
|
||||||
if not ctrl:
|
if not ctrl:
|
||||||
if interaction.placing:
|
if interaction.placing:
|
||||||
|
|
@ -1512,6 +1555,9 @@ func _shake_button(btn: Control) -> void:
|
||||||
|
|
||||||
|
|
||||||
## Der Baum ruft zurück, wenn der Nutzer dort ein Objekt auswählt.
|
## 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -85,4 +227,9 @@ func _apply() -> void:
|
||||||
cos(yaw) * cos(pitch)
|
cos(yaw) * cos(pitch)
|
||||||
) * 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
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)
|
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")
|
||||||
|
|
|
||||||
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")/.."
|
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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue