scopedvr-map-core/svmap/kollider_bau.gd
slyphax 17a8927fec Map-Kern aus dem Editor übernommen (Stand e8ab954)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:30:18 +01:00

294 lines
12 KiB
GDScript
Raw Permalink 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 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/<oid>.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 []