Playtest-Kollision: echter Registry-Fußabdruck statt Pick-Radius-Kugel

Sly-Befund 20.10.: Im Editor-Testmodus hing man an unsichtbaren Wänden weit
VOR den Gebäuden. Ursache: _collect_obstacles nutzte die Viewport-Metas
pick_radius (Kugel-Näherung = volle Raumdiagonale + Zuschlag) — bei der
Kaserne (17×35 m) blockte das ~40×40 m. Die Trimesh-Fixes aus t_9558038d
wirken nur im Spiel-Client, der Editor-Playtest baut seine Blockierung
selbst (keine Physik-Welt, nur AABBs).

Fix: Hindernis-AABB aus der Registry (collision.size × scale, um rot_y_deg
konservativ gedreht, Y vom Fußpunkt bis Fußpunkt + Höhe — gleiche
Konvention wie der Client-Kollider). Fallback: Kugel-Näherung bleibt für
Objekte ohne Registry-Eintrag.

Regression in test_playtest.gd: gedreht/ungedreht, Scale, Fallback
(20 checks, alle grün). Suiten: playtest_comfort 51, playtest_spawn 56,
resize_dialog 44, terrain 153 — ALL OK.
This commit is contained in:
Hermes (scopedvr-dev) 2026-10-20 20:11:11 +00:00
parent 1f1a413065
commit fabbbcce20
2 changed files with 134 additions and 3 deletions

View file

@ -145,16 +145,25 @@ func _spawn_start(doc: Dictionary) -> Vector3:
## Hindernis-AABBs sammeln (nur LESEND, keine neue Physik-Welt): ## Hindernis-AABBs sammeln (nur LESEND, keine neue Physik-Welt):
## - Objekt-Marker: die VORHANDENEN Viewport-Metas — pick_radius (x/z- ## - Objekt-Marker: der ECHTE Fußabdruck aus der Registry (collision.size ×
## Halbausdehnung, Kugel-Approximation konservativ) + half_height ## scale, um rot_y_deg konservativ gedreht — Sly-Befund 20.10.: die alte
## (Center-Höhe über dem Fußpunkt → Objekt-Top = 2× half_height). ## pick_radius-Kugel-Näherung blockte ~40×40 m um eine 17×35 m-Kaserne =
## „unsichtbare Wand“ weit vor dem Modell).
## - Bau-Teile: exakt über BuildOps.piece_aabb (Dokument-Teile → AABB). ## - Bau-Teile: exakt über BuildOps.piece_aabb (Dokument-Teile → AABB).
## - Fallback: Objekte ohne Registry-Eintrag behalten die alte Kugel-Näherung.
func _collect_obstacles(doc: Dictionary, view_builder: Node3D) -> Array: func _collect_obstacles(doc: Dictionary, view_builder: Node3D) -> Array:
var out: Array = [] var out: Array = []
var lib := _lade_objekt_registry()
if view_builder != null: if view_builder != null:
for child in view_builder.get_children(): for child in view_builder.get_children():
if child is Node3D and (child as Node3D).has_meta("obj_index"): if child is Node3D and (child as Node3D).has_meta("obj_index"):
var m := child as Node3D var m := child as Node3D
var idx := int(m.get_meta("obj_index", -1))
var aabb_v: Variant = _objekt_aabb_aus_registry(doc, idx, lib, m)
if aabb_v != null:
out.append(aabb_v)
continue
# Fallback: alte Kugel-Näherung (Objekt fehlt in der Registry).
var r := float(m.get_meta("pick_radius", 1.0)) var r := float(m.get_meta("pick_radius", 1.0))
var ch := float(m.get_meta("half_height", 0.5)) var ch := float(m.get_meta("half_height", 0.5))
var p := m.position var p := m.position
@ -170,6 +179,50 @@ func _collect_obstacles(doc: Dictionary, view_builder: Node3D) -> Array:
return out return out
## objects.json einmal je Sammel-Lauf laden (klein genug, Playtest startet
## selten) — Key „objects“ → Dictionary oid → Entry.
func _lade_objekt_registry() -> Dictionary:
var parsed: Variant = JSON.parse_string(
FileAccess.get_file_as_string(SvmapIO.MANIFEST_PATH))
if parsed is Dictionary and (parsed as Dictionary).has("objects"):
return (parsed as Dictionary)["objects"]
return {}
## Rotierter Fußabdruck-AABB eines Objekts aus der Registry — null wenn keine
## belastbaren Daten da sind (Caller fällt auf die Kugel-Näherung zurück).
## Y läuft vom Fußpunkt (Marker-Position) bis Fußpunkt + size.y × scale —
## gleiche Konvention wie der Client-Kollider.
func _objekt_aabb_aus_registry(doc: Dictionary, idx: int, lib: Dictionary,
marker: Node3D) -> Variant:
var objects: Array = doc.get("objects", [])
if idx < 0 or idx >= objects.size() or not (objects[idx] is Dictionary):
return null
var obj: Dictionary = objects[idx]
var entry: Dictionary = lib.get(str(obj.get("id", "")), {})
if entry.is_empty():
return null
var col: Dictionary = entry.get("collision", {})
var size_v: Variant = col.get("size", null)
if not (size_v is Array) or (size_v as Array).size() != 3:
return null
var skala := float(obj.get("scale", 1.0))
var sy := float(size_v[1]) * skala
var hx := float(size_v[0]) * skala * 0.5
var hz := float(size_v[2]) * skala * 0.5
if hx <= 0.0 or hz <= 0.0:
return null
var p := marker.position
var rot := deg_to_rad(float(obj.get("rot_y_deg", 0.0)))
var c := cos(rot)
var s := sin(rot)
# 4 Grundflächen-Ecken rotieren → konservative xz-Ausdehnung (symmetrisch
# um die Position, da der Fußabdruck zentriert ist).
var ex := maxf(absf(hx * c + hz * s), absf(hx * c - hz * s))
var ez := maxf(absf(hx * s + hz * c), absf(hx * s - hz * c))
return AABB(Vector3(p.x - ex, p.y, p.z - ez), Vector3(ex * 2.0, sy, ez * 2.0))
# ------------------------------------------------------------------- HUD ---- # ------------------------------------------------------------------- HUD ----
func _build_hud() -> void: func _build_hud() -> void:

View file

@ -57,6 +57,7 @@ func _run_all() -> void:
_test_terrain_height_matches_document() _test_terrain_height_matches_document()
_test_obstacle_blocks_player() _test_obstacle_blocks_player()
_test_run_and_jump() _test_run_and_jump()
_test_obstacles_use_registry_footprint()
_test_exit_restores_editor() _test_exit_restores_editor()
_finish() _finish()
@ -210,6 +211,83 @@ func _test_obstacle_blocks_player() -> void:
body.get("obstacles").erase(wall) body.get("obstacles").erase(wall)
## --- Test 3b: Hindernisse = echter Registry-Fußabdruck (Sly 20.10.) ---------
## Die alte pick_radius-Kugel-Näherung blockte ~40×40 m um eine 17×35 m-Kaserne
## (= „unsichtbare Wand“ weit vor dem Modell). Jetzt: collision.size × scale,
## um rot_y_deg gedreht; Fallback Kugel-Näherung nur ohne Registry-Eintrag.
func _test_obstacles_use_registry_footprint() -> void:
print("Testgruppe: Hindernis-AABBs aus Registry-Fußabdruck")
var pt := _pt()
if pt == null:
_check(false, "PlaytestMode noch aktiv (Vorbedingung)")
return
# Faken Viewport-Baukasten: 1 Marker mit obj_index + absurder Pick-Kugel.
var builder := Node3D.new()
var marker := Node3D.new()
marker.set_meta("obj_index", 0)
marker.set_meta("pick_radius", 20.3)
marker.set_meta("half_height", 3.1)
marker.position = Vector3(0.0, 0.0, -5.0)
builder.add_child(marker)
root.add_child(builder)
# Kaserne um 90° gedreht: lange Achse (35.5 m) liegt dann auf X.
var doc: Dictionary = {"objects": [{
"id": "barracks_001", "scale": 1.0, "rot_y_deg": 90.0, "pos": [0.0, 0.0, -5.0],
}]}
var obs: Array = pt.call("_collect_obstacles", doc, builder)
_check(obs.size() == 1, "Genau 1 Hindernis gesammelt (ist %d)" % obs.size())
if obs.size() == 1:
var ab: AABB = obs[0]
_check(ab.size.x < 38.0,
"X-Ausdehnung unter Pick-Kugel-Blob (ist %.1f < 38)" % ab.size.x)
_check(absf(ab.size.x - 35.456) < 1.2,
"Gedrehte Länge auf X ≈ 35.5 m (ist %.2f)" % ab.size.x)
_check(absf(ab.size.z - 16.99) < 1.2,
"Gedrehte Breite auf Z ≈ 17.0 m (ist %.2f)" % ab.size.z)
_check(absf(ab.size.y - 6.247) < 0.5,
"Höhe ≈ 6.2 m (ist %.2f)" % ab.size.y)
_check(absf(ab.position.y - 0.0) < 0.01, "Fußpunkt an Marker-Y")
# Rotation 0: Länge wieder auf Z.
var doc0: Dictionary = {"objects": [{
"id": "barracks_001", "scale": 1.0, "rot_y_deg": 0.0, "pos": [0.0, 0.0, -5.0],
}]}
var obs0: Array = pt.call("_collect_obstacles", doc0, builder)
if obs0.size() == 1:
var ab0: AABB = obs0[0]
_check(absf(ab0.size.z - 35.456) < 1.2,
"Rotation 0: Länge auf Z ≈ 35.5 m (ist %.2f)" % ab0.size.z)
else:
_check(false, "Rotation-0-Fall liefert 1 Hindernis")
# Scale wird mitgenommen (2× → doppelte Grundfläche).
var doc2: Dictionary = {"objects": [{
"id": "barracks_001", "scale": 2.0, "rot_y_deg": 0.0, "pos": [0.0, 0.0, -5.0],
}]}
var obs2: Array = pt.call("_collect_obstacles", doc2, builder)
if obs2.size() == 1:
_check(absf((obs2[0] as AABB).size.z - 70.9) < 2.4,
"Scale 2× verdoppelt die Länge (ist %.1f)" % (obs2[0] as AABB).size.z)
else:
_check(false, "Scale-Fall liefert 1 Hindernis")
# Fallback: unbekannte OID → alte Kugel-Näherung bleibt.
var docx: Dictionary = {"objects": [{
"id": "gibts_nicht_999", "scale": 1.0, "rot_y_deg": 0.0, "pos": [0.0, 0.0, -5.0],
}]}
var obsx: Array = pt.call("_collect_obstacles", docx, builder)
if obsx.size() == 1:
var abx: AABB = obsx[0]
_check(absf(abx.size.x - 40.6) < 0.2,
"Fallback: Kugel-Näherung 2×pick_radius (ist %.1f)" % abx.size.x)
else:
_check(false, "Fallback-Fall liefert 1 Hindernis")
builder.queue_free()
## --- Test 4: Rennen/Springen ------------------------------------------------ ## --- Test 4: Rennen/Springen ------------------------------------------------
func _test_run_and_jump() -> void: func _test_run_and_jump() -> void:
print("Testgruppe: Rennen/Springen wirkt korrekt") print("Testgruppe: Rennen/Springen wirkt korrekt")