extends RefCounted class_name KolliderBau ## Kollider-Bau 1:1 aus dem Quest-3-Client gespiegelt (Karte t_1da96275 — ## „Editor-Playtest-Physik = Quest 3 identisch"). ## ## Dieser Modul ist der SPIEGEL von client/scripts/world/svmap_world.gd ## (_collider/_primitiv_form/_trimesh_aus_datei/_half_height + Terrain- ## Trimesh). Die Playtest-Physik im Editor benutzt EXAKT dieselben Formen ## wie der Quest-Client: box/kugel/zylinder/verbund, Trimesh (inline oder ## aus library/kollisions/.json), Terrain als Trimesh des Höhenfelds. ## ## Regel: Ändert der Client den Kollider-Bau, wird dieser Spiegel MIT ## geändert (Karte verweist auf svmap_world.gd als Quelle der Wahrheit). ## Bewusst KEINE Eigenlogik hier — jede Abweichung wäre eine Abweichung ## vom Quest-3-Verhalten, die Sly ausdrücklich abgelehnt hat. const DEFAULT_MANIFEST_PATH := "res://library/objects.json" ## Trimesh-Quelldateien (begehbare Gebäude): Cache über alle Exemplare — ## mehrere Platzierungen derselben OID teilen sich die Vertices (wie im Client). static var _trimesh_datei_cache := {} ## Bequemer Einstieg: Manifest selbst laden (leer → Objekte ohne Eintrag ## werden übersprungen — die Editor-Validierung hätte sie abgelehnt). ## Spiegel von SvmapWorld.load_manifest(). static func lade_manifest() -> Dictionary: if not FileAccess.file_exists(DEFAULT_MANIFEST_PATH): return {} var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(DEFAULT_MANIFEST_PATH)) return parsed if parsed is Dictionary else {} ## Baut ALLE Welt-Kollider aus einem svmap-Dokument: Terrain-Trimesh + ## je Objekt ein StaticBody3D (Form aus der Registry, Quelle-Dateien aus ## library/kollisions). Gibt die Wurzel zurück (Caller hängt sie ein und ## gibt sie mit frei). Spiegel von SvmapWorld._build (nur Kollider, ohne ## Visuals — die sieht der Editor bereits durch den view_builder). static func baue_welt_kollider(data: Dictionary, manifest: Dictionary = {}) -> Node3D: var root := Node3D.new() root.name = "PlaytestKollider" var lib: Dictionary = {} if manifest.is_empty(): manifest = lade_manifest() lib = manifest.get("objects", {}) var terrain: Dictionary = data.get("terrain", {}) if not terrain.is_empty(): var tform := terrain_form(data) if tform != null: var tbody := StaticBody3D.new() tbody.name = "TerrainKollider" var tcs := CollisionShape3D.new() tcs.shape = tform tbody.add_child(tcs) root.add_child(tbody) var objects: Array = data.get("objects", []) var i := 0 for obj in objects: i += 1 if not (obj is Dictionary): continue var oid := str(obj.get("id", "")) var entry: Dictionary = lib.get(oid, {}) if entry.is_empty(): continue # Editor-Validierung hätte das abgelehnt — defensiv. var col_spec: Dictionary = entry.get("collision", {}) var scale := float(obj.get("scale", 1.0)) var pos: Array = obj.get("pos", [0.0, 0.0, 0.0]) var marker := Node3D.new() marker.name = "Kollider_%d_%s" % [i, oid] marker.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2])) # Karte t_e166e656: 3-Achsen-Rotation — `rot` (Euler xyz) oder Fallback # rot_y_deg, identische Regel wie im Client/Server (SvmapIO.objekt_rotation). marker.rotation_degrees = SvmapIO.objekt_rotation(obj) marker.add_child(kollider(col_spec, scale)) root.add_child(marker) return root ## --------------------------------------------------------------------- -- ## Ab hier 1:1-SPIEGEL von svmap_world.gd (Client) — Funktionsweise, Reihen- ## folge und Toleranzen identisch, nur statisch gebunden. ## --------------------------------------------------------------------- -- ## Spiegel von SvmapWorld._collider(): compound = mehrere Teile mit eigenem ## Versatz/Drehung, "none" = kein Kollider, sonst eine Form am Fußpunkt. static func kollider(col_spec: Dictionary, scale: float) -> StaticBody3D: var body := StaticBody3D.new() # Rein dekorativ ("none"): kein Kollider. if str(col_spec.get("shape", "box")) == "none": return body if str(col_spec.get("shape", "box")) == "compound": for teil_v in col_spec.get("parts", []): var teil: Dictionary = teil_v if teil_v is Dictionary else {} var cs := CollisionShape3D.new() cs.shape = primitiv_form(teil, scale) var off: Array = teil.get("pos", [0.0, 0.0, 0.0]) cs.position = Vector3(float(off[0]), float(off[1]), float(off[2])) * scale var rot: Array = teil.get("rot_deg", [0.0, 0.0, 0.0]) cs.rotation_degrees = Vector3(float(rot[0]), float(rot[1]), float(rot[2])) body.add_child(cs) return body var cs2 := CollisionShape3D.new() var form := primitiv_form(col_spec, scale) if form == null: return body # Ungültige Kollisions-Spec (z. B. defektes Trimesh): kein Kollider. cs2.shape = form cs2.position.y = halbe_hoehe(cs2.shape) # pos = Objekt-Fußpunkt (Server-Konvention) body.add_child(cs2) return body ## Spiegel von SvmapWorld._primitiv_form(): box/sphere/cylinder/trimesh. ## Trimesh: inline-vertices oder collision.quelle aus library/kollisions/, ## × scale, backface AN (begehbar + beschußbar von beiden Seiten). ## Defekte Specs → null (kein Kollider). static func primitiv_form(col_spec: Dictionary, scale: float) -> Shape3D: match str(col_spec.get("shape", "box")): "sphere": var s := SphereShape3D.new() s.radius = float(col_spec.get("radius", 1.0)) * scale return s "cylinder": var s := CylinderShape3D.new() s.radius = float(col_spec.get("radius", 1.0)) * scale s.height = float(col_spec.get("height", 1.0)) * scale return s "trimesh": var verts_v: Variant = col_spec.get("vertices", null) if verts_v == null and col_spec.has("quelle"): verts_v = trimesh_aus_datei(str(col_spec["quelle"])) if not (verts_v is Array): return null var verts: Array = verts_v if verts.is_empty() or verts.size() % 3 != 0: return null var faces := PackedVector3Array() faces.resize(verts.size()) var fi := 0 for v in verts: if not (v is Array) or (v as Array).size() != 3 \ or not (v[0] is float or v[0] is int) \ or not (v[1] is float or v[1] is int) \ or not (v[2] is float or v[2] is int): return null faces[fi] = Vector3(float(v[0]), float(v[1]), float(v[2])) * scale fi += 1 var s := ConcavePolygonShape3D.new() s.set_faces(faces) s.backface_collision = true return s _: var s := BoxShape3D.new() var size: Array = col_spec.get("size", [1.0, 1.0, 1.0]) s.size = Vector3(float(size[0]), float(size[1]), float(size[2])) * scale return s ## Spiegel von SvmapWorld._trimesh_aus_datei(): Quelle-Datei relativ zu ## res://library/, JSON {"vertices": [[x,y,z], ...]}. static func trimesh_aus_datei(quelle: String) -> Variant: if quelle.is_empty(): return null if _trimesh_datei_cache.has(quelle): return _trimesh_datei_cache[quelle] var fa := FileAccess.open("res://library/" + quelle, FileAccess.READ) if fa == null: _trimesh_datei_cache[quelle] = null return null var parsed: Variant = JSON.parse_string(fa.get_as_text()) var verts: Variant = null if parsed is Dictionary and (parsed as Dictionary).get("vertices", null) is Array: verts = (parsed as Dictionary)["vertices"] _trimesh_datei_cache[quelle] = verts return verts ## Spiegel des Terrain-Kolliders aus SvmapWorld._build_terrain(): DASSELBE ## Mesh (gleiche Platzierungs-Konvention (0,0) = (−size/2, −size/2), gleiche ## Dreiecks-Wicklung, generate_normals) → create_trimesh_shape(). Nur der ## Kollider, kein MeshInstance (den rendered der Editor bereits). static func terrain_form(data: Dictionary) -> ConcavePolygonShape3D: var terrain: Dictionary = data.get("terrain", {}) var res := int(terrain.get("resolution", 0)) var size_x := float(terrain.get("size_x_m", 0.0)) var size_z := float(terrain.get("size_z_m", 0.0)) var heights := SvmapIO.decode_heights(terrain) if res < 2 or heights.size() < res * res or size_x <= 0.0 or size_z <= 0.0: return null var st := SurfaceTool.new() st.begin(Mesh.PRIMITIVE_TRIANGLES) st.set_smooth_group(-1) # wie Client (einflussfrei auf die Geometrie) var last := float(res - 1) var zi := 0 while zi < res: var xi := 0 while xi < res: # Platzierungs-Konvention (TerrainField.gd, map_document, # HeightMapShape3D): Index (0,0) liegt bei (−size/2, −size/2). var idx := zi * res + xi st.add_vertex(Vector3( float(xi) / last * size_x - size_x * 0.5, heights[idx], float(zi) / last * size_z - size_z * 0.5)) xi += 1 zi += 1 # Dreiecke so gewickelt, dass die Normalen nach OBEN zeigen — exakt die # Client-Wicklung ((a, d, c) + (a, b, d), Godot-Front-Faces = clockwise). var zi2 := 0 while zi2 < res - 1: var xi2 := 0 while xi2 < res - 1: var a := zi2 * res + xi2 var b := zi2 * res + xi2 + 1 var c := (zi2 + 1) * res + xi2 var d := (zi2 + 1) * res + xi2 + 1 st.add_index(a) st.add_index(d) st.add_index(c) st.add_index(a) st.add_index(b) st.add_index(d) xi2 += 1 zi2 += 1 st.generate_normals() var mesh := st.commit() return mesh.create_trimesh_shape() as ConcavePolygonShape3D ## Spiegel von SvmapWorld._half_height(). static func halbe_hoehe(shape: Shape3D) -> float: if shape is BoxShape3D: return (shape as BoxShape3D).size.y * 0.5 if shape is CylinderShape3D: return (shape as CylinderShape3D).height * 0.5 if shape is SphereShape3D: return (shape as SphereShape3D).radius return 0.0 ## Konservative Hindernis-AABB einer GEDREHTEN Box (Karte t_e166e656): alle ## 8 lokalen Ecken der Kollisions-Box × Basis der Objekt-Rotation, dann die ## umschließende AABB. Identische Formel wie Server (svmap_loader) und Client ## (svmap_world) — Identitäts-Vertrag der Playtest-Hindernisboxen. ## ## ALLES Trimesh (t_ff7fc919): bei Trimesh kommt die LOKALE AABB aus der ## Dreiecks-Suppe (Quelle-Datei oder inline), nicht aus collision.size — ## size bleibt nur Merge-/Platzhalter-Info. Defekte/fehlende Suppe → ## Fallback auf die Mess-AABB aus size (identisch zum Client/Server-Mirror). static func hindernis_aabb(obj: Dictionary, col: Dictionary, scale: float) -> AABB: var size: Array = col.get("size", [1.0, 1.0, 1.0]) var half_ext := Vector3(float(size[0]), float(size[1]), float(size[2])) * scale * 0.5 # Lokale AABB in Fußpunkt-Koordinaten: Box = zentriert über dem Fußpunkt # (x/z symmetrisch, y von 0 bis Höhe) — exakt die Placement-Konvention # der CollisionShape (position.y = halbe Höhe). var mn := Vector3(-half_ext.x, 0.0, -half_ext.z) var mx := Vector3(half_ext.x, half_ext.y * 2.0, half_ext.z) if str(col.get("shape", "box")) == "trimesh": var suppe: Array = _hindernis_suppe(col) if suppe.size() >= 3 and suppe.size() % 3 == 0: mn = Vector3(INF, INF, INF) mx = -Vector3(INF, INF, INF) for v in suppe: mn = mn.min(Vector3(float(v[0]), float(v[1]), float(v[2])) * scale) mx = mx.max(Vector3(float(v[0]), float(v[1]), float(v[2])) * scale) if mx.x < mn.x: # numerisch leer (nicht-finite Werte) → Fallback mn = Vector3(-half_ext.x, 0.0, -half_ext.z) mx = Vector3(half_ext.x, half_ext.y * 2.0, half_ext.z) var rot := SvmapIO.objekt_rotation(obj) var basis := Basis.from_euler(Vector3(deg_to_rad(rot.x), deg_to_rad(rot.y), deg_to_rad(rot.z))) # Zentriert über dem Fußpunkt: Lokale Mitte × Basis + pos (bei der Box ist # die lokale Mitte (0, h/2, 0) — bitidentisch zur alten 8-Ecken-Formel). var pos: Array = obj.get("pos", [0.0, 0.0, 0.0]) var center := basis * ((mn + mx) * 0.5) \ + Vector3(float(pos[0]), float(pos[1]), float(pos[2])) var half := (mx - mn) * 0.5 var out := AABB(center + basis * -half, Vector3.ZERO) for sx in [-half.x, half.x]: for sy in [-half.y, half.y]: for sz in [-half.z, half.z]: out = out.expand(center + basis * Vector3(sx, sy, sz)) return out ## Suppe für die Hindernis-AABB (Trimesh): inline-vertices oder aus der ## Quelle-Datei (trimesh_aus_datei, static Cache). Leer bei nicht-Trimesh / ## fehlender Datei — der Caller fällt auf die Mess-AABB aus. static func _hindernis_suppe(col: Dictionary) -> Array: if str(col.get("shape", "box")) != "trimesh": return [] var verts_v: Variant = col.get("vertices", null) if verts_v == null and col.has("quelle"): verts_v = trimesh_aus_datei(str(col["quelle"])) return verts_v if verts_v is Array else []