Integrate the Blender character, gear and prop library
NpcAvatar - select one of 8 library character variants by role/seed (with npc_base and the procedural rig as fallbacks), keep Rig/Head and the existing bone animation - tint only skin/cloth/pants/accessory/hair; fixed detail materials keep their colour, npc_vest keeps its hi-vis vest, firefighter/guard use dark uniform tints - attach caps, hoods, helmets, backpacks, SCBA tanks, lanyards and hand items at the documented head/back/chest/hand points (primitive fallbacks retained) WorldBuilder - prefer assets/models/blender/props/ for the six replacements and the new street dressing (street lamp/tree, palms, planters, A-frame, cone, barrier, stanchion, podium, handrail, wall lantern, double door, marquee, sedan) - fit the same colliders/navigation obstacles and keep the placement validator; collect library emissives (bulbs, marquee face, wall lanterns, door LED, sedan lights) for set_night_mode() - add a library triangle counter + 60k budget check (current ~27,986); toggle prop gallery to the library models; fix the marquee label placement Tests/smoke - character selection/gear attach/fixed-material tint tests; library prop load, emissive and triangle-budget tests; smoke_props routes a guest around a stanchion in open sidewalk (the old bench route is now crowded by trees) - headless 222/222, Python 68/68, all smokes pass, compare 11 good / 0 bad Docs: TODO Phase 3.5 moved to DONE, README/architecture/blender-assets/plan describe the integrated runtime, MEMORY records the integration rules.
This commit is contained in:
1 parent
d513e0b82c
commit
d8cfddbf89
23 files changed
+531
-165
No files matched your search
@@ -7,6 +7,7 @@ extends SceneTree
|
||||
|
||||
const OUT_DIR := "user://screenshots"
|
||||
const MODEL_DIR := "res://assets/models/generated/"
|
||||
const LIBRARY_DIR := "res://assets/models/blender/props/"
|
||||
const PROPS := ["bollard", "bench", "trash_bin", "planter_box",
|
||||
"newspaper_box", "fire_hydrant"]
|
||||
const SPACING := 1.9
|
||||
@@ -74,7 +75,10 @@ func _build_stage() -> void:
|
||||
var columns := [-1.95, 0.0, 1.95]
|
||||
for index in PROPS.size():
|
||||
var prop_name: String = PROPS[index]
|
||||
var packed := ResourceLoader.load(MODEL_DIR + prop_name + ".glb") as PackedScene
|
||||
var path := LIBRARY_DIR + prop_name + ".glb"
|
||||
if not ResourceLoader.exists(path):
|
||||
path = MODEL_DIR + prop_name + ".glb"
|
||||
var packed := ResourceLoader.load(path) as PackedScene
|
||||
if packed == null:
|
||||
continue
|
||||
var model := packed.instantiate() as Node3D
|
||||
|
||||
+27
-17
@@ -19,6 +19,7 @@ var failures: Array[String] = []
|
||||
var bench: Node3D = null
|
||||
var bench_center := Vector3.ZERO
|
||||
var bench_body: StaticBody3D = null
|
||||
var route_center := Vector3.ZERO
|
||||
var guard_start := Vector3.ZERO
|
||||
var npc: Node3D = null
|
||||
var npc_start := Vector3.ZERO
|
||||
@@ -46,12 +47,12 @@ func _process(_delta: float) -> bool:
|
||||
_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)
|
||||
# Stand on the door side of the bench and walk into it (+z). The
|
||||
# street side is crowded with trees/planters now.
|
||||
var start := bench_center - Vector3(0, 0, 2.0)
|
||||
start.y = 0.1
|
||||
gm.guard.global_position = start
|
||||
# Face the bench (-z): yaw 0 looks down -z, so W walks toward it.
|
||||
gm.guard.rotation.y = 0.0
|
||||
gm.guard.rotation.y = PI
|
||||
guard_start = start
|
||||
Input.action_press("move_forward")
|
||||
step = 1
|
||||
@@ -59,9 +60,9 @@ func _process(_delta: float) -> bool:
|
||||
1:
|
||||
if step_frame >= GUARD_PUSH_FRAMES:
|
||||
Input.action_release("move_forward")
|
||||
_check(guard_start.z - gm.guard.global_position.z > 0.5,
|
||||
_check(gm.guard.global_position.z - guard_start.z > 0.5,
|
||||
"guard walked toward the bench")
|
||||
_check(gm.guard.global_position.z > bench_center.z + 0.25,
|
||||
_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")
|
||||
@@ -71,16 +72,14 @@ func _process(_delta: float) -> bool:
|
||||
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.global_position.z > route_center.z + 0.35,
|
||||
"guest reached the far side of the stanchion")
|
||||
_check(npc.global_position.distance_to(route_center) > 0.4,
|
||||
"guest is not inside the stanchion collider")
|
||||
_check(npc_start.distance_to(npc.global_position) > 1.0,
|
||||
"guest moved while routing around the prop")
|
||||
_finish()
|
||||
@@ -112,22 +111,33 @@ func _stage_npc() -> void:
|
||||
step = 0
|
||||
step_frame = 0
|
||||
return
|
||||
var detail := root.find_child("StreetDetail", true, false)
|
||||
route_center = bench_center
|
||||
if detail != null:
|
||||
for child in detail.get_children():
|
||||
if str(child.name).begins_with("Prop_stanchion"):
|
||||
route_center = (child as Node3D).global_position
|
||||
break
|
||||
npc = gm.npcs[0]
|
||||
npc_start = bench_center + Vector3(0, 0.5, 2.5)
|
||||
npc_start = route_center + Vector3(0, 0.5, -2.0)
|
||||
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)
|
||||
if npc.npc != null:
|
||||
# "enter" would send the guest to the door once the path ends; keep this
|
||||
# test focused on routing around the prop.
|
||||
npc.npc.intent = "roam"
|
||||
# A stanchion sits in open sidewalk, so the route is not squeezed by trees
|
||||
# or bins; the target forces a straight line through it.
|
||||
npc.target_position = Vector3(route_center.x, 0.5, route_center.z + 5.5)
|
||||
|
||||
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 \
|
||||
return npc.global_position.z > route_center.z + 0.35 \
|
||||
or npc.global_position.distance_to(npc.target_position) < 0.9
|
||||
|
||||
func _inside_bench(point: Vector3) -> bool:
|
||||
|
||||
@@ -201,6 +201,38 @@ func test_fallback_props_get_the_same_collider() -> bool:
|
||||
root.free()
|
||||
return a and b and c and d
|
||||
|
||||
func test_world_loads_the_blender_library_props() -> bool:
|
||||
var root := Node3D.new()
|
||||
var builder := WorldBuilder.new(root)
|
||||
builder.build()
|
||||
var a := TestUtils.check(builder.fallback_props.is_empty(),
|
||||
"no primitive prop fallback (fallbacks: %s)" % str(builder.fallback_props))
|
||||
var b := TestUtils.check(builder.library_props.has("street_lamp")
|
||||
and builder.library_props.has("street_tree")
|
||||
and builder.library_props.has("bollard")
|
||||
and builder.library_props.has("palm_tree")
|
||||
and builder.library_props.has("sedan"),
|
||||
"replacement and new props come from the library (%d)" % builder.library_props.size())
|
||||
var c := TestUtils.check(root.get_node_or_null("Building/LibraryDoubleDoor") != null
|
||||
and root.get_node_or_null("LibraryMarquee") != null
|
||||
and root.get_node_or_null("ParkedSedan") != null,
|
||||
"library door, marquee and sedan are placed")
|
||||
var d := TestUtils.check(not builder._lamp_bulb_mats.is_empty()
|
||||
and not builder._marquee_emissive.is_empty(),
|
||||
"library lamps/marquee expose emissive materials")
|
||||
builder.set_night_mode(true)
|
||||
var lit := (builder._lamp_bulb_mats[0].emission_energy_multiplier > 0.0
|
||||
and builder._marquee_emissive[0].emission_energy_multiplier > 0.0)
|
||||
builder.set_night_mode(false)
|
||||
var dark := builder._lamp_bulb_mats[0].emission_energy_multiplier <= 0.0
|
||||
var e := TestUtils.check(lit and dark, "night mode drives library emissives")
|
||||
var triangles := builder.library_triangle_count()
|
||||
var f := TestUtils.check(triangles > 0
|
||||
and triangles <= WorldBuilder.LIBRARY_TRIANGLE_BUDGET,
|
||||
"library prop triangles stay inside the budget (%d)" % triangles)
|
||||
root.free()
|
||||
return a and b and c and d and e and f
|
||||
|
||||
func test_world_facade_windows_light_at_night() -> bool:
|
||||
var root := Node3D.new()
|
||||
var builder := WorldBuilder.new(root)
|
||||
|
||||
@@ -128,7 +128,7 @@ func test_walk_to_idle_animation_transition() -> bool:
|
||||
func test_skinned_model_loads_and_keeps_the_head_contract() -> bool:
|
||||
var avatar := _avatar(Rules.NpcType.TOURIST, 33)
|
||||
var a := TestUtils.check(avatar.loaded_model and not avatar.fallback_avatar,
|
||||
"committed npc_base.glb is used")
|
||||
"a skinned library character is used")
|
||||
var b := TestUtils.check(avatar.get_node_or_null("Rig/Head") != null,
|
||||
"Rig/Head survives the skinned path")
|
||||
var c := TestUtils.check(avatar._skeleton != null and avatar._bone_indices.has("Head")
|
||||
@@ -137,6 +137,82 @@ func test_skinned_model_loads_and_keeps_the_head_contract() -> bool:
|
||||
avatar.free()
|
||||
return a and b and c
|
||||
|
||||
func test_character_selection_is_deterministic_and_complete() -> bool:
|
||||
var expected := {
|
||||
Rules.NpcType.TOURIST: "npc_casual",
|
||||
Rules.NpcType.CONFUSED_VISITOR: "npc_casual",
|
||||
Rules.NpcType.INTOXICATED: "npc_casual",
|
||||
Rules.NpcType.AUTHORIZED_STAFF: "npc_jacket",
|
||||
Rules.NpcType.PROTESTER: "npc_jacket",
|
||||
Rules.NpcType.INFLUENCER: "npc_coat",
|
||||
Rules.NpcType.PERFORMER: "npc_dress",
|
||||
Rules.NpcType.DELIVERY: "npc_vest",
|
||||
Rules.NpcType.RESPONDER: "npc_firefighter",
|
||||
Rules.NpcType.MENTAL_CRISIS: "npc_hoodie",
|
||||
}
|
||||
var problems: Array[String] = []
|
||||
for npc_type in expected:
|
||||
var model := NpcAvatar.character_model_for(npc_type, 5)
|
||||
if model != expected[npc_type]:
|
||||
problems.append("%d -> %s (expected %s)" % [npc_type, model, expected[npc_type]])
|
||||
if not ResourceLoader.exists(NpcAvatar.BLENDER_NPC_DIR + model + ".glb"):
|
||||
problems.append("%s missing" % model)
|
||||
if NpcAvatar.character_model_for(npc_type, 5) != model:
|
||||
problems.append("%d is not seed-stable" % npc_type)
|
||||
# Seed-alternating variants still resolve to a library model.
|
||||
for seed_value in [1, 2, 3, 4, 5, 6]:
|
||||
var variant := NpcAvatar.character_model_for(Rules.NpcType.TRESPASSER, seed_value)
|
||||
if not ResourceLoader.exists(NpcAvatar.BLENDER_NPC_DIR + variant + ".glb"):
|
||||
problems.append("trespasser seed %d -> %s missing" % [seed_value, variant])
|
||||
return TestUtils.check(problems.is_empty(),
|
||||
"every role selects an existing library character (%s)" % str(problems))
|
||||
|
||||
func test_library_gear_attaches_to_the_contract_points() -> bool:
|
||||
var tourist := _avatar(Rules.NpcType.TOURIST, 12)
|
||||
var backpack := tourist.find_child("Gear_backpack", true, false) as Node3D
|
||||
var camera := tourist.find_child("Gear_camera", true, false) as Node3D
|
||||
var a := TestUtils.check(tourist.character_model == "npc_casual"
|
||||
and backpack != null and backpack.get_parent() is Node3D
|
||||
and backpack.get_parent().name == "Rig",
|
||||
"backpack attaches under Rig")
|
||||
var b := TestUtils.check(camera != null and camera.get_parent() is Node3D
|
||||
and camera.get_parent().name == "Hand",
|
||||
"hand gear attaches under the hand proxy")
|
||||
tourist.free()
|
||||
var responder := _avatar(Rules.NpcType.RESPONDER, 12)
|
||||
var helmet := responder.find_child("Gear_firefighter_helmet", true, false) as Node3D
|
||||
var tank := responder.find_child("Gear_scba_tank", true, false) as Node3D
|
||||
var flashlight := responder.find_child("Gear_flashlight", true, false) as Node3D
|
||||
var c := TestUtils.check(responder.character_model == "npc_firefighter"
|
||||
and helmet != null and helmet.get_parent().name == "Head",
|
||||
"firefighter helmet attaches to Rig/Head")
|
||||
var d := TestUtils.check(tank != null and flashlight != null,
|
||||
"responder gets SCBA tank and flashlight")
|
||||
responder.free()
|
||||
return a and b and c and d
|
||||
|
||||
func test_fixed_detail_materials_are_not_tinted() -> bool:
|
||||
var avatar := _avatar(Rules.NpcType.RESPONDER, 3)
|
||||
var mi := avatar.find_child("npc_base", true, false) as MeshInstance3D
|
||||
var unknown_overrides := 0
|
||||
var contract_overrides := 0
|
||||
for index in mi.mesh.get_surface_count():
|
||||
var source := mi.mesh.surface_get_material(index)
|
||||
if source == null:
|
||||
continue
|
||||
var slot := str(source.resource_name)
|
||||
var override := mi.get_surface_override_material(index)
|
||||
if slot in ["skin", "cloth", "pants", "accessory", "hair"]:
|
||||
if override != null:
|
||||
contract_overrides += 1
|
||||
elif override != null:
|
||||
unknown_overrides += 1
|
||||
var a := TestUtils.check(contract_overrides == 5, "five contract slots are tinted")
|
||||
var b := TestUtils.check(unknown_overrides == 0,
|
||||
"fixed detail materials keep their authored colour (%d overrides)" % unknown_overrides)
|
||||
avatar.free()
|
||||
return a and b
|
||||
|
||||
func test_skinned_outfit_tints_the_material_slots() -> bool:
|
||||
var avatar := _avatar(Rules.NpcType.TOURIST, 44)
|
||||
var mi := avatar.find_child("npc_base", true, false) as MeshInstance3D
|
||||
|
||||
Reference in new issue
Block a user