extends SceneTree ## Editor-Test (headless): Karte t_950fddaa (K1) — Kamera-Drehen fixen — ## erweitert um Karte t_eca890c5 (K1b): Startansicht auf den Karten-INHALT. ## Ausführen: godot4 --headless --path . --script res://tests/test_orbit_camera.gd ## Exit 0 = alles grün. ## ## Instanziert die ECHTE Main-UI (wie test_rmb_vorrang) und prüft: ## 1. STARTANSICHT (K1b): Laden/Neu → fit_to_document — Bounding-Box aus ## Objekten + Bauteilen + Spawnpunkten (Terrain NICHT), Mitte/Abstand ## laut Regel (Kantenlänge × 1,3, min 40 m, nie weiter als die ganze ## Karte), Klartext-Statuszeile; leeres Dokument → ganze Karte; ## 256-m- und 1024-m-Map; Mindestabstand; Karten-Deckel. ## 2. KEIN SPRUNG: MMT-Druck ändert Position/Basis NICHT (1e-4), aber ## target = Trefferpunkt; Projektion des target ≈ Cursor (1 px). ## Vergleichszeile dokumentiert, wie weit das ALTE Verhalten weg war. ## 3. DREHPUNKT AUF DEM TERRAIN (K1b): MMT auf Hügel-Flanke (höheres ## Heightfield) → target = Terrain-Treffer, y ≈ terrain_height_at — ## nicht die Basis-Ebene (y=0) und nicht der alte Ziel-Punkt. ## 4. GLEICHMÄSSIG: 100 px ziehen in 10 Schritten → jede Schritt dreht ## gleichmäßig (max 1,3× Mittel), kein Vorzeichenwechsel. ## 5. FLUG-ÜBERGANG: _enter_fly → Bewegung → _exit_fly ohne Positionssprung. ## 6. POS1: GANZE Karte wieder im Bild — auch nach Objekt-Ansicht (K1b). var failures := 0 var checks := 0 var main: Control var camera: Camera3D var subvp: SubViewport var interaction: Node var view_builder: Node var base_doc: Dictionary # Grund-Dokument der Suite (K1-Szene) für Rückkehr nach Spezial-Gruppen const ORBIT_DEG_PER_PX := 0.3 func _initialize() -> void: _run_all.call_deferred() func _run_all() -> void: var scene: PackedScene = load("res://scenes/main.tscn") _check(scene != null, "scenes/main.tscn laedt") if scene == null: _finish() return main = scene.instantiate() root.add_child(main) await process_frame await process_frame camera = main.get("camera") interaction = main.get("interaction") view_builder = main.get("view_builder") _check(camera != null, "Kamera-Referenz gefunden") _check(interaction != null, "interaction-Referenz gefunden") _check(view_builder != null, "view_builder-Referenz gefunden") if camera == null or interaction == null or view_builder == null: _finish() return var container := _find_viewport_container(main) _check(container != null, "SubViewportContainer gefunden") if container == null: _finish() return subvp = container.get_child(0) as SubViewport _check(subvp != null, "SubViewport gefunden") if subvp == null: _finish() return # Headless-Fenster ist degeneriert klein (SubViewport ~951×2 px) — für # aussagekräftige Projektionen/Frustum-Prüfungen Editor-Größe erzwingen. container.stretch = false subvp.size = Vector2i(1600, 900) await process_frame _check(subvp.size == Vector2i(1600, 900), "SubViewport auf 1600×900 (headless)") # Test-Map anlegen (gleicher Pfad wie „Neue Map“): _refresh_all löst # fit_to_document in _refresh_viewport aus (Karte K1b — Inhalts-Ansicht). var doc: Dictionary = SvmapDocument.create_new("K1-Test", 1024.0, "mittag") doc["objects"] = [ {"id": "crate_wood", "pos": [0.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0}, {"id": "crate_wood", "pos": [450.0, 0.0, 450.0], "scale": 1.0, "rot_y_deg": 0.0}, ] base_doc = doc main.set("doc", doc) main.call("_refresh_all") await process_frame interaction.call("refresh_terrain_cache") main.call("_set_tool_mode", "objects") await process_frame _test_start_view() await _test_pivot_no_jump() await _test_content_rules() await _test_terrain_pivot() await _test_uniform_drag() await _test_fly_transition() await _test_home_key() _finish() # ------------------------------------------------- 1. STARTANSICHT (K1b) --- func _test_start_view() -> void: print("Testgruppe: Startansicht = Karten-INHALT (K1b, Laden/Neu)") var target: Vector3 = camera.get("target") var dist: float = float(camera.get("distance")) # Dokument: 2 Kisten (0,0,0) + (450,0,450) und 8 Default-Spawnpunkte # (x ±409,6 / z ±307,2 … / y 0,2) → BBox x −409,6…450, y 0…0,2, z −307,2…450. # Mitte (20,2 / 0,1 / 71,4); größte Kante 859,6 × 1,3 = 1117,48; der # Karten-Deckel (Umkugel 1024er Map = 1215,6) wird NICHT erreicht. _check(target.distance_to(Vector3(20.2, 0.1, 71.4)) < 0.01, "target = Mitte der Inhalts-BBox (20,2 / 0,1 / 71,4) — Objekte+Spawns, NICHT Kartemitte") _check(absf(dist - 1117.48) < 0.5, "distance = Kantenlänge × 1,3 (859,6 × 1,3 = 1117,5 m): %.1f m" % dist) _check(dist < 1215.56, "Deckel greift nicht (Karte kleiner als ganze Map): %.1f < 1215,6 m" % dist) _check(absf(float(camera.get("yaw_deg")) - 45.0) < 1e-4, "yaw = 45 Grad") _check(absf(float(camera.get("pitch_deg")) - 35.0) < 1e-4, "pitch = 35 Grad") var status: String = str((main.get("status_label") as Label).text) _check(status.contains("Ansicht:") and status.contains("2 Objekte") and status.contains("8 Spawnpunkte") and status.contains("1117"), "Statuszeile Klartext: „%s“" % status) var alle_drin := true for c in _corners(Vector3(-409.6, 0.0, -307.2), Vector3(450.0, 0.2, 450.0)): if not camera.is_position_in_frustum(c): alle_drin = false _check(alle_drin, "alle 8 Inhalts-Ecken im Frustum") # --------------------------------- 1b. INHALTS-REGELN: Größen/leer (K1b) --- func _test_content_rules() -> void: print("Testgruppe: Inhalts-Ansicht — Regeln (256 m, leer, Min/Deckel)") var mn256 := Vector3(-128.0, -10.0, -128.0) var mx256 := Vector3(128.0, 80.0, 128.0) # (a) 256-m-Map, keine Spawns, 3 Objekte → BBox 40×20×15, Kante 40 × 1,3 = 52 m var doc256: Dictionary = SvmapDocument.create_new("K1b-256", 256.0, "mittag") doc256["spawn_zones"] = {} doc256["objects"] = [ {"id": "crate_wood", "pos": [0.0, 10.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0}, {"id": "crate_wood", "pos": [40.0, 30.0, -10.0], "scale": 1.0, "rot_y_deg": 0.0}, {"id": "crate_wood", "pos": [20.0, 20.0, 5.0], "scale": 1.0, "rot_y_deg": 0.0}, ] camera.call("fit_to_document", doc256, mn256, mx256) var target: Vector3 = camera.get("target") var dist: float = float(camera.get("distance")) _check(target.distance_to(Vector3(20.0, 20.0, -2.5)) < 0.01, "256-m-Map: target = Content-Mitte (20 / 20 / −2,5)") _check(absf(dist - 52.0) < 0.01, "256-m-Map: distance = 40 × 1,3 = 52 m (%.2f) — Terrain zählt NICHT (sonst 312,5)" % dist) _check(String((main.get("status_label") as Label).text).contains("3 Objekte"), "256-m-Map: Status meldet „3 Objekte“") var alle_drin := true for c in _corners(Vector3(0.0, 10.0, -10.0), Vector3(40.0, 30.0, 5.0)): if not camera.is_position_in_frustum(c): alle_drin = false _check(alle_drin, "256-m-Map: alle 8 Inhalts-Ecken im Frustum") # (b) Deckel: Content diagonal über die Karte → nie weiter als die ganze Karte doc256["objects"] = [ {"id": "crate_wood", "pos": [-128.0, 0.0, -128.0], "scale": 1.0, "rot_y_deg": 0.0}, {"id": "crate_wood", "pos": [128.0, 0.0, 128.0], "scale": 1.0, "rot_y_deg": 0.0}, ] camera.call("fit_to_document", doc256, mn256, mx256) dist = float(camera.get("distance")) var d_map := Vector3(256.0, 90.0, 256.0).length() * 0.5 / sin(deg_to_rad(37.5)) * 1.02 _check(absf(dist - d_map) < 0.5, "Deckel: distance = Karten-Fit (%.1f m), Kante×1,3 (332,8 m) wird GESCHNITTEN" % dist) # (c) Mindestabstand: einzelnes Objekt (Kante 0) → 40 m doc256["objects"] = [ {"id": "crate_wood", "pos": [10.0, 5.0, 10.0], "scale": 1.0, "rot_y_deg": 0.0}, ] camera.call("fit_to_document", doc256, mn256, mx256) target = camera.get("target") dist = float(camera.get("distance")) _check(target.distance_to(Vector3(10.0, 5.0, 10.0)) < 0.01, "1 Objekt: target = Objekt-Position") _check(absf(dist - 40.0) < 0.01, "Mindestabstand: 1 Objekt → 40 m statt 0 × 1,3 = 0 (%.2f)" % dist) # (d) leeres Dokument (ohne Default-Spawns!) → wie bisher: Kartenmitte, ganze Karte var doc_leer: Dictionary = SvmapDocument.create_new("K1b-leer", 256.0, "mittag") doc_leer["spawn_zones"] = {} camera.call("fit_to_document", doc_leer, mn256, mx256) target = camera.get("target") dist = float(camera.get("distance")) _check(target.distance_to(Vector3(0.0, 35.0, 0.0)) < 0.01, "leeres Dokument: target = Kartemitte (0, 35, 0)") _check(absf(dist - d_map) < 0.5, "leeres Dokument: distance = Umkugel der GANZEN Karte (%.1f m)" % dist) _check(String((main.get("status_label") as Label).text).contains("ganze Karte"), "leeres Dokument: Status meldet „ganze Karte“") # ------------------------------------------ 1c. DREHPUNKT AUF TERRAIN (K1b) func _test_terrain_pivot() -> void: print("Testgruppe: Drehpunkt trifft das TERRAIN (K1b)") # Hügel-Map: Plateau 30 m (r ≤ 150 m), sonst Grundhöhe 5 m — Heightfield # R16/LE direkt schreiben (gleiche Kodierung wie SvmapIO.decode_heights). var doc: Dictionary = SvmapDocument.create_new("K1b-Huegel", 256.0, "mittag") var terrain: Dictionary = doc["terrain"] var res := int(terrain["resolution"]) var hmin := float(terrain["min_height_m"]) var hmax := float(terrain["max_height_m"]) var bytes := PackedByteArray() bytes.resize(res * res * 2) for zi in res: for xi in res: var x := (float(xi) / float(res - 1) - 0.5) * 256.0 var z := (float(zi) / float(res - 1) - 0.5) * 256.0 var h := 30.0 if sqrt(x * x + z * z) <= 150.0 else 5.0 bytes.encode_u16((zi * res + xi) * 2, int(round((h - hmin) / (hmax - hmin) * 65535.0))) terrain["heightmap_r16_b64"] = Marshalls.raw_to_base64(bytes) doc["spawn_zones"] = {} doc["objects"] = [] main.set("doc", doc) main.call("_refresh_all") await process_frame interaction.call("refresh_terrain_cache") await process_frame var soll_y := SvmapDocument.terrain_height_at(doc, 120.0, 0.0) print(" ZAHLEN: terrain_height_at(120, 0) = %.2f m (Plateau 30 m erwartet)" % soll_y) var p_welt := Vector3(120.0, 30.0, 0.0) # auf dem Plateau, weit weg von Objekten var press: Vector2 = camera.unproject_position(p_welt) var pos_before: Vector3 = camera.position var basis_before: Basis = camera.global_transform.basis _motion(press) _mouse(MOUSE_BUTTON_MIDDLE, true, press) await process_frame var pos_after: Vector3 = camera.position var basis_after: Basis = camera.global_transform.basis _check(pos_after.distance_to(pos_before) < 1e-4, "KEIN Sprung: Position identisch") _check(_basis_diff(basis_before, basis_after) < 1e-4, "KEIN Sprung: Basis identisch") var tgt: Vector3 = camera.get("target") _check(tgt.distance_to(p_welt) < 8.0, "target = Terrain-Treffer (Abweichung %.2f m zum Sollpunkt)" % tgt.distance_to(p_welt)) _check(absf(tgt.y - SvmapDocument.terrain_height_at(doc, tgt.x, tgt.z)) < 1.0, "target.y ≈ terrain_height_at(target.x, target.z): %.2f vs %.2f" % [ tgt.y, SvmapDocument.terrain_height_at(doc, tgt.x, tgt.z)]) _check(tgt.y > 20.0, "target liegt AUF dem Hügel (y = %.2f), nicht auf der Basis-Ebene (0 m)" % tgt.y) var proj: Vector2 = camera.unproject_position(tgt) _check(proj.distance_to(press) <= 2.0, "target-Projektion ≈ Cursor (%.2f px)" % proj.distance_to(press)) _mouse(MOUSE_BUTTON_MIDDLE, false, press) await process_frame # Grund-Dokument zurück (Pos1-Gruppe braucht die 1024er-Map-Bounds). main.set("doc", base_doc) main.call("_refresh_all") await process_frame interaction.call("refresh_terrain_cache") await process_frame # -------------------------------------------- 2. PIVOT UNTER DEM CURSOR ----- func _test_pivot_no_jump() -> void: print("Testgruppe: Drehpunkt unter dem Mauszeiger (kein Sprung)") var markers: Array = view_builder.get("object_markers") var marker: Node3D = markers[1] # Objekt WEIT abseits der Kartenmitte _check(marker != null and is_instance_valid(marker), "Objekt-Marker vorhanden (crate_wood bei 450, 450)") if marker == null: return var center: Vector3 = marker.global_position \ + Vector3.UP * float(marker.get_meta("half_height", 0.0)) var press: Vector2 = camera.unproject_position(center) # Altes Verhalten dokumentieren: Drehpunkt war der Fit-Ziel-Punkt — seine # Projektion lag weit weg vom Cursor. var alt_target: Vector3 = Vector3(0.0, 35.0, 0.0) var alt_px: Vector2 = camera.unproject_position(alt_target) print(" ALT (Vergleich, alter Code): Drehpunkt-Projektion lag %.0f px vom Cursor entfernt" % alt_px.distance_to(press)) var pos_before: Vector3 = camera.position var basis_before: Basis = camera.global_transform.basis _motion(press) _mouse(MOUSE_BUTTON_MIDDLE, true, press) await process_frame var pos_after: Vector3 = camera.position var basis_after: Basis = camera.global_transform.basis _check(pos_after.distance_to(pos_before) < 1e-4, "KEIN Sprung: Kameraposition identisch (Abweichung %.12f)" % pos_after.distance_to(pos_before)) _check(_basis_diff(basis_before, basis_after) < 1e-4, "KEIN Sprung: Basis identisch") var new_target: Vector3 = camera.get("target") _check(new_target.distance_to(center) < 1e-4, "target = Trefferpunkt (Objekt-Mitte)") var proj: Vector2 = camera.unproject_position(new_target) _check(proj.distance_to(press) <= 1.0, "target-Projektion ≈ Cursor (Abweichung %.12f px)" % proj.distance_to(press)) var new_dist: float = float(camera.get("distance")) _check(absf(new_dist - pos_before.distance_to(center)) < 1e-3, "distance = |Kamera − Treffer| (%.1f m)" % new_dist) _mouse(MOUSE_BUTTON_MIDDLE, false, press) await process_frame # ------------------------------------------------- 3. GLEICHMÄSSIG ZIEHEN -- func _test_uniform_drag() -> void: print("Testgruppe: 100 px ziehen in 10 Schritten — gleichmäßig") var start := subvp.size * 0.5 _motion(start) _mouse(MOUSE_BUTTON_MIDDLE, true, start) await process_frame var yaw0: float = float(camera.get("yaw_deg")) var steps: Array[float] = [] var p := start for i in range(10): p.x += 10.0 _motion_rel(p, Vector2(10.0, 0.0)) await process_frame var yaw_i: float = float(camera.get("yaw_deg")) steps.append(yaw0 - yaw_i) yaw0 = yaw_i _mouse(MOUSE_BUTTON_MIDDLE, false, p) await process_frame var mean := 0.0 for s in steps: mean += s mean /= float(steps.size()) _check(absf(mean - 3.0) < 1e-3, "Mittel: %.3f Grad/Schritt (= 10 px × %.1f)" % [mean, ORBIT_DEG_PER_PX]) var max_ratio := 0.0 var sign_ok := true for s in steps: if mean > 0.0: max_ratio = maxf(max_ratio, s / mean) if s <= 0.0: sign_ok = false _check(max_ratio <= 1.3, "kein Nachlaufen/Sprung: max Schritt %.3f× Mittel" % max_ratio) _check(sign_ok, "kein Vorzeichenwechsel beim Ziehen") # ------------------------------------------------------ 4. FLUG-ÜBERGANG --- func _test_fly_transition() -> void: print("Testgruppe: Flug-Übergang nahtlos") _mouse(MOUSE_BUTTON_RIGHT, true, subvp.size * 0.5) await process_frame _check(bool(camera.call("fly_active")), "Flug aktiv") # Zwischenbewegung (wie im Flug: Position + Blickrichtung ändern). camera.position = camera.position + Vector3(12.0, -4.0, -9.0) camera.set("_fly_yaw", -30.0) camera.set("_fly_pitch", 20.0) camera.call("_apply_fly_orientation") await process_frame var pos_before: Vector3 = camera.position _mouse(MOUSE_BUTTON_RIGHT, false, subvp.size * 0.5) await process_frame _check(not bool(camera.call("fly_active")), "Flug aus → Orbit") var pos_after: Vector3 = camera.position _check(pos_after.distance_to(pos_before) < 1e-3, "kein Positionssprung beim Übergang (%.12f)" % pos_after.distance_to(pos_before)) # ------------------------------------------------------------- 5. POS1 ----- func _test_home_key() -> void: print("Testgruppe: Pos1 = ganze Karte ins Bild") var key := InputEventKey.new() key.keycode = KEY_HOME key.physical_keycode = KEY_HOME key.pressed = true subvp.push_input(key) await process_frame var target: Vector3 = camera.get("target") _check(target.distance_to(Vector3(0.0, 35.0, 0.0)) < 1e-4, "Pos1: target = Kartemitte") var radius := Vector3(1024.0, 90.0, 1024.0).length() * 0.5 _check(float(camera.get("distance")) >= radius / sin(deg_to_rad(37.5)) - 1.0, "Pos1: distance = Umkugel-Formel (ganze Map sichtbar)") _check(absf(float(camera.get("yaw_deg")) - 45.0) < 1e-4, "Pos1: yaw = 45 Grad") _check(absf(float(camera.get("pitch_deg")) - 35.0) < 1e-4, "Pos1: pitch = 35 Grad") var alle_drin := true for c in _corners(Vector3(-512.0, -10.0, -512.0), Vector3(512.0, 80.0, 512.0)): if not camera.is_position_in_frustum(c): alle_drin = false _check(alle_drin, "Pos1: alle 8 Map-Ecken im Frustum") # ------------------------------------------------------------- Helpers ----- func _corners(mn: Vector3, mx: Vector3) -> Array[Vector3]: var out: Array[Vector3] = [] for x in [mn.x, mx.x]: for y in [mn.y, mx.y]: for z in [mn.z, mx.z]: out.append(Vector3(x, y, z)) return out func _basis_diff(a: Basis, b: Basis) -> float: var d := 0.0 d = maxf(d, (a.x - b.x).length()) d = maxf(d, (a.y - b.y).length()) d = maxf(d, (a.z - b.z).length()) return d func _find_viewport_container(node: Node) -> SubViewportContainer: if node is SubViewportContainer: return node as SubViewportContainer for child in node.get_children(): var found := _find_viewport_container(child) if found != null: return found return null ## Headless-Besonderheit (wie test_rmb_vorrang): direkt in den SubViewport ## pushen, LOKALE Koordinaten. func _mouse(button: MouseButton, pressed: bool, pos: Vector2, ctrl := false, shift := false) -> void: var mb := InputEventMouseButton.new() mb.button_index = button mb.pressed = pressed mb.position = pos mb.global_position = pos mb.ctrl_pressed = ctrl mb.shift_pressed = shift subvp.push_input(mb) func _motion(pos: Vector2) -> void: _motion_rel(pos, Vector2.ZERO) func _motion_rel(pos: Vector2, rel: Vector2) -> void: var mm := InputEventMouseMotion.new() mm.position = pos mm.global_position = pos mm.relative = rel subvp.push_input(mm) func _finish() -> void: 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)