294 lines
12 KiB
GDScript
294 lines
12 KiB
GDScript
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 []
|