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):
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
extends SceneTree
|
||||
## Prop-collision smoke: proves the guard and an NPC cannot walk through street
|
||||
## furniture, and that a guest slides around a bench instead of passing through
|
||||
## it. Windowed (the guard reads captured-mouse input):
|
||||
##
|
||||
## godot --path . --script res://src/test/smoke_props.gd
|
||||
##
|
||||
## Exit code 0 = pass, 1 = failure.
|
||||
|
||||
const MAX_FRAMES := 1800
|
||||
const GUARD_PUSH_FRAMES := 100
|
||||
|
||||
var frame := 0
|
||||
var gm: Node = null
|
||||
var started := false
|
||||
var step := 0
|
||||
var step_frame := 0
|
||||
var failures: Array[String] = []
|
||||
var bench: Node3D = null
|
||||
var bench_center := Vector3.ZERO
|
||||
var bench_body: StaticBody3D = null
|
||||
var guard_start := Vector3.ZERO
|
||||
var npc: Node3D = null
|
||||
var npc_start := Vector3.ZERO
|
||||
|
||||
func _initialize() -> void:
|
||||
root.size = Vector2i(1280, 720)
|
||||
root.add_child(load("res://scenes/main.tscn").instantiate())
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
frame += 1
|
||||
if gm == null:
|
||||
gm = root.find_child("GameManager", true, false)
|
||||
return false
|
||||
if not started:
|
||||
gm.call("_on_venue_selected", "hollywood_building")
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||
started = true
|
||||
return false
|
||||
if frame > MAX_FRAMES:
|
||||
_fail("timeout before the collision checks finished")
|
||||
return true
|
||||
step_frame += 1
|
||||
match step:
|
||||
0:
|
||||
_find_bench()
|
||||
if bench == null:
|
||||
return false
|
||||
# Stand a couple of metres in front of the bench and walk into it.
|
||||
var start := bench_center + Vector3(0, 0, 2.2)
|
||||
start.y = 0.1
|
||||
gm.guard.global_position = start
|
||||
# Face the bench (-z): with the guard's basis, forward input at yaw PI
|
||||
# walks toward lower z.
|
||||
gm.guard.rotation.y = PI
|
||||
guard_start = start
|
||||
Input.action_press("move_forward")
|
||||
step = 1
|
||||
step_frame = 0
|
||||
1:
|
||||
if step_frame >= GUARD_PUSH_FRAMES:
|
||||
Input.action_release("move_forward")
|
||||
_check(guard_start.z - gm.guard.global_position.z > 0.5,
|
||||
"guard walked toward the bench")
|
||||
_check(gm.guard.global_position.z > bench_center.z + 0.25,
|
||||
"guard stopped at the bench instead of passing through it")
|
||||
_check(not _inside_bench(gm.guard.global_position),
|
||||
"guard is not inside the bench collider")
|
||||
# Step the guard aside so the guest test hits the bench, not the player.
|
||||
gm.guard.global_position = bench_center + Vector3(4.0, -0.08, 2.2)
|
||||
_stage_npc()
|
||||
step = 2
|
||||
step_frame = 0
|
||||
2:
|
||||
if step_frame % 120 == 0 and npc != null and is_instance_valid(npc):
|
||||
print("npc dbg pos=%s vel=%s arrived=%s wait=%s" % [str(npc.global_position), str(npc.velocity), str(npc.arrived), str(npc.awaiting_interaction)])
|
||||
if npc == null or not is_instance_valid(npc):
|
||||
_stage_npc()
|
||||
return false
|
||||
if _npc_finished():
|
||||
_check(npc.global_position.z < bench_center.z - 0.4,
|
||||
"guest reached the far side of the bench")
|
||||
_check(not _inside_bench(npc.global_position),
|
||||
"guest is not inside the bench collider")
|
||||
_check(npc_start.distance_to(npc.global_position) > 1.0,
|
||||
"guest moved while routing around the prop")
|
||||
_finish()
|
||||
return true
|
||||
return false
|
||||
|
||||
func _find_bench() -> void:
|
||||
var detail := gm.get_node_or_null("World/StreetDetail")
|
||||
if detail == null:
|
||||
# The world root name differs between scene builds; search instead.
|
||||
detail = root.find_child("StreetDetail", true, false)
|
||||
if detail == null:
|
||||
_fail("StreetDetail not found")
|
||||
return
|
||||
for child in detail.get_children():
|
||||
if str(child.name).begins_with("Prop_bench"):
|
||||
bench = child as Node3D
|
||||
break
|
||||
if bench == null:
|
||||
_fail("no bench prop placed")
|
||||
return
|
||||
bench_center = bench.global_position
|
||||
bench_body = bench.get_node_or_null("Collision") as StaticBody3D
|
||||
if bench_body == null:
|
||||
_fail("bench has no static collider")
|
||||
|
||||
func _stage_npc() -> void:
|
||||
if gm.npcs.is_empty():
|
||||
step = 0
|
||||
step_frame = 0
|
||||
return
|
||||
npc = gm.npcs[0]
|
||||
npc_start = bench_center + Vector3(0, 0.5, 2.5)
|
||||
npc.global_position = npc_start
|
||||
npc.velocity = Vector3.ZERO
|
||||
npc.arrived = false
|
||||
npc.awaiting_interaction = false
|
||||
npc.lifetime_s = 999.0
|
||||
npc.reaction_mode = ""
|
||||
# Target well past the bench forces a straight line through it, and stays
|
||||
# beyond the controller's 3 m "arrived" shortcut so the guest must route.
|
||||
npc.target_position = Vector3(bench_center.x, 0.5, bench_center.z - 5.0)
|
||||
|
||||
func _npc_finished() -> bool:
|
||||
if npc == null or not is_instance_valid(npc):
|
||||
return false
|
||||
return npc.global_position.z < bench_center.z - 0.4 \
|
||||
or npc.global_position.distance_to(npc.target_position) < 0.9
|
||||
|
||||
func _inside_bench(point: Vector3) -> bool:
|
||||
if bench_body == null:
|
||||
return false
|
||||
var collider := bench_body.get_node_or_null("Shape") as CollisionShape3D
|
||||
if collider == null or not (collider.shape is BoxShape3D):
|
||||
return false
|
||||
var box := collider.shape as BoxShape3D
|
||||
var half := box.size * 0.5
|
||||
var center := collider.global_position
|
||||
half.x += 0.1
|
||||
half.z += 0.1
|
||||
return absf(point.x - center.x) < half.x and absf(point.z - center.z) < half.z
|
||||
|
||||
func _check(condition: bool, message: String) -> void:
|
||||
if condition:
|
||||
print("ok: " + message)
|
||||
else:
|
||||
_fail(message)
|
||||
|
||||
func _fail(message: String) -> void:
|
||||
failures.append(message)
|
||||
_finish()
|
||||
|
||||
func _finish() -> void:
|
||||
Input.action_release("move_forward")
|
||||
if failures.is_empty():
|
||||
print("SMOKE OK: props block the guard and guests route around them")
|
||||
quit(0)
|
||||
else:
|
||||
for message in failures:
|
||||
printerr("SMOKE FAIL: " + message)
|
||||
quit(1)
|
||||
@@ -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