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
+162
-12
@@ -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"))
|
||||
|
||||
Reference in new issue
Block a user