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"))
+154 -56
View File
@@ -14,8 +14,14 @@ const T_SKYLINE := preload("res://assets/textures/skyline.png")
## Generated prop models, one `.glb` per street-furniture piece.
const MODEL_DIR := "res://assets/models/generated/"
const BLENDER_PROP_DIR := "res://assets/models/blender/props/"
const MODEL_PROPS := ["bollard", "bench", "trash_bin", "planter_box",
"newspaper_box", "fire_hydrant"]
## Library props loaded in place of the generated set, plus new street dressing.
const LIBRARY_PROPS := ["bollard", "bench", "trash_bin", "planter_box",
"newspaper_box", "fire_hydrant", "street_lamp", "street_tree", "traffic_cone",
"palm_tree", "round_planter", "a_frame_sign", "crowd_barrier", "stanchion",
"security_podium"]
var root: Node3D = null
## Door lock indicator mesh near the handle; toggled by GameManager.
@@ -33,6 +39,11 @@ var _window_mats: Array[StandardMaterial3D] = []
## not listed), so tests and the debug overlay can report real model usage.
var loaded_models: Array[String] = []
var fallback_props: Array[String] = []
## Props that came from the Blender library rather than the generated set.
var library_props: Array[String] = []
var library_nodes: Array[Node3D] = []
## Total triangle budget for the Blender library pieces placed in the world.
const LIBRARY_TRIANGLE_BUDGET := 60000
func _init(root_node: Node3D) -> void:
root = root_node
@@ -48,6 +59,7 @@ func build() -> void:
_build_crosswalk()
_build_street_furniture()
_build_street_detail()
_build_library_dressing()
_build_guard_spawn()
_build_navmesh()
set_night_mode(false)
@@ -106,6 +118,16 @@ func _build_building() -> void:
# Door lock indicator (green = open, red = locked), placed beside the handle
door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, -3.82), Color(0.1, 0.9, 0.3))
door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3))
# Blender glazed double door in front of the recessed frame.
var door_model := _load_prop_scene("double_door")
if door_model != null:
door_model.name = "LibraryDoubleDoor"
door_model.position = Vector3(0, 0.0, -3.84)
building.add_child(door_model)
library_nodes.append(door_model)
for material in _emissive_materials(door_model):
if not _lamp_bulb_mats.has(material):
_lamp_bulb_mats.append(material)
_build_facade_windows(building)
var col := BoxShape3D.new()
col.size = Vector3(40, 18, 4)
@@ -257,21 +279,32 @@ func _build_skyline_backdrop() -> void:
var _skyline_mat: StandardMaterial3D = null
func _build_marquee() -> void:
## Striped awning above the door, then the neon sign strip + label.
var awning := _add_box(root, Vector3(10.5, 0.08, 1.8), Vector3(0, 6.55, -3.7), Color(1, 1, 1), true, T_AWNING, Vector2(4, 1), "awning")
awning.name = "Awning"
## Neon-style marquee strip above the door: emissive bars + a label.
var strip_parent := Node3D.new()
strip_parent.name = "Marquee"
for offset in [-2.2, 2.2]:
var bar := _add_box(strip_parent, Vector3(0.25, 0.5, 0.5), Vector3(offset, 7.4, -3.6), Color(1, 0.35, 0.75))
var mat := _emissive_material(Color(1.0, 0.35, 0.75))
bar.material_override = mat
_marquee_emissive.append(mat)
## Blender canopy + sign box above the door; the procedural striped awning and
## neon bars remain only as a fallback when the library asset is missing.
var library := _load_prop_scene("marquee")
if library != null:
library.name = "LibraryMarquee"
library.position = Vector3(0, 5.62, -3.9)
root.add_child(library)
library_nodes.append(library)
for material in _emissive_materials(library):
if not _marquee_emissive.has(material):
_marquee_emissive.append(material)
else:
var awning := _add_box(root, Vector3(10.5, 0.08, 1.8), Vector3(0, 6.55, -3.7), Color(1, 1, 1), true, T_AWNING, Vector2(4, 1), "awning")
awning.name = "Awning"
var strip_parent := Node3D.new()
strip_parent.name = "Marquee"
for offset in [-2.2, 2.2]:
var bar := _add_box(strip_parent, Vector3(0.25, 0.5, 0.5), Vector3(offset, 7.4, -3.6), Color(1, 0.35, 0.75))
var mat := _emissive_material(Color(1.0, 0.35, 0.75))
bar.material_override = mat
_marquee_emissive.append(mat)
root.add_child(strip_parent)
marquee_label = Label3D.new()
marquee_label.name = "MarqueeLabel"
marquee_label.text = "GIG LINE"
marquee_label.position = Vector3(0, 7.3, -3.55)
marquee_label.position = Vector3(0, 6.45, -2.12)
marquee_label.modulate = Color(1, 0.6, 0.9)
marquee_label.font_size = 160
marquee_label.pixel_size = 0.004
@@ -279,61 +312,110 @@ func _build_marquee() -> void:
marquee_label.outline_modulate = Color(0.9, 0.15, 0.5)
marquee_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
marquee_label.no_depth_test = false
strip_parent.add_child(marquee_label)
root.add_child(strip_parent)
root.add_child(marquee_label)
## Venue dressing that is not part of the sidewalk prop table: a stair handrail,
## a parked sedan, and wall lanterns. Kept separate so the placement validator
## only sees grounded sidewalk furniture.
func _build_library_dressing() -> void:
if ResourceLoader.exists(BLENDER_PROP_DIR + "handrail.glb"):
var rail := _load_prop_scene("handrail")
if rail != null:
rail.name = "LibraryHandrail"
rail.position = Vector3(3.35, 0.2, 3.95)
rail.rotation_degrees.y = 90.0
root.add_child(rail)
library_nodes.append(rail)
if ResourceLoader.exists(BLENDER_PROP_DIR + "sedan.glb"):
var sedan := _add_prop_model(root, "sedan", Vector3(6.0, 0.1, 17.0), 180.0)
sedan.name = "ParkedSedan"
if not library_nodes.has(sedan):
library_nodes.append(sedan)
for material in _emissive_materials(sedan):
if not _lamp_bulb_mats.has(material):
_lamp_bulb_mats.append(material)
if ResourceLoader.exists(BLENDER_PROP_DIR + "wall_lantern.glb"):
for side in [-4.9, 4.9]:
var lantern := _load_prop_scene("wall_lantern")
if lantern == null:
continue
lantern.name = "WallLantern"
lantern.position = Vector3(side, 3.3, -3.78)
root.add_child(lantern)
library_nodes.append(lantern)
for material in _emissive_materials(lantern):
if not _lamp_bulb_mats.has(material):
_lamp_bulb_mats.append(material)
## Emissive materials under a prop, used to tie library bulbs/LEDs to night mode.
func _emissive_materials(holder: Node3D) -> Array[StandardMaterial3D]:
var out: Array[StandardMaterial3D] = []
for node in holder.find_children("*", "MeshInstance3D", true, false):
var mi := node as MeshInstance3D
if mi.mesh == null:
continue
for index in mi.mesh.get_surface_count():
var standard := mi.mesh.surface_get_material(index) as StandardMaterial3D
if standard != null and standard.emission_enabled \
and standard.emission_energy_multiplier > 0.0:
out.append(standard)
return out
func _build_street_furniture() -> void:
# Street lamps along the sidewalk (bulbs + omni lights, off by day).
# Blender library street lamps (bulbs + omni lights, off by day). The lamp
# arm points to local -X, so the left side is rotated 180 degrees to face
# the sidewalk.
for side in [-7.5, 7.5]:
var lamp := Node3D.new()
lamp.name = "Lamp_%d" % int(side)
_add_box(lamp, Vector3(0.22, 5.0, 0.22), Vector3(side, 2.5, 8.5), Color(0.16, 0.16, 0.18), true, null, Vector2.ONE, "metal")
_add_box(lamp, Vector3(1.6, 0.15, 0.15), Vector3(side + (0.8 if side < 0 else -0.8), 4.9, 8.5), Color(0.16, 0.16, 0.18), true, null, Vector2.ONE, "metal")
var bulb := _add_box(lamp, Vector3(0.16, 0.16, 0.5), Vector3(side + (0.8 if side < 0 else -0.8), 4.85, 8.5), Color(1, 1, 0.85))
var bulb_mat := _emissive_material(Color(1.0, 1.0, 0.8))
bulb.material_override = bulb_mat
bulb.material_override.emission_energy_multiplier = 1.5
_lamp_bulb_mats.append(bulb_mat)
var holder := _add_prop_model(root, "street_lamp",
Vector3(side, 0.18, 8.5), 180.0 if side < 0.0 else 0.0)
holder.name = "Lamp_%d" % int(side)
var omni := OmniLight3D.new()
omni.position = Vector3(side + (0.8 if side < 0 else -0.8), 4.7, 8.5)
omni.position = Vector3(-0.85, 4.65, 0)
omni.omni_range = 12.0
omni.light_energy = 0.0
omni.light_color = Color(1, 0.93, 0.75)
lamp.add_child(omni)
holder.add_child(omni)
_lamp_omni.append(omni)
root.add_child(lamp)
_street_lamps.append(bulb)
# Planters beside the steps were flat boxes that clipped the treads; the
# generated planter_box props replace them in _build_street_detail().
# Low-poly trees along the street front.
var trunk_color := Color(0.35, 0.25, 0.18)
var leaf_color := Color(0.16, 0.42, 0.2)
# Flank the walkway; keep the sight line to the door clear.
_collect_emissive(holder)
# Blender library street trees replace the cone trees. Flank the walkway and
# keep the sight line to the door clear.
for i in 6:
var side := -1.0 if i % 2 == 0 else 1.0
var x := side * (6.5 + float(int(i / 2)) * 3.0)
var z := 4.0 + float((i * 7) % 5)
var tree := Node3D.new()
tree.name = "Tree_%d" % (i + 1)
var trunk := MeshInstance3D.new()
var trunk_mesh := CylinderMesh.new()
trunk_mesh.top_radius = 0.09
trunk_mesh.bottom_radius = 0.14
trunk_mesh.height = 1.4
trunk.mesh = trunk_mesh
trunk.material_override = _plain_material(trunk_color, "wood")
trunk.position = Vector3(x, 0.7, z)
tree.add_child(trunk)
var crown := MeshInstance3D.new()
var crown_mesh := CylinderMesh.new()
crown_mesh.top_radius = 0.05
crown_mesh.bottom_radius = 0.85
crown_mesh.height = 2.2
crown.mesh = crown_mesh
crown.material_override = _plain_material(leaf_color, "foliage")
crown.position = Vector3(x, 2.1, z)
tree.add_child(crown)
root.add_child(tree)
_add_prop_model(root, "street_tree", Vector3(x, 0.18, z),
float((i * 37) % 360))
## Total triangles across the placed Blender library pieces, for the draw budget
## test. Counts indices when present, otherwise the vertex stream.
func library_triangle_count() -> int:
var total := 0
for holder in library_nodes:
if not is_instance_valid(holder):
continue
for node in holder.find_children("*", "MeshInstance3D", true, false):
var mi := node as MeshInstance3D
if mi.mesh == null:
continue
for index in mi.mesh.get_surface_count():
var arrays := mi.mesh.surface_get_arrays(index)
var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
if indices.size() > 0:
total += indices.size() / 3
else:
var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
total += vertices.size() / 3
return total
## Register a library prop's emissive surfaces so night mode can switch them on.
func _collect_emissive(holder: Node3D) -> void:
for node in holder.find_children("*", "MeshInstance3D", true, false):
var mi := node as MeshInstance3D
if not _street_lamps.has(mi):
_street_lamps.append(mi)
for material in _emissive_materials(holder):
if not _lamp_bulb_mats.has(material):
_lamp_bulb_mats.append(material)
## 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
@@ -359,6 +441,17 @@ const PROP_PLACEMENTS := [
"accessory": "crate"},
{"zone": "curb", "prop": "fire_hydrant", "x": 8.4, "z": 11.2,
"accessory": "cone"},
# Blender library dressing: palms frame the block, planters and a queue
# barrier sit outside the guest lane.
{"zone": "mid_block", "prop": "palm_tree", "x": -13.0, "z": 10.2, "rot": 10.0},
{"zone": "mid_block", "prop": "palm_tree", "x": 13.0, "z": 10.2, "rot": -10.0},
{"zone": "mid_block", "prop": "round_planter", "x": -12.6, "z": 6.2},
{"zone": "mid_block", "prop": "round_planter", "x": 12.6, "z": 6.2},
{"zone": "curb", "prop": "a_frame_sign", "x": -6.9, "z": 6.5, "rot": 15.0},
{"zone": "curb", "prop": "traffic_cone", "x": 9.6, "z": 9.8},
{"zone": "curb", "prop": "crowd_barrier", "x": 3.0, "z": 10.8},
{"zone": "curb", "prop": "stanchion", "x": -2.6, "z": 10.8},
{"zone": "entrance", "prop": "security_podium", "x": 3.2, "z": 5.6, "rot": -15.0},
]
## The door, steps, and the lane guests walk in through stay clear of props.
@@ -449,6 +542,9 @@ func _add_prop_model(parent: Node3D, prop: String, pos: Vector3,
if model != null:
holder.add_child(model)
loaded_models.append(prop)
if ResourceLoader.exists(BLENDER_PROP_DIR + prop + ".glb"):
library_props.append(prop)
library_nodes.append(holder)
else:
var fallback := _fallback_prop(prop)
holder.add_child(fallback)
@@ -542,7 +638,9 @@ func _add_prop_accessory(holder: Node3D, kind: String) -> void:
Color(0.92, 0.92, 0.9), "painted_metal")
func _load_prop_scene(prop: String) -> Node3D:
var path := MODEL_DIR + prop + ".glb"
var path := BLENDER_PROP_DIR + prop + ".glb"
if not ResourceLoader.exists(path):
path = MODEL_DIR + prop + ".glb"
if not ResourceLoader.exists(path):
return null
var res: Resource = ResourceLoader.load(path)
+5 -1
View File
@@ -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
View File
@@ -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:
+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