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

+162 -12
View File
@@ -63,6 +63,7 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector
npc.setup(id, profile, intent, seed_value)
npc.access_override = p_override
_visual_seed = seed_value
_npc_type = profile.npc_type
spawn_position = p_spawn
target_position = p_target
door_position = p_door
@@ -105,6 +106,7 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector
var _visual_seed := 0
var _rig: Node3D = null
var _npc_type := 0
var _head: Node3D = null
var _bob_phase := 0.0
var _guard_ref: Node3D = null
@@ -130,6 +132,7 @@ var _queue_offset := Vector3.ZERO
var _height_scale := 1.0
var loaded_model := false
var fallback_avatar := true
var character_model := ""
## Tests can force the offline rig; the game always tries the committed model.
var use_skinned_model := true
var _skeleton: Skeleton3D = null
@@ -162,6 +165,46 @@ const ACCESSORIES := {
Rules.NpcType.CONFUSED_VISITOR: "none",
}
const BLENDER_NPC_DIR := "res://assets/models/blender/npc/"
const BLENDER_GEAR_DIR := "res://assets/models/blender/npc_gear/"
const GEAR_ATTACH := {
"cap": "head", "beanie": "head", "hood": "head", "sunglasses": "head",
"firefighter_helmet": "head",
"backpack": "back", "shoulder_bag": "back", "scba_tank": "back",
"lanyard": "chest",
"phone": "hand", "cup": "hand", "clipboard": "hand", "umbrella": "hand",
"camera": "hand", "protest_sign": "hand", "flashlight": "hand",
"wand": "hand", "radio": "hand",
}
## Character variants from tools/blender_assets.py. The manifest is the source
## of truth; this mapping keeps selection deterministic and testable offline.
const CHARACTER_MODELS := {
Rules.NpcType.TOURIST: "npc_casual",
Rules.NpcType.CONFUSED_VISITOR: "npc_casual",
Rules.NpcType.INTOXICATED: "npc_casual",
Rules.NpcType.AUTHORIZED_GUEST: "npc_jacket",
Rules.NpcType.AUTHORIZED_STAFF: "npc_jacket",
Rules.NpcType.PROTESTER: "npc_jacket",
Rules.NpcType.INFLUENCER: "npc_coat",
Rules.NpcType.TRESPASSER: "npc_coat",
Rules.NpcType.VIOLENT_THREAT: "npc_hoodie",
Rules.NpcType.MENTAL_CRISIS: "npc_hoodie",
Rules.NpcType.PERFORMER: "npc_dress",
Rules.NpcType.DELIVERY: "npc_vest",
Rules.NpcType.RESPONDER: "npc_firefighter",
}
## Which library character represents this profile. Pure and seed-stable; some
## roles alternate between two silhouettes so crowds do not clone.
static func character_model_for(npc_type: int, seed_value: int) -> String:
match npc_type:
Rules.NpcType.AUTHORIZED_GUEST:
return "npc_jacket" if seed_value % 3 == 0 else "npc_casual"
Rules.NpcType.TRESPASSER, Rules.NpcType.VIOLENT_THREAT:
return "npc_hoodie" if seed_value % 2 == 0 else "npc_coat"
_:
return str(CHARACTER_MODELS.get(npc_type, "npc_casual"))
const HAIR_STYLES := ["short", "long", "bun", "beard", "bald", "short"]
const HAND_ITEMS := ["phone", "cup", "clipboard", "umbrella", "camera", "none"]
@@ -292,7 +335,11 @@ static func reaction_label(mode: String, escalation: int) -> String:
## Instantiate the Blender-authored skinned base mesh. Returns false when the
## GLB is missing/not imported, so setup falls back to the procedural rig.
func _build_skinned_body(npc_type: int) -> bool:
var path := "res://assets/models/generated/npc_base.glb"
character_model = character_model_for(npc_type, _visual_seed)
var path := BLENDER_NPC_DIR + character_model + ".glb"
if not ResourceLoader.exists(path):
character_model = "npc_base"
path = "res://assets/models/generated/npc_base.glb"
if not ResourceLoader.exists(path):
return false
var packed := ResourceLoader.load(path) as PackedScene
@@ -316,18 +363,39 @@ func _build_skinned_body(npc_type: int) -> bool:
var outfit := outfit_for(npc_type, _visual_seed)
var cloth: Color = outfit["shirt"]
match str(outfit["layer"]):
"vest":
cloth = Color(0.92, 0.72, 0.12)
"coat":
cloth = cloth.darkened(0.25)
"jacket":
cloth = cloth.darkened(0.12)
var pants: Color = outfit["pants"]
if character_model == "npc_base":
match str(outfit["layer"]):
"vest":
cloth = Color(0.92, 0.72, 0.12)
"coat":
cloth = cloth.darkened(0.25)
"jacket":
cloth = cloth.darkened(0.12)
else:
match character_model:
"npc_vest":
# The hi-vis vest is a fixed material; cloth is the undershirt.
pass
"npc_firefighter":
cloth = Color(0.16, 0.17, 0.20)
pants = Color(0.13, 0.14, 0.17)
"npc_guard":
cloth = Color(0.10, 0.13, 0.20)
pants = Color(0.09, 0.10, 0.14)
"npc_dress":
pants = outfit["pants"].darkened(0.25)
_:
match str(outfit["layer"]):
"coat":
cloth = cloth.darkened(0.25)
"jacket":
cloth = cloth.darkened(0.12)
_height_scale = float(outfit["height_scale"])
_rig.scale = Vector3.ONE * _height_scale
_shirt_color = cloth
_skin_color = outfit["skin"]
_apply_outfit_materials(npc_type, outfit, cloth)
_apply_outfit_materials(npc_type, outfit, cloth, pants)
# Animation proxies: the existing _animate()/idle code drives these Node3D
# pivots; _sync_skeleton() copies their rotations onto the armature bones.
@@ -365,6 +433,7 @@ func _build_skinned_body(npc_type: int) -> bool:
_bind_bones()
_add_accessory(str(outfit["accessory"]), cloth)
_add_character_gear()
_add_hand_item(str(outfit["hand_item"]), outfit["skin"])
_build_focus_circle()
_add_props(npc_type)
@@ -374,12 +443,13 @@ func _build_skinned_body(npc_type: int) -> bool:
## Tint the imported material slots per avatar. Each avatar gets its own copied
## materials so the shared imported scene is never mutated.
func _apply_outfit_materials(npc_type: int, outfit: Dictionary, cloth: Color) -> void:
func _apply_outfit_materials(npc_type: int, outfit: Dictionary, cloth: Color,
pants: Color) -> void:
var mesh: Mesh = _skinned_mesh.mesh
var tints := {
"skin": outfit["skin"],
"cloth": cloth,
"pants": outfit["pants"],
"pants": pants,
"hair": outfit["hair"],
"accessory": Color(0.10, 0.11, 0.14),
}
@@ -388,8 +458,12 @@ func _apply_outfit_materials(npc_type: int, outfit: Dictionary, cloth: Color) ->
if source == null:
continue
var slot := str(source.resource_name)
# Only the five contract slots are tinted. Fixed-colour detail materials
# (hivis, reflective, leather, steel, ...) keep their authored colour.
if not tints.has(slot):
continue
_skinned_mesh.set_surface_override_material(index,
Materials.npc_slot(source, tints.get(slot, Color.WHITE)))
Materials.npc_slot(source, tints[slot]))
func _bind_bones() -> void:
_bone_indices.clear()
@@ -568,6 +642,17 @@ func _add_hair(head: Node3D, style: String, color: Color) -> void:
func _add_hand_item(kind: String, skin: Color) -> void:
if _hands.size() < 2:
return
var item := kind
match character_model:
"npc_firefighter":
item = "flashlight"
"npc_guard":
item = "radio"
"npc_hoodie":
if item == "none":
item = "cup"
if item != "none" and _attach_gear(item):
return
var hand: Node3D = _hands[1]
match kind:
"phone":
@@ -599,8 +684,73 @@ func _add_hand_item(kind: String, skin: Color) -> void:
Color(0.10, 0.10, 0.12), "metal"))
_detail(_add_sphere(hand, 0.045, Vector3(0, -0.02, 0.08), Color(0.2, 0.35, 0.5), "glass"))
## Load and attach a Blender gear GLB at its documented attach point. Returns
## false when the asset is missing so the primitive fallback still works.
func _attach_gear(gear_name: String) -> bool:
if not GEAR_ATTACH.has(gear_name):
return false
var path := BLENDER_GEAR_DIR + gear_name + ".glb"
if not ResourceLoader.exists(path):
return false
var packed := ResourceLoader.load(path) as PackedScene
if packed == null:
return false
var node := packed.instantiate() as Node3D
if node == null:
return false
node.name = "Gear_" + gear_name
match str(GEAR_ATTACH[gear_name]):
"head":
if _head == null:
node.free()
return false
_head.add_child(node)
"hand":
if _hands.size() < 2:
node.free()
return false
_hands[1].add_child(node)
"back":
if _rig == null:
node.free()
return false
node.position = Vector3(0, 1.10, 0)
_rig.add_child(node)
"chest":
if _rig == null:
node.free()
return false
node.position = Vector3(0, 1.30, 0)
_rig.add_child(node)
_:
node.free()
return false
_detail_nodes.append(node)
return true
## Character-specific library gear (firefighter helmet/SCBA, dress bag, vest
## cap). Accessory/hand items already cover the shared roles.
func _add_character_gear() -> void:
match character_model:
"npc_firefighter":
_attach_gear("firefighter_helmet")
_attach_gear("scba_tank")
"npc_guard":
_attach_gear("cap")
"npc_vest":
_attach_gear("cap")
"npc_dress":
_attach_gear("shoulder_bag")
## Small role accessory: cap, hood, backpack, sunglasses or a staff lanyard.
func _add_accessory(kind: String, shirt: Color) -> void:
if character_model == "npc_firefighter":
return
if character_model == "npc_hoodie" and kind == "hood":
# The hoodie variant already models the hood up.
return
if _attach_gear(kind):
return
match kind:
"cap":
_detail(_add_sphere(_head, 0.175, Vector3(0, 0.12, 0), shirt.darkened(0.15), "cloth"))