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