- 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
351 lines
No EOL
16 KiB
GDScript
351 lines
No EOL
16 KiB
GDScript
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") |