Give street props colliders, zoned placements and variants
Props were decoration: the guard and guests walked straight through benches, bins, bollards and planters. They now block movement and steer guests around. Collision - _add_prop_model() fits a StaticBody3D from the model's mesh bounds in holder-local space: a cylinder for bollard/bin/hydrant, a box otherwise, so placement rotation and variant scale carry over. - Fallback primitives go through the same fit, so a missing .glb cannot silently remove collision. - Each prop also publishes a NavigationObstacle3D; NpcAvatar enables agent avoidance and moves with the safe velocity from velocity_computed. The navmesh stays a flat walkable plane and avoidance does the steering. Placement - PROP_PLACEMENTS groups furniture by entrance, mid-block and curb zones, with a static placement_problems() validator for bounds, overlap/min-spacing, duplicates, and the door/steps/guest-lane exclusions. - variant_for(index) gives deterministic yaw jitter, subtle scale, and optional clutter (bag by the bin, crate by the newspaper boxes, cone by the hydrant). Verification - New windowed smoke_props.gd: guard walks into a bench and stops; a guest headed beyond the bench routes around it and never enters the collider. - Headless 182 -> 187: placement validation and its failure modes, variant determinism/range, collider + obstacle on every placed prop, fallback collider. - Other smokes (PLAN verbs, crowd, emergency, timeline) still pass; Python tooling 53/53; screenshots recaptured; compare 9 good / 0 bad. - Version 0.4.0. TODO: collision/placement section moved to DONE; prop pipeline checks remain.
This commit is contained in:
1 parent
a3e90accdd
commit
bb40a98537
18 files changed
+518
-66
No files matched your search
@@ -83,6 +83,96 @@ func test_world_places_generated_props() -> bool:
|
||||
root.free()
|
||||
return a and b and c and d
|
||||
|
||||
func test_prop_placements_are_valid() -> bool:
|
||||
var problems := WorldBuilder.placement_problems()
|
||||
return TestUtils.check(problems.is_empty(),
|
||||
"prop placements keep the door, steps and approach lane clear (%s)" % str(problems))
|
||||
|
||||
func test_placement_validator_catches_problems() -> bool:
|
||||
var bad: Array = WorldBuilder.PROP_PLACEMENTS.duplicate(true)
|
||||
bad.append({"zone": "test", "prop": "bench", "x": 0.0, "z": 2.0})
|
||||
bad.append({"zone": "test", "prop": "bench", "x": 0.0, "z": 2.0})
|
||||
var problems := WorldBuilder.placement_problems(bad)
|
||||
var a := TestUtils.check(not problems.is_empty(), "validator rejects bad placements")
|
||||
var joined := " ".join(problems)
|
||||
var b := TestUtils.check(joined.contains("steps and door"),
|
||||
"validator reports the door/steps exclusion")
|
||||
var c := TestUtils.check(joined.contains("duplicate"), "validator reports a duplicate")
|
||||
return a and b and c
|
||||
|
||||
func test_prop_variants_are_deterministic() -> bool:
|
||||
var first := WorldBuilder.variant_for(3, 180.0)
|
||||
var second := WorldBuilder.variant_for(3, 180.0)
|
||||
var other := WorldBuilder.variant_for(4, 180.0)
|
||||
var a := TestUtils.check(is_equal_approx(first["rotation"], second["rotation"])
|
||||
and is_equal_approx(first["scale"], second["scale"]),
|
||||
"the same placement always gets the same variant")
|
||||
var b := TestUtils.check(not is_equal_approx(first["rotation"], other["rotation"]),
|
||||
"different placements get different rotations")
|
||||
var c := TestUtils.check(first["scale"] >= 0.95 and first["scale"] <= 1.05,
|
||||
"variant scale stays subtle")
|
||||
var d := TestUtils.check(absf(first["rotation"] - 180.0) <= 2.01,
|
||||
"variant rotation stays near the authored angle")
|
||||
return a and b and c and d
|
||||
|
||||
func test_every_prop_has_a_collider() -> bool:
|
||||
var root := Node3D.new()
|
||||
var builder := WorldBuilder.new(root)
|
||||
builder.build()
|
||||
var detail := root.get_node_or_null("StreetDetail")
|
||||
var problems: Array[String] = []
|
||||
if detail == null:
|
||||
problems.append("no StreetDetail")
|
||||
else:
|
||||
for holder in detail.get_children():
|
||||
var body := holder.get_node_or_null("Collision")
|
||||
if body == null or not (body is StaticBody3D):
|
||||
problems.append("%s has no StaticBody3D" % holder.name)
|
||||
continue
|
||||
var collider := body.get_node_or_null("Shape") as CollisionShape3D
|
||||
if collider == null or collider.shape == null:
|
||||
problems.append("%s has no collision shape" % holder.name)
|
||||
continue
|
||||
var size: Vector3
|
||||
if collider.shape is BoxShape3D:
|
||||
size = (collider.shape as BoxShape3D).size
|
||||
elif collider.shape is CylinderShape3D:
|
||||
var cylinder := collider.shape as CylinderShape3D
|
||||
size = Vector3(cylinder.radius * 2.0, cylinder.height, cylinder.radius * 2.0)
|
||||
else:
|
||||
problems.append("%s uses an unexpected shape" % holder.name)
|
||||
continue
|
||||
if size.x <= 0.05 or size.y <= 0.05 or size.z <= 0.05:
|
||||
problems.append("%s has a degenerate collider" % holder.name)
|
||||
if collider.position.y + size.y * 0.5 <= 0.05:
|
||||
problems.append("%s collider sits below the ground" % holder.name)
|
||||
if holder.get_node_or_null("Avoidance") == null:
|
||||
problems.append("%s has no navigation obstacle" % holder.name)
|
||||
root.free()
|
||||
return TestUtils.check(problems.is_empty(), "every prop has a static collider (%s)" % str(problems))
|
||||
|
||||
func test_fallback_props_get_the_same_collider() -> bool:
|
||||
## A missing `.glb` must not silently remove collision: fallback primitives go
|
||||
## through the same bounds fit as the imported models.
|
||||
var root := Node3D.new()
|
||||
var builder := WorldBuilder.new(root)
|
||||
var holder := Node3D.new()
|
||||
var fallback := builder._fallback_prop("bench")
|
||||
holder.add_child(fallback)
|
||||
builder._add_prop_collision(holder, "bench", fallback)
|
||||
var body := holder.get_node_or_null("Collision") as StaticBody3D
|
||||
var a := TestUtils.check(body != null, "fallback bench gets a static body")
|
||||
var collider := body.get_node_or_null("Shape") as CollisionShape3D if body else null
|
||||
var b := TestUtils.check(collider != null and collider.shape is BoxShape3D,
|
||||
"fallback bench gets a box shape")
|
||||
var width: float = (collider.shape as BoxShape3D).size.x if b else 0.0
|
||||
var c := TestUtils.check(width > 1.0, "fallback collider follows the bench width (%.2f)" % width)
|
||||
var d := TestUtils.check(holder.get_node_or_null("Avoidance") is NavigationObstacle3D,
|
||||
"fallback bench publishes a navigation obstacle")
|
||||
holder.free()
|
||||
root.free()
|
||||
return a and b and c and d
|
||||
|
||||
func test_world_facade_windows_light_at_night() -> bool:
|
||||
var root := Node3D.new()
|
||||
var builder := WorldBuilder.new(root)
|
||||
|
||||
Reference in new issue
Block a user