scopedvr-map-editor/tests/test_shortcuts.gd
Hermes (godot-vr) 095af5ff3c 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
2026-10-16 17:43:17 +00:00

351 lines
No EOL
16 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

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

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