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:
zwitschi committed 2026-11-01 14:57:02 +01:00
1 parent d513e0b82c
commit d8cfddbf89
23 files changed
+531 -165

No files matched your search

+32
View File
@@ -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)
+77 -1
View File
@@ -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