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
+33
-3
@@ -73,6 +73,15 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector
|
||||
agent.target_desired_distance = 4.0
|
||||
agent.radius = 0.35
|
||||
agent.height = 1.2
|
||||
# Steer around street furniture instead of walking into it (props publish a
|
||||
# NavigationObstacle3D; the navmesh itself stays a flat walkable plane).
|
||||
agent.avoidance_enabled = true
|
||||
agent.neighbor_distance = 4.0
|
||||
agent.max_neighbors = 8
|
||||
agent.time_horizon_agents = 0.6
|
||||
agent.time_horizon_obstacles = 1.2
|
||||
agent.max_speed = FLEE_SPEED
|
||||
agent.velocity_computed.connect(_on_safe_velocity)
|
||||
add_child(agent)
|
||||
|
||||
# Normalize intent targets: approach the entrance, stop near the door.
|
||||
@@ -98,6 +107,7 @@ var _sign_prop: MeshInstance3D = null
|
||||
var _legs: Array[MeshInstance3D] = []
|
||||
var _arms: Array[MeshInstance3D] = []
|
||||
var _theater_t := 0.0
|
||||
var _avoidance_active := false
|
||||
var _photo_prop_base_y := 0.0
|
||||
var _sign_prop_base_y := 0.0
|
||||
const SKIN_TONE := Color(0.88, 0.72, 0.60)
|
||||
@@ -527,6 +537,7 @@ func _advance_fsm(delta: float) -> void:
|
||||
var leave_requested_by_lifetime := false
|
||||
|
||||
func _move_toward_target(delta: float) -> void:
|
||||
_avoidance_active = false
|
||||
if agent == null:
|
||||
_move_direct(delta)
|
||||
return
|
||||
@@ -575,14 +586,33 @@ func _move_toward_target(delta: float) -> void:
|
||||
return
|
||||
dir = dir.normalized()
|
||||
var speed := FLEE_SPEED if reaction_mode == "flee" else 1.3
|
||||
velocity.x = dir.x * speed
|
||||
velocity.z = dir.z * speed
|
||||
velocity.y -= 9.8 * delta
|
||||
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
|
||||
if agent.avoidance_enabled:
|
||||
# The navigation server returns the obstacle-aware velocity through
|
||||
# velocity_computed; never dash straight at the point in the meantime.
|
||||
_avoidance_active = true
|
||||
agent.velocity = Vector3(dir.x * speed, 0.0, dir.z * speed)
|
||||
else:
|
||||
velocity.x = dir.x * speed
|
||||
velocity.z = dir.z * speed
|
||||
velocity.y -= 9.8 * delta
|
||||
move_and_slide()
|
||||
|
||||
## Safe velocity from NavigationServer avoidance: move with the steered vector.
|
||||
func _on_safe_velocity(safe: Vector3) -> void:
|
||||
if not _avoidance_active:
|
||||
return
|
||||
velocity.x = safe.x
|
||||
velocity.z = safe.z
|
||||
if is_on_floor():
|
||||
velocity.y = 0.0
|
||||
else:
|
||||
velocity.y -= 9.8 * get_physics_process_delta_time()
|
||||
move_and_slide()
|
||||
|
||||
## Fallback used when no navigation mesh is available (e.g. minimal scenes).
|
||||
func _move_direct(delta: float) -> void:
|
||||
_avoidance_active = false
|
||||
var to_target := target_position - global_position
|
||||
to_target.y = 0.0
|
||||
var dist := to_target.length()
|
||||
|
||||
+183
-20
@@ -335,35 +335,112 @@ func _build_street_furniture() -> void:
|
||||
tree.add_child(crown)
|
||||
root.add_child(tree)
|
||||
|
||||
## Street-furniture placement table. Positions are in world space on the
|
||||
## sidewalk top (y = 0.18) and grouped by zone, so a cluster reads as street
|
||||
## furniture instead of a scattered list. `accessory` names a small procedural
|
||||
## addition (bag, crate, cone) for one-off variation.
|
||||
const PROP_PLACEMENTS := [
|
||||
# Entrance: bollards flank the foot of the steps, bins catch people who
|
||||
# stop at the door.
|
||||
{"zone": "entrance", "prop": "bollard", "x": -5.6, "z": 4.7},
|
||||
{"zone": "entrance", "prop": "bollard", "x": -4.2, "z": 4.7},
|
||||
{"zone": "entrance", "prop": "bollard", "x": 4.2, "z": 4.7},
|
||||
{"zone": "entrance", "prop": "bollard", "x": 5.6, "z": 4.7},
|
||||
{"zone": "entrance", "prop": "trash_bin", "x": -6.6, "z": 4.9, "accessory": "bag"},
|
||||
{"zone": "entrance", "prop": "trash_bin", "x": 6.6, "z": 4.9},
|
||||
# Mid-block: benches face the boulevard, planters break up the long run.
|
||||
{"zone": "mid_block", "prop": "bench", "x": -8.8, "z": 6.4, "rot": 180.0},
|
||||
{"zone": "mid_block", "prop": "bench", "x": 8.8, "z": 6.4, "rot": 180.0},
|
||||
{"zone": "mid_block", "prop": "planter_box", "x": -11.8, "z": 8.8},
|
||||
{"zone": "mid_block", "prop": "planter_box", "x": 11.8, "z": 8.8},
|
||||
# Curb: newspaper boxes and a hydrant along the roadway edge.
|
||||
{"zone": "curb", "prop": "newspaper_box", "x": -9.6, "z": 11.0, "rot": -12.0},
|
||||
{"zone": "curb", "prop": "newspaper_box", "x": -8.9, "z": 11.0, "rot": -12.0,
|
||||
"accessory": "crate"},
|
||||
{"zone": "curb", "prop": "fire_hydrant", "x": 8.4, "z": 11.2,
|
||||
"accessory": "cone"},
|
||||
]
|
||||
|
||||
## The door, steps, and the lane guests walk in through stay clear of props.
|
||||
const PROP_EXCLUSIONS := [
|
||||
{"name": "steps and door", "x0": -4.6, "x1": 4.6, "z0": -4.6, "z1": 4.55},
|
||||
{"name": "guest approach lane", "x0": -1.4, "x1": 1.4, "z0": 4.55, "z1": 14.0},
|
||||
]
|
||||
const PROP_BOUNDS := {"x0": -14.0, "x1": 14.0, "z0": 4.55, "z1": 11.8}
|
||||
## Two prop centres closer than this read as one blob (newspaper boxes are
|
||||
## deliberately lined up, so this stays below their 0.7 m spacing).
|
||||
const PROP_MIN_SPACING := 0.6
|
||||
|
||||
static func _variant_seed(index: int) -> int:
|
||||
return index * 7919 + 13
|
||||
|
||||
## Deterministic per-placement variation. The same index always gives the same
|
||||
## rotation and scale, so the street is reproducible while repeats differ.
|
||||
static func variant_for(index: int, base_rotation: float = 0.0) -> Dictionary:
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = _variant_seed(index)
|
||||
return {
|
||||
"rotation": base_rotation + rng.randf_range(-2.0, 2.0),
|
||||
"scale": rng.randf_range(0.96, 1.04),
|
||||
"roll": rng.randf(),
|
||||
}
|
||||
|
||||
## Static validation used by tests: reject duplicate, out-of-bounds, or
|
||||
## overlapping placements, and any prop inside the door/steps/approach lane.
|
||||
static func placement_problems(placements: Array = PROP_PLACEMENTS) -> Array[String]:
|
||||
var problems: Array[String] = []
|
||||
var seen := {}
|
||||
for i in placements.size():
|
||||
var entry: Dictionary = placements[i]
|
||||
var prop := str(entry.get("prop", ""))
|
||||
var x := float(entry.get("x", 0.0))
|
||||
var z := float(entry.get("z", 0.0))
|
||||
var key := "%s@%.2f,%.2f" % [prop, x, z]
|
||||
if seen.has(key):
|
||||
problems.append("duplicate placement %s" % key)
|
||||
seen[key] = true
|
||||
if x < float(PROP_BOUNDS["x0"]) or x > float(PROP_BOUNDS["x1"]) \
|
||||
or z < float(PROP_BOUNDS["z0"]) or z > float(PROP_BOUNDS["z1"]):
|
||||
problems.append("%s at (%.1f, %.1f) is outside the sidewalk" % [prop, x, z])
|
||||
for zone in PROP_EXCLUSIONS:
|
||||
if x > float(zone["x0"]) and x < float(zone["x1"]) \
|
||||
and z > float(zone["z0"]) and z < float(zone["z1"]):
|
||||
problems.append("%s at (%.1f, %.1f) blocks %s" % [prop, x, z, zone["name"]])
|
||||
for j in range(i):
|
||||
var other: Dictionary = placements[j]
|
||||
var dx := x - float(other.get("x", 0.0))
|
||||
var dz := z - float(other.get("z", 0.0))
|
||||
if Vector2(dx, dz).length() < PROP_MIN_SPACING:
|
||||
problems.append("%s and %s at (%.1f, %.1f) overlap" % [
|
||||
prop, str(other.get("prop", "")), x, z])
|
||||
return problems
|
||||
|
||||
## Street furniture built from the generated GLB props (tools/model_pipeline.py).
|
||||
## The meshes are real 3D models (bollards, benches, bins, planters, newspaper
|
||||
## boxes, a hydrant) rather than scaled boxes, and every placement is fixed so
|
||||
## the street looks the same on every run.
|
||||
## boxes, a hydrant) rather than scaled boxes. Placements and their deterministic
|
||||
## variants are fixed, so the street looks the same on every run.
|
||||
func _build_street_detail() -> void:
|
||||
var detail := Node3D.new()
|
||||
detail.name = "StreetDetail"
|
||||
root.add_child(detail)
|
||||
# Bollards flank the foot of the steps.
|
||||
for x in [-5.6, -4.2, 4.2, 5.6]:
|
||||
_add_prop_model(detail, "bollard", Vector3(x, 0.18, 4.7))
|
||||
# Benches face the boulevard.
|
||||
_add_prop_model(detail, "bench", Vector3(-8.8, 0.18, 6.4), 180.0)
|
||||
_add_prop_model(detail, "bench", Vector3(8.8, 0.18, 6.4), 180.0)
|
||||
# Bins where people leave the steps.
|
||||
_add_prop_model(detail, "trash_bin", Vector3(-6.6, 0.18, 4.9))
|
||||
_add_prop_model(detail, "trash_bin", Vector3(6.6, 0.18, 4.9))
|
||||
# Planters break up the long sidewalk run.
|
||||
_add_prop_model(detail, "planter_box", Vector3(-11.8, 0.18, 8.8))
|
||||
_add_prop_model(detail, "planter_box", Vector3(11.8, 0.18, 8.8))
|
||||
# Newspaper boxes and a hydrant along the curb.
|
||||
_add_prop_model(detail, "newspaper_box", Vector3(-9.6, 0.18, 11.0), -12.0)
|
||||
_add_prop_model(detail, "newspaper_box", Vector3(-8.9, 0.18, 11.0), -12.0)
|
||||
_add_prop_model(detail, "fire_hydrant", Vector3(8.4, 0.18, 11.2))
|
||||
for index in PROP_PLACEMENTS.size():
|
||||
var entry: Dictionary = PROP_PLACEMENTS[index]
|
||||
var base_rot := float(entry.get("rot", 0.0))
|
||||
var variant := variant_for(index, base_rot)
|
||||
var holder := _add_prop_model(detail, str(entry["prop"]),
|
||||
Vector3(float(entry["x"]), 0.18, float(entry["z"])),
|
||||
float(variant["rotation"]), index)
|
||||
holder.scale = Vector3.ONE * float(variant["scale"])
|
||||
var accessory := str(entry.get("accessory", ""))
|
||||
if accessory != "":
|
||||
_add_prop_accessory(holder, accessory)
|
||||
|
||||
## Place one generated prop. Falls back to simple primitives when the `.glb` is
|
||||
## missing (fresh checkout before `godot --import`) so the world still builds.
|
||||
## A static collider is fitted from the model bounds (cylinder for round props,
|
||||
## box for everything else), so props block the guard and NPCs.
|
||||
func _add_prop_model(parent: Node3D, prop: String, pos: Vector3,
|
||||
rot_y_deg: float = 0.0) -> Node3D:
|
||||
rot_y_deg: float = 0.0, variant_index: int = -1) -> Node3D:
|
||||
var holder := Node3D.new()
|
||||
holder.name = "Prop_%s_%d" % [prop, parent.get_child_count()]
|
||||
holder.position = pos
|
||||
@@ -373,11 +450,97 @@ func _add_prop_model(parent: Node3D, prop: String, pos: Vector3,
|
||||
holder.add_child(model)
|
||||
loaded_models.append(prop)
|
||||
else:
|
||||
holder.add_child(_fallback_prop(prop))
|
||||
var fallback := _fallback_prop(prop)
|
||||
holder.add_child(fallback)
|
||||
fallback_props.append(prop)
|
||||
model = fallback
|
||||
parent.add_child(holder)
|
||||
_add_prop_collision(holder, prop, model)
|
||||
return holder
|
||||
|
||||
## Fit a simple collider to the prop's meshes (in holder-local space, so the
|
||||
## placement scale and rotation carry over).
|
||||
func _add_prop_collision(holder: Node3D, prop: String, model: Node3D) -> void:
|
||||
var bounds := _model_bounds(model, holder)
|
||||
if bounds.size == Vector3.ZERO:
|
||||
return
|
||||
var body := StaticBody3D.new()
|
||||
body.name = "Collision"
|
||||
var collider := CollisionShape3D.new()
|
||||
collider.name = "Shape"
|
||||
var round_props := ["bollard", "trash_bin", "fire_hydrant"]
|
||||
if prop in round_props:
|
||||
var cylinder := CylinderShape3D.new()
|
||||
cylinder.radius = maxf(bounds.size.x, bounds.size.z) * 0.5
|
||||
cylinder.height = bounds.size.y
|
||||
collider.shape = cylinder
|
||||
else:
|
||||
var box := BoxShape3D.new()
|
||||
box.size = bounds.size
|
||||
collider.shape = box
|
||||
collider.position = bounds.get_center()
|
||||
body.add_child(collider)
|
||||
holder.add_child(body)
|
||||
# Publish the footprint to NavigationServer so guest avoidance steers around
|
||||
# the prop even though the navmesh itself is a flat walkable plane.
|
||||
var obstacle := NavigationObstacle3D.new()
|
||||
obstacle.name = "Avoidance"
|
||||
obstacle.radius = maxf(bounds.size.x, bounds.size.z) * 0.5 + 0.25
|
||||
obstacle.height = maxf(bounds.size.y, 0.5)
|
||||
obstacle.position = Vector3(bounds.get_center().x, 0.0, bounds.get_center().z)
|
||||
holder.add_child(obstacle)
|
||||
|
||||
## AABB of every mesh under `model`, expressed relative to `ancestor`.
|
||||
func _model_bounds(model: Node3D, ancestor: Node3D) -> AABB:
|
||||
var bounds := AABB()
|
||||
var found := false
|
||||
for node in model.find_children("*", "MeshInstance3D", true, false):
|
||||
var mi := node as MeshInstance3D
|
||||
if mi.mesh == null:
|
||||
continue
|
||||
var xform := _transform_to_ancestor(mi, ancestor)
|
||||
var box: AABB = xform * mi.mesh.get_aabb()
|
||||
bounds = box if not found else bounds.merge(box)
|
||||
found = true
|
||||
return bounds
|
||||
|
||||
func _transform_to_ancestor(node: Node, ancestor: Node) -> Transform3D:
|
||||
var xform := Transform3D.IDENTITY
|
||||
var current := node
|
||||
while current != null and current != ancestor:
|
||||
if current is Node3D:
|
||||
xform = (current as Node3D).transform * xform
|
||||
current = current.get_parent()
|
||||
return xform
|
||||
|
||||
## Small deterministic clutter next to a prop: a bag by the bin, a crate by the
|
||||
## newspaper boxes, a cone by the hydrant.
|
||||
func _add_prop_accessory(holder: Node3D, kind: String) -> void:
|
||||
match kind:
|
||||
"bag":
|
||||
var bag := MeshInstance3D.new()
|
||||
var sphere := SphereMesh.new()
|
||||
sphere.radius = 0.22
|
||||
sphere.height = 0.5
|
||||
bag.mesh = sphere
|
||||
bag.material_override = Materials.flat(Color(0.10, 0.11, 0.13), "rubber")
|
||||
bag.position = Vector3(0.38, 0.22, 0.06)
|
||||
holder.add_child(bag)
|
||||
_add_box(holder, Vector3(0.12, 0.10, 0.12), Vector3(0.38, 0.48, 0.06),
|
||||
Color(0.16, 0.17, 0.20), false, null, Vector2.ONE, "rubber")
|
||||
"crate":
|
||||
_add_box(holder, Vector3(0.46, 0.40, 0.40), Vector3(0.48, 0.20, 0.08),
|
||||
Color(0.45, 0.31, 0.18), true, null, Vector2.ONE, "wood")
|
||||
_add_box(holder, Vector3(0.48, 0.03, 0.42), Vector3(0.48, 0.41, 0.08),
|
||||
Color(0.35, 0.23, 0.13), false, null, Vector2.ONE, "wood")
|
||||
"cone":
|
||||
_add_cylinder_mesh(holder, 0.16, 0.55, Vector3(-0.42, 0.28, 0.10),
|
||||
Color(0.95, 0.36, 0.10), "painted_metal")
|
||||
_add_box(holder, Vector3(0.38, 0.05, 0.38), Vector3(-0.42, 0.03, 0.10),
|
||||
Color(0.12, 0.12, 0.14), false, null, Vector2.ONE, "rubber")
|
||||
_add_cylinder_mesh(holder, 0.11, 0.08, Vector3(-0.42, 0.34, 0.10),
|
||||
Color(0.92, 0.92, 0.9), "painted_metal")
|
||||
|
||||
func _load_prop_scene(prop: String) -> Node3D:
|
||||
var path := MODEL_DIR + prop + ".glb"
|
||||
if not ResourceLoader.exists(path):
|
||||
|
||||
Reference in new issue
Block a user