class_name NpcAvatar extends CharacterBody3D ## Visual + navigation wrapper around a logic NPCController (ADR-0001). ## Gives the controller a body, color-coded by archetype, and simple movement. const NPCController := preload("res://src/core/npc_controller.gd") const Rules := preload("res://src/core/rules.gd") const Materials := preload("res://src/game/materials.gd") var npc: NPCController = null var spawn_position: Vector3 = Vector3.ZERO var target_position: Vector3 = Vector3.ZERO var door_position: Vector3 = Vector3(0, 0.5, 1.2) var awaiting_interaction := false var arrived := false ## Seconds idle at an arrived waypoint. Ordinary traffic lives for a while, then ## despawns so the entrance does not fill with standers (TODO: NPC crowd cap). var idle_s := 0.0 var lifetime_s := 90.0 var ephemeral := true ## Crowd presentation (PLAN: Public Crowd Reaction). "watch" rubbernecks at the ## incident, "flee" clears away from it, "" is normal navigation. var reaction_mode := "" var reaction_point := Vector3.ZERO var _reaction_timer := 0.0 const RUBBERNECK_S := 12.0 const FLEE_S := 20.0 const FLEE_SPEED := 2.4 ## LOD distances in metres. Hysteresis keeps the tier from flickering at a ## boundary; the impostor is used far enough away that the silhouette is all ## that reads. const LOD_TIERS := [14.0, 26.0] const LOD_HYSTERESIS := 1.5 var agent: NavigationAgent3D = null ## Set once the nav agent proves it has a path; direct movement is the fallback. var _nav_usable := false var _nav_try_frames := 0 const TYPE_COLORS := { Rules.NpcType.TOURIST: Color(0.90, 0.75, 0.55), Rules.NpcType.PERFORMER: Color(0.80, 0.55, 0.90), Rules.NpcType.INFLUENCER: Color(0.95, 0.55, 0.55), Rules.NpcType.CONFUSED_VISITOR: Color(0.95, 0.85, 0.50), Rules.NpcType.INTOXICATED: Color(0.60, 0.75, 0.50), Rules.NpcType.TRESPASSER: Color(0.55, 0.55, 0.75), Rules.NpcType.VIOLENT_THREAT: Color(0.85, 0.25, 0.25), Rules.NpcType.MENTAL_CRISIS: Color(0.85, 0.65, 0.45), Rules.NpcType.AUTHORIZED_STAFF: Color(0.45, 0.70, 0.90), Rules.NpcType.AUTHORIZED_GUEST: Color(0.60, 0.85, 0.65), Rules.NpcType.DELIVERY: Color(0.80, 0.70, 0.40), Rules.NpcType.RESPONDER: Color(0.25, 0.30, 0.85), Rules.NpcType.PROTESTER: Color(0.90, 0.40, 0.20), } func _init() -> void: npc = NPCController.new() func get_npc_controller() -> NPCController: return npc func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector3, p_target: Vector3, p_door: Vector3 = Vector3(0, 0.5, 1.2), p_npc_id: String = "", p_override: Dictionary = {}) -> void: var id := p_npc_id if p_npc_id != "" else "npc_%d" % seed_value 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 position = p_spawn npc.fsm.state_changed.connect(_on_state_changed) add_to_group("npc") _idle_style = idle_style_for(profile.npc_type, seed_value) if not use_skinned_model or not _build_skinned_body(profile.npc_type): _build_body(profile.npc_type) fallback_avatar = true _build_impostor() _nav_usable = false _nav_try_frames = 0 agent = NavigationAgent3D.new() agent.name = "NavAgent" agent.path_desired_distance = 0.4 agent.target_desired_distance = 4.0 agent.radius = 0.35 agent.height = 1.2 # Steer around street furniture instead of walking into it (props publish a # NavigationObstacle3D; the navmesh itself stays a flat walkable plane). agent.avoidance_enabled = true agent.neighbor_distance = 4.0 agent.max_neighbors = 8 agent.time_horizon_agents = 0.6 agent.time_horizon_obstacles = 1.2 agent.max_speed = FLEE_SPEED agent.velocity_computed.connect(_on_safe_velocity) add_child(agent) # Normalize intent targets: approach the entrance, stop near the door. var collider := CollisionShape3D.new() var shape := CapsuleShape3D.new() shape.radius = 0.35 var height_scale: float = _rig.scale.y if _rig else 1.0 shape.height = 1.6 * height_scale collider.shape = shape collider.position = Vector3(0, 0.8 * height_scale, 0) add_child(collider) 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 var _focus_circle: MeshInstance3D = null var _name_tag: Label3D = null ## Theater props and limbs for the idle poses (ADR-0010 rig). var _photo_prop: MeshInstance3D = null var _photo_flash: MeshInstance3D = null var _sign_prop: MeshInstance3D = null var _legs: Array[Node3D] = [] var _arms: Array[Node3D] = [] var _knees: Array[Node3D] = [] var _elbows: Array[Node3D] = [] var _hands: Array[Node3D] = [] var _theater_t := 0.0 var _idle_t := 0.0 var _idle_style := "breathe" var _reaction_tag: Label3D = null var _impostor: Node3D = null var _detail_nodes: Array[Node3D] = [] var _lod_tier := 0 var _lod_check_s := 0.0 var _queue_offset := Vector3.ZERO var _height_scale := 1.0 var loaded_model := false var fallback_avatar := true var character_model := "" ## QA/review override for the visual model. Empty keeps role/seed selection; ## `capture_npc_review.gd` uses it to stage the one library model (guard) that ## has no spawned NPC role. var character_override := "" ## Tests can force the offline rig; the game always tries the committed model. var use_skinned_model := true var _skeleton: Skeleton3D = null var _bone_indices := {} var _skinned_mesh: MeshInstance3D = null var _shirt_color := Color(0.7, 0.7, 0.7) var _skin_color := Color(0.88, 0.72, 0.60) var _avoidance_active := false var _photo_prop_base_y := 0.0 var _sign_prop_base_y := 0.0 const SKIN_TONE := Color(0.88, 0.72, 0.60) const PANTS_COLOR := Color(0.22, 0.22, 0.26) const HAIR_COLORS := [Color(0.12, 0.1, 0.08), Color(0.35, 0.22, 0.12), Color(0.55, 0.42, 0.28), Color(0.15, 0.15, 0.18)] ## Which small accessory a role wears. This is look-only: the player must still ## read intent from behaviour, never from the archetype name (ADR-0010). const ACCESSORIES := { Rules.NpcType.AUTHORIZED_STAFF: "cap", Rules.NpcType.RESPONDER: "cap", Rules.NpcType.DELIVERY: "cap", Rules.NpcType.TOURIST: "backpack", Rules.NpcType.INFLUENCER: "sunglasses", Rules.NpcType.TRESPASSER: "hood", Rules.NpcType.VIOLENT_THREAT: "hood", Rules.NpcType.MENTAL_CRISIS: "hood", Rules.NpcType.AUTHORIZED_GUEST: "lanyard", Rules.NpcType.PERFORMER: "none", Rules.NpcType.PROTESTER: "none", Rules.NpcType.INTOXICATED: "none", Rules.NpcType.CONFUSED_VISITOR: "none", } const BLENDER_NPC_DIR := "res://assets/models/blender/npc/" const BLENDER_GEAR_DIR := "res://assets/models/blender/npc_gear/" ## Final-context adjustments applied inside the attach node. The lanyard was ## authored under the jacket's front surface; a small forward offset keeps the ## ribbon and badge readable without changing the attach-point contract. const GEAR_OFFSETS := { "lanyard": Vector3(0, 0, 0.04), "firefighter_helmet": Vector3(0, 0.03, 0), } ## Hand gear is authored around the wrist proxy. The palm sits ~4 cm in front ## of that origin, so without this offset the first phone/flashlight versions ## were partly buried inside the hand. Keep the grip axis and move the whole ## item forward into the open palm. const HAND_ITEM_FORWARD := Vector3(0, 0, 0.04) const GEAR_ATTACH := { "cap": "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_POOLS := { Rules.NpcType.TOURIST: ["npc_casual", "npc_crowd_a", "npc_crowd_d"], Rules.NpcType.CONFUSED_VISITOR: ["npc_casual", "npc_crowd_b"], Rules.NpcType.INTOXICATED: ["npc_casual", "npc_crowd_c"], Rules.NpcType.AUTHORIZED_GUEST: ["npc_jacket", "npc_casual", "npc_casual"], Rules.NpcType.AUTHORIZED_STAFF: ["npc_jacket"], Rules.NpcType.PROTESTER: ["npc_jacket"], Rules.NpcType.INFLUENCER: ["npc_coat"], Rules.NpcType.TRESPASSER: ["npc_hoodie", "npc_coat"], Rules.NpcType.VIOLENT_THREAT: ["npc_hoodie", "npc_coat"], 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; a role ## picks from its pool by seed so crowds vary without cloning, and every pool ## entry resolves to a committed library GLB. static func character_model_for(npc_type: int, seed_value: int) -> String: var pool: Array = CHARACTER_POOLS.get(npc_type, ["npc_casual"]) return str(pool[absi(seed_value) % pool.size()]) const HAIR_STYLES := ["short", "long", "bun", "beard", "bald", "short"] const HAND_ITEMS := ["phone", "cup", "clipboard", "umbrella", "camera", "none"] ## Outer layer per role/seed. Pure so the same person always dresses the same. static func layer_for(npc_type: int, seed_value: int) -> String: match npc_type: Rules.NpcType.RESPONDER: return "vest" Rules.NpcType.DELIVERY: return "vest" Rules.NpcType.AUTHORIZED_STAFF: return "jacket" Rules.NpcType.INFLUENCER: return "coat" Rules.NpcType.PERFORMER: return "dress" if seed_value % 2 == 0 else "coat" Rules.NpcType.PROTESTER: return "jacket" Rules.NpcType.AUTHORIZED_GUEST: return "jacket" if seed_value % 3 == 0 else "none" Rules.NpcType.TRESPASSER, Rules.NpcType.VIOLENT_THREAT: return "coat" _: return "none" ## Deterministic hand item so silhouettes differ without changing the role read. static func hand_item_for(npc_type: int, seed_value: int) -> String: match npc_type: Rules.NpcType.AUTHORIZED_STAFF, Rules.NpcType.DELIVERY: return "clipboard" Rules.NpcType.TOURIST: return "camera" Rules.NpcType.INFLUENCER: return "phone" Rules.NpcType.AUTHORIZED_GUEST: return "phone" if seed_value % 4 == 0 else "none" Rules.NpcType.CONFUSED_VISITOR: return "cup" Rules.NpcType.PERFORMER, Rules.NpcType.PROTESTER: return "none" _: return HAND_ITEMS[(seed_value * 5 + 1) % 3 + 3] \ if seed_value % 5 == 0 else "none" ## Clothing palette for one avatar. Pure and seed-driven so crowds read as ## individuals and the result is deterministic (tests rely on that). static func outfit_for(npc_type: int, seed_value: int) -> Dictionary: var base: Color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7)) var frac := float((seed_value * 1103515245 + 12345) % 1000) / 1000.0 var shirt := base.lerp(Color(0.92, 0.92, 0.94), frac * 0.22) shirt = shirt.darkened(frac * 0.12) # Jeans through khaki/chino, so not every avatar wears the same trousers. var pants := Color(0.16, 0.18, 0.23).lerp(Color(0.46, 0.38, 0.28), frac) var hair_index := (seed_value * 7) % HAIR_COLORS.size() var style_index := (seed_value * 13 + npc_type * 3) % HAIR_STYLES.size() return { "shirt": shirt, "sleeves": shirt.darkened(0.16), "pants": pants, "hair": HAIR_COLORS[hair_index], "hair_style": HAIR_STYLES[style_index], "skin": SKIN_TONE.darkened(frac * 0.18), "accessory": ACCESSORIES.get(npc_type, "none"), "layer": layer_for(npc_type, seed_value), "hand_item": hand_item_for(npc_type, seed_value), "height_scale": 0.92 + 0.16 * frac, "blend": frac, } ## Idle behaviour style: someone chatting on a phone, arms crossed, looking ## around, or just breathing. Pure so it is deterministic and testable. static func idle_style_for(npc_type: int, seed_value: int) -> String: match npc_type: Rules.NpcType.INFLUENCER, Rules.NpcType.TOURIST: return "phone" Rules.NpcType.PROTESTER, Rules.NpcType.MENTAL_CRISIS: return "look" Rules.NpcType.AUTHORIZED_STAFF, Rules.NpcType.DELIVERY: return "clipboard" Rules.NpcType.TRESPASSER, Rules.NpcType.VIOLENT_THREAT: return "look" _: if seed_value % 3 == 0: return "crossed" return "breathe" ## Queue slot behind the door for the nth person waiting, so arrivals stack ## instead of sharing one point. static func queue_slot_for(index: int) -> Vector3: var slots := [ Vector3(0.0, 0.0, 0.0), Vector3(0.85, 0.0, 0.45), Vector3(-0.85, 0.0, 0.45), Vector3(0.85, 0.0, 1.15), Vector3(-0.85, 0.0, 1.15), Vector3(0.0, 0.0, 1.85), ] return slots[clampi(index, 0, slots.size() - 1)] ## Yaw that makes the avatar's face (+Z, the procedural and Blender-front axis) ## point along `direction`. Godot's `look_at` aims -Z, which used to make every ## NPC walk backwards; keep the +Z convention in one tested helper. static func yaw_towards(direction: Vector3) -> float: var flat := Vector2(direction.x, direction.z) if flat.length() < 0.001: return 0.0 return atan2(flat.x, flat.y) static func lod_tier_for_distance(distance: float) -> int: if distance < LOD_TIERS[0]: return 0 if distance < LOD_TIERS[1]: return 1 return 2 ## Short reaction symbol shown above the head: readable crowd state at a glance. static func reaction_label(mode: String, escalation: int) -> String: if mode == "flee": return "!!" if mode == "watch": return "?!" if escalation >= 3: return "!" if escalation >= 2: return "?" return "" ## 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: character_model = character_override if character_override != "" \ else 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 if packed == null: return false var model := packed.instantiate() as Node3D if model == null: return false var skeleton := model.find_child("Skeleton3D", true, false) as Skeleton3D var mesh_instance := model.find_child("npc_base", true, false) as MeshInstance3D if skeleton == null or mesh_instance == null: model.queue_free() return false _rig = Node3D.new() _rig.name = "Rig" add_child(_rig) _rig.add_child(model) _skeleton = skeleton _skinned_mesh = mesh_instance var outfit := outfit_for(npc_type, _visual_seed) var cloth: Color = outfit["shirt"] 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, pants) # Animation proxies: the existing _animate()/idle code drives these Node3D # pivots; _sync_skeleton() copies their rotations onto the armature bones. _bind_bones() _head = Node3D.new() _head.name = "Head" _head.position = _rest_origin(["Head"]) _rig.add_child(_head) for side in [-1.0, 1.0]: var suffix := "L" if side < 0.0 else "R" var hip := Node3D.new() hip.name = "Hip" + suffix hip.position = _rest_origin(["Hip" + suffix, "UpperLeg." + suffix]) _rig.add_child(hip) var knee := Node3D.new() knee.name = "Knee" knee.position = _rest_origin(["Knee" + suffix, "LowerLeg." + suffix]) - hip.position hip.add_child(knee) _legs.append(hip) _knees.append(knee) var shoulder := Node3D.new() shoulder.name = "Shoulder" + suffix shoulder.position = _rest_origin(["Shoulder" + suffix, "UpperArm." + suffix]) _rig.add_child(shoulder) var elbow := Node3D.new() elbow.name = "Elbow" elbow.position = _rest_origin(["Elbow" + suffix, "LowerArm." + suffix]) - shoulder.position shoulder.add_child(elbow) var hand := Node3D.new() hand.name = "Hand" hand.position = _rest_origin(["Hand" + suffix, "Wrist." + suffix]) - elbow.position elbow.add_child(hand) _arms.append(shoulder) _elbows.append(elbow) _hands.append(hand) _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) loaded_model = true fallback_avatar = false return true ## 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, pants: Color) -> void: var mesh: Mesh = _skinned_mesh.mesh var tints := { "skin": outfit["skin"], "cloth": cloth, "pants": pants, "hair": outfit["hair"], "accessory": Color(0.10, 0.11, 0.14), } var ao := Materials.load_ao( BLENDER_NPC_DIR + character_model + "_ao.png") for index in mesh.get_surface_count(): var source := mesh.surface_get_material(index) 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 var material := Materials.npc_slot(source, tints[slot]) # The per-character baked AO is applied to skin, hair and fixed gear. # On the 256² fabric maps it magnified into large patches at portrait # distance, so cloth/pants keep their ORM weave and rely on lighting. if ao != null and slot not in ["cloth", "pants"]: material.ao_enabled = true material.ao_texture = ao material.ao_texture_channel = BaseMaterial3D.TEXTURE_CHANNEL_RED _skinned_mesh.set_surface_override_material(index, material) func _bind_bones() -> void: _bone_indices.clear() if _skeleton == null: return for index in _skeleton.get_bone_count(): _bone_indices[_skeleton.get_bone_name(index)] = index ## First-found rest origin for a list of candidate bone names. Lets the proxy ## pivots track the actual skeleton (old 13-core or Quaternius 62-joint). func _rest_origin(names: Array) -> Vector3: if _skeleton == null: return Vector3.ZERO for bone_name in names: if _bone_indices.has(bone_name): return _skeleton.get_bone_rest(_bone_indices[bone_name]).origin return Vector3.ZERO ## Copy the proxy pivot rotations onto armature bones. Limb bones point down, so ## an X rotation swings them forward/back; the Head bone points up, so a Y ## rotation is the look yaw. The Quaternius rig uses dot-suffixed names ## (UpperLeg.L, UpperArm.L, ...); the old 13-core rig uses HipL/ShoulderL, so ## both are tried and the first one present in the skeleton wins. func _sync_skeleton() -> void: if _skeleton == null or _bone_indices.is_empty(): return for i in 2: _pose_bone_any(["UpperLeg.L", "HipL"], ["UpperLeg.R", "HipR"], i, _legs[i].rotation.x) for i in 2: _pose_bone_any(["LowerLeg.L", "KneeL"], ["LowerLeg.R", "KneeR"], i, _knees[i].rotation.x) for i in 2: _pose_bone_any(["UpperArm.L", "ShoulderL"], ["UpperArm.R", "ShoulderR"], i, _arms[i].rotation.x) for i in 2: _pose_bone_any(["LowerArm.L", "ElbowL"], ["LowerArm.R", "ElbowR"], i, _elbows[i].rotation.x) if _head != null: _pose_bone("Head", _head.rotation.y, Vector3.UP) func _pose_bone_any(left: Array, right: Array, index: int, angle: float) -> void: var names: Array = left if index == 0 else right for bone_name in names: if _bone_indices.has(bone_name): _pose_bone(bone_name, angle, Vector3.RIGHT) return func _pose_bone(bone_name: String, angle: float, axis: Vector3) -> void: if not _bone_indices.has(bone_name): return var index: int = _bone_indices[bone_name] # Bone pose rotation is the absolute local orientation, not an offset from # rest; compose with the rest rotation so identity preserves the bind pose. var rest := _skeleton.get_bone_rest(index).basis.get_rotation_quaternion() _skeleton.set_bone_pose_rotation(index, rest * Quaternion(axis, angle)) func _build_body(npc_type: int) -> void: _rig = Node3D.new() _rig.name = "Rig" add_child(_rig) var outfit := outfit_for(npc_type, _visual_seed) var shirt: Color = outfit["shirt"] var sleeves: Color = outfit["sleeves"] var pants: Color = outfit["pants"] var skin: Color = outfit["skin"] var hair: Color = outfit["hair"] _height_scale = float(outfit["height_scale"]) _rig.scale.y = _height_scale _shirt_color = shirt _skin_color = skin var limbs_color := sleeves # Hips and torso, with a shoulder block so the silhouette is not a tube. _add_box_mesh(_rig, Vector3(0.42, 0.64, 0.27), Vector3(0, 1.05, 0), shirt, "cloth") _add_box_mesh(_rig, Vector3(0.50, 0.13, 0.26), Vector3(0, 1.38, 0), shirt, "cloth") _add_box_mesh(_rig, Vector3(0.38, 0.20, 0.25), Vector3(0, 0.70, 0), pants, "cloth") _add_layer(str(outfit["layer"]), shirt) # Articulated legs: hip pivot -> thigh -> knee -> shin -> shoe. for side in [-1.0, 1.0]: var hip := Node3D.new() hip.name = "Hip%s" % ("L" if side < 0.0 else "R") hip.position = Vector3(0.13 * side, 0.64, 0) _rig.add_child(hip) _add_capsule(hip, 0.105, 0.36, Vector3(0, -0.17, 0), pants, 0.0, "cloth") var knee := Node3D.new() knee.name = "Knee" knee.position = Vector3(0, -0.34, 0) hip.add_child(knee) _add_capsule(knee, 0.09, 0.32, Vector3(0, -0.15, 0), pants, 0.0, "cloth") _add_box_mesh(knee, Vector3(0.16, 0.10, 0.30), Vector3(0, -0.33, 0.05), Color(0.12, 0.12, 0.14), "rubber") _legs.append(hip) _knees.append(knee) # Articulated arms: shoulder pivot -> upper arm -> elbow -> forearm -> hand. for side in [-1.0, 1.0]: var shoulder := Node3D.new() shoulder.name = "Shoulder%s" % ("L" if side < 0.0 else "R") shoulder.position = Vector3(0.33 * side, 1.38, 0) _rig.add_child(shoulder) _add_capsule(shoulder, 0.075, 0.32, Vector3(0, -0.15, 0), limbs_color, 0.0, "cloth") var elbow := Node3D.new() elbow.name = "Elbow" elbow.position = Vector3(0, -0.30, 0) shoulder.add_child(elbow) _add_capsule(elbow, 0.065, 0.28, Vector3(0, -0.14, 0), limbs_color, 0.0, "cloth") var hand := Node3D.new() hand.name = "Hand" hand.position = Vector3(0, -0.30, 0.02) elbow.add_child(hand) _add_sphere(hand, 0.065, Vector3.ZERO, skin, "skin") _arms.append(shoulder) _elbows.append(elbow) _hands.append(hand) # Neck, head, face, hair, so the body reads as a person (ADR-0010). _add_capsule(_rig, 0.08, 0.16, Vector3(0, 1.48, 0), skin, 0.0, "skin") _head = Node3D.new() _head.name = "Head" _head.position = Vector3(0, 1.62, 0) _add_sphere(_head, 0.17, Vector3.ZERO, skin, "skin") _add_ears(_head, skin) _add_face(_head) _add_hair(_head, str(outfit["hair_style"]), hair) _rig.add_child(_head) _add_accessory(str(outfit["accessory"]), shirt) _add_hand_item(str(outfit["hand_item"]), skin) _build_focus_circle() _add_props(npc_type) ## Outer layer: jacket, hi-vis vest, coat, or dress over the base shirt. func _add_layer(kind: String, shirt: Color) -> void: match kind: "jacket": _add_box_mesh(_rig, Vector3(0.46, 0.54, 0.31), Vector3(0, 1.08, 0), shirt.darkened(0.28), "cloth") _add_box_mesh(_rig, Vector3(0.05, 0.48, 0.02), Vector3(0, 1.12, 0.165), shirt.darkened(0.42), "cloth") "vest": _add_box_mesh(_rig, Vector3(0.45, 0.44, 0.30), Vector3(0, 1.06, 0), Color(0.92, 0.72, 0.12), "cloth") _add_box_mesh(_rig, Vector3(0.48, 0.06, 0.31), Vector3(0, 0.84, 0), Color(0.82, 0.82, 0.84), "cloth") "coat": _add_box_mesh(_rig, Vector3(0.47, 0.80, 0.33), Vector3(0, 1.01, 0), shirt.darkened(0.35), "cloth") _add_box_mesh(_rig, Vector3(0.54, 0.08, 0.30), Vector3(0, 1.41, 0), shirt.darkened(0.42), "cloth") _add_box_mesh(_rig, Vector3(0.04, 0.60, 0.02), Vector3(0, 1.02, 0.17), shirt.darkened(0.5), "cloth") "dress": var skirt := CylinderMesh.new() skirt.top_radius = 0.24 skirt.bottom_radius = 0.38 skirt.height = 0.52 var mi := MeshInstance3D.new() mi.mesh = skirt mi.position = Vector3(0, 0.82, 0) mi.material_override = _material(shirt.darkened(0.1), "cloth") _rig.add_child(mi) _: pass func _add_ears(head: Node3D, skin: Color) -> void: for side in [-1.0, 1.0]: _add_sphere(head, 0.045, Vector3(0.165 * side, 0.0, 0.0), skin, "skin") func _add_face(head: Node3D) -> void: var eye := Color(0.08, 0.08, 0.1) for side in [-1.0, 1.0]: _add_sphere(head, 0.032, Vector3(0.06 * side, 0.02, 0.145), eye, "glass") _add_box_mesh(head, Vector3(0.07, 0.015, 0.02), Vector3(0.06 * side, 0.075, 0.152), Color(0.16, 0.12, 0.10), "cloth") _add_box_mesh(head, Vector3(0.09, 0.015, 0.02), Vector3(0, -0.075, 0.15), Color(0.42, 0.22, 0.20), "cloth") func _add_hair(head: Node3D, style: String, color: Color) -> void: match style: "long": _add_sphere(head, 0.185, Vector3(0, 0.07, -0.02), color, "cloth") _add_box_mesh(head, Vector3(0.30, 0.36, 0.12), Vector3(0, -0.14, -0.11), color, "cloth") "bun": _add_sphere(head, 0.185, Vector3(0, 0.07, -0.02), color, "cloth") _add_sphere(head, 0.09, Vector3(0, 0.20, -0.10), color, "cloth") "beard": _add_sphere(head, 0.185, Vector3(0, 0.07, -0.02), color, "cloth") _add_box_mesh(head, Vector3(0.22, 0.14, 0.10), Vector3(0, -0.11, 0.09), color.darkened(0.1), "cloth") "bald": pass _: _add_sphere(head, 0.185, Vector3(0, 0.07, -0.02), color, "cloth") ## Hand-held item attached to the right hand socket: phone, cup, clipboard, ## umbrella, camera, or nothing. 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": _detail(_add_box_mesh(hand, Vector3(0.055, 0.11, 0.012), Vector3(0, -0.03, 0.02), Color(0.10, 0.11, 0.14), "glass")) "cup": _detail(_add_capsule(hand, 0.035, 0.10, Vector3(0, -0.05, 0.02), Color(0.90, 0.88, 0.82), 0.0, "cloth")) "clipboard": _detail(_add_box_mesh(hand, Vector3(0.22, 0.28, 0.02), Vector3(0, -0.08, 0.05), Color(0.55, 0.40, 0.22), "wood")) _detail(_add_box_mesh(hand, Vector3(0.16, 0.02, 0.01), Vector3(0, 0.05, 0.07), Color(0.85, 0.85, 0.88), "metal")) "umbrella": _detail(_add_capsule(hand, 0.014, 0.85, Vector3(0, -0.30, 0.02), Color(0.15, 0.16, 0.20), 0.0, "metal")) var canopy := CylinderMesh.new() canopy.top_radius = 0.02 canopy.bottom_radius = 0.34 canopy.height = 0.22 var mi := MeshInstance3D.new() mi.mesh = canopy mi.position = Vector3(0, 0.12, 0.02) mi.material_override = _material(skin.lerp(Color(0.2, 0.25, 0.4), 0.7), "cloth") hand.add_child(mi) _detail(mi) "camera": _detail(_add_box_mesh(hand, Vector3(0.16, 0.10, 0.09), Vector3(0, -0.04, 0.02), 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 if GEAR_OFFSETS.has(gear_name): node.position += GEAR_OFFSETS[gear_name] if str(GEAR_ATTACH[gear_name]) == "hand": node.position += HAND_ITEM_FORWARD _detail_nodes.append(node) var ao := Materials.load_ao(BLENDER_GEAR_DIR + gear_name + "_ao.png") for child in node.find_children("*", "MeshInstance3D", true, false): Materials.apply_ao(child as MeshInstance3D, ao) 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")) _detail(_add_box_mesh(_head, Vector3(0.30, 0.03, 0.16), Vector3(0, 0.10, 0.16), shirt.darkened(0.22), "cloth")) "hood": _detail(_add_sphere(_head, 0.205, Vector3(0, 0.03, -0.03), Color(0.16, 0.16, 0.18), "cloth")) "backpack": _detail(_add_box_mesh(_rig, Vector3(0.34, 0.42, 0.16), Vector3(0, 1.06, -0.24), Color(0.24, 0.28, 0.36), "cloth")) _detail(_add_box_mesh(_rig, Vector3(0.30, 0.06, 0.02), Vector3(0, 1.24, -0.33), Color(0.42, 0.44, 0.5), "cloth")) "sunglasses": _detail(_add_box_mesh(_head, Vector3(0.24, 0.05, 0.03), Vector3(0, 0.04, 0.15), Color(0.06, 0.06, 0.08), "glass")) "lanyard": _detail(_add_box_mesh(_rig, Vector3(0.05, 0.06, 0.02), Vector3(0, 1.20, 0.16), Color(0.85, 0.78, 0.30), "painted_metal")) _detail(_add_box_mesh(_rig, Vector3(0.02, 0.20, 0.02), Vector3(-0.08, 1.34, 0.14), Color(0.3, 0.32, 0.36), "cloth")) _detail(_add_box_mesh(_rig, Vector3(0.02, 0.20, 0.02), Vector3(0.08, 1.34, 0.14), Color(0.3, 0.32, 0.36), "cloth")) _: pass func _build_focus_circle() -> void: _focus_circle = MeshInstance3D.new() _focus_circle.name = "FocusCircle" # Blue, semi-transparent disc floating in front of the face (ADR-0008). # Billboarded so it always reads as a circle on screen. var disc := CylinderMesh.new() disc.top_radius = 0.15 disc.bottom_radius = 0.15 disc.height = 0.001 disc.radial_segments = 24 _focus_circle.mesh = disc var mat := StandardMaterial3D.new() mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED mat.albedo_color = Color(0.25, 0.55, 1.0, 0.45) mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA mat.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED _focus_circle.material_override = mat _focus_circle.position = Vector3(0, 0, 0.30) _focus_circle.visible = false _head.add_child(_focus_circle) _name_tag = Label3D.new() _name_tag.name = "NameTag" _name_tag.text = npc.profile.display_name if npc and npc.profile else "" _name_tag.font_size = 48 _name_tag.pixel_size = 0.004 _name_tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED _name_tag.position = Vector3(0, 0.58, 0) _name_tag.modulate = Color(0.95, 0.95, 0.9, 0.9) _name_tag.visible = false _head.add_child(_name_tag) _reaction_tag = Label3D.new() _reaction_tag.name = "ReactionTag" _reaction_tag.font_size = 72 _reaction_tag.pixel_size = 0.005 _reaction_tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED _reaction_tag.position = Vector3(0, 2.05, 0) _reaction_tag.visible = false _rig.add_child(_reaction_tag) func set_focus_visual(focused: bool, menu_open: bool = false) -> void: if _focus_circle: _focus_circle.visible = focused var mat := _focus_circle.material_override as StandardMaterial3D if mat: mat.albedo_color = Color(0.35, 0.65, 1.0, 0.7) if menu_open else Color(0.25, 0.55, 1.0, 0.45) _focus_circle.scale = Vector3.ONE * (1.15 if menu_open else 1.0) if _name_tag: _name_tag.visible = focused func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0, role: String = "cloth") -> MeshInstance3D: var mi := MeshInstance3D.new() var mesh := CapsuleMesh.new() mesh.radius = radius mesh.height = height mi.mesh = mesh mi.position = pos mi.rotation_degrees.z = z_rot mi.material_override = _material(color, role) parent.add_child(mi) return mi func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color, role: String = "skin") -> MeshInstance3D: var mi := MeshInstance3D.new() var mesh := SphereMesh.new() mesh.radius = radius mesh.height = radius * 2.0 mi.mesh = mesh mi.position = pos mi.material_override = _material(color, role) parent.add_child(mi) return mi ## Register a small dress detail so the mid-distance LOD can hide it. func _detail(node: Node3D) -> Node3D: _detail_nodes.append(node) return node func _add_box_mesh(parent: Node3D, size: Vector3, pos: Vector3, color: Color, role: String = "cloth") -> MeshInstance3D: var mi := MeshInstance3D.new() var mesh := BoxMesh.new() mesh.size = size mi.mesh = mesh mi.position = pos mi.material_override = _material(color, role) parent.add_child(mi) return mi func _material(color: Color, role: String = "cloth") -> StandardMaterial3D: return Materials.flat(color, role) ## Small per-archetype props so silhouettes differ (camera, box, hat, sign…). func _add_props(npc_type: int) -> void: var chest_y := 1.25 match npc_type: Rules.NpcType.INFLUENCER: _photo_prop = _add_box_prop(_rig, Vector3(0.16, 0.1, 0.09), Vector3(0.3, chest_y, 0.22), Color(0.1, 0.1, 0.1)) _photo_prop.name = "PhotoProp" _photo_prop.visible = false _photo_prop_base_y = _photo_prop.position.y _photo_flash = _add_box_prop(_rig, Vector3(0.05, 0.05, 0.06), Vector3(0.34, chest_y + 0.06, 0.30), Color(0.4, 0.7, 1.0)) _photo_flash.visible = false Rules.NpcType.DELIVERY: _add_box_prop(_rig, Vector3(0.34, 0.26, 0.28), Vector3(0.0, 1.02, 0.3), Color(0.55, 0.4, 0.25)) Rules.NpcType.PERFORMER: var hat := _material(Color(0.85, 0.2, 0.25)) var cone := MeshInstance3D.new() var cm := CylinderMesh.new() cm.top_radius = 0.02 cm.bottom_radius = 0.22 cm.height = 0.4 cone.mesh = cm cone.material_override = hat cone.position = Vector3(0, 0.26, 0) _head.add_child(cone) # Hat in one hand and a small board in the other: the performer plays # to the sidewalk once at the waypoint. _add_box_prop(_rig, Vector3(0.06, 0.5, 0.06), Vector3(-0.42, 1.3, 0.14), Color(0.45, 0.35, 0.25)) _sign_prop = _add_box_prop(_rig, Vector3(0.42, 0.32, 0.03), Vector3(-0.46, 1.6, 0.14), Color(0.95, 0.9, 0.75)) _sign_prop_base_y = _sign_prop.position.y Rules.NpcType.PROTESTER: _add_box_prop(_rig, Vector3(0.05, 1.2, 0.05), Vector3(0.28, 1.45, 0.18), Color(0.4, 0.36, 0.32)) _sign_prop = _add_box_prop(_rig, Vector3(0.5, 0.42, 0.04), Vector3(0.48, 1.55, 0.18), Color(0.93, 0.93, 0.95)) _sign_prop_base_y = _sign_prop.position.y Rules.NpcType.TOURIST: _photo_prop = _add_box_prop(_rig, Vector3(0.3, 0.36, 0.14), Vector3(-0.34, 1.15, -0.18), Color(0.25, 0.3, 0.45)) _photo_prop.name = "PhotoProp" _photo_prop.visible = false _photo_prop_base_y = _photo_prop.position.y Rules.NpcType.CONFUSED_VISITOR: _add_box_prop(_rig, Vector3(0.3, 0.05, 0.34), Vector3(0, 0.92, 0.06), Color(0.9, 0.88, 0.8)) func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) -> MeshInstance3D: var mi := MeshInstance3D.new() var mesh := BoxMesh.new() mesh.size = size mi.mesh = mesh mi.position = pos mi.material_override = _material(color) parent.add_child(mi) return mi ## Rubberneck: idle people stop and turn toward the incident. Returns false when ## this NPC is too engaged (walking, being talked to, already leaving) to watch. func start_rubberneck(at: Vector3) -> bool: if not arrived or npc.leave_requested or leave_requested_by_lifetime: return false # Someone actually waiting at the entrance is being dealt with, not a bystander. # Avatars live under the identity SceneRoot, so `position` matches world space # and also works before the node enters the tree (tests). if awaiting_interaction and (position - door_position).length() < 3.0: return false if reaction_mode == "flee": return false reaction_mode = "watch" reaction_point = at _reaction_timer = RUBBERNECK_S velocity = Vector3.ZERO return true ## Panic: clear away from the incident (usually the door) in a straight line. func start_flee(from: Vector3) -> void: if npc.leave_requested or leave_requested_by_lifetime: return reaction_mode = "flee" reaction_point = from _reaction_timer = FLEE_S var away := global_position - from away.y = 0.0 if away.length() < 0.5: away = Vector3(0.0, 0.0, 1.0) target_position = global_position + away.normalized() * 25.0 target_position.y = 0.5 arrived = false awaiting_interaction = false func reaction_is_active() -> bool: return reaction_mode != "" func _tick_reaction(delta: float) -> void: if reaction_mode == "": return _reaction_timer -= delta if reaction_mode == "flee": if _reaction_timer <= 0.0 or (global_position - target_position).length() < 1.5: reaction_mode = "" queue_free() return if _reaction_timer <= 0.0: reaction_mode = "" ## Turn the body toward the reaction point without moving. func _face_reaction_point(delta: float) -> void: var to_point := reaction_point - global_position to_point.y = 0.0 if to_point.length() < 0.1: return rotation.y = lerp_angle(rotation.y, yaw_towards(to_point), clampf(delta * 4.0, 0.0, 1.0)) func _physics_process(delta: float) -> void: if npc == null or npc.fsm == null: return _tick_lod(delta) if awaiting_interaction and _queue_offset != Vector3.ZERO: var queued := door_position + _queue_offset if target_position.distance_to(queued) > 0.05: target_position = queued arrived = false _advance_fsm(delta) _tick_reaction(delta) if reaction_mode == "watch": velocity = Vector3.ZERO _face_reaction_point(delta) _animate(delta) return if reaction_mode == "flee": _move_toward_target(delta) _animate(delta) return _move_toward_target(delta) _animate(delta) ## Procedural walk bob + head-toward-guard cue when the NPC is aware. ## Theater pose for the current intent once the NPC has stopped walking. ## "sit" at the steps, "photo" for camera props, "perform" for the hat/sign, ## "film" for a slow pan, otherwise "idle". func theater_pose() -> String: if not arrived: return "idle" match npc.intent: "sit": return "sit" "photo": return "photo" "film": return "film" "perform": return "perform" _: return "idle" ## How far the rig drops for a seated pose. Pure so it is headless-testable. static func crouch_for_pose(pose: String) -> float: return -0.34 if pose == "sit" else 0.0 ## Vertical lift of a held camera prop. `phase` is seconds; the raise is brief so ## it reads as taking a photo rather than a permanently raised arm. static func prop_lift_for_pose(pose: String, phase: float) -> float: if pose != "photo" and pose != "film": return 0.0 return maxf(0.0, sin(phase * 1.7)) * 0.16 ## The tourist's camera bag and the influencer's chest camera are only part of ## the silhouette while the character is actually taking photos. Keeping them ## hidden at other times prevents the tourist from reading as two backpacks. static func photo_prop_visible_for_pose(pose: String) -> bool: return pose == "photo" or pose == "film" ## Opposite arm/leg swing for the walk cycle. `side` is 0 or 1; pure so the gait ## can be checked without a scene tree. static func limb_swing(phase: float, side: int, amplitude: float) -> float: return sin(phase + float(side) * PI) * amplitude ## Sway amount for a performer playing to the sidewalk. static func sway_for_pose(pose: String, phase: float) -> float: if pose != "perform": return 0.0 return sin(phase * 2.2) * 0.09 func _animate(delta: float) -> void: if _rig == null: return var speed := Vector2(velocity.x, velocity.z).length() var pose := theater_pose() var escalation: int = npc.highest_escalation_received if npc != null else 0 var crouch := crouch_for_pose(pose) if _photo_prop: _photo_prop.visible = photo_prop_visible_for_pose(pose) if _photo_flash: _photo_flash.visible = photo_prop_visible_for_pose(pose) if speed > 0.3: _idle_t = 0.0 _bob_phase += delta * (6.0 + speed * 1.7) var urgency := 1.35 if reaction_mode == "flee" else 1.0 # Coats and skirts follow the hip/knee bones; a shorter stride keeps the # hem from clipping through the legs during normal walking. var hem_scale := 0.76 if character_model in ["npc_dress", "npc_coat"] else 1.0 var stride := clampf(0.36 + speed * 0.10, 0.42, 0.7) * urgency * hem_scale _rig.position.y = sin(_bob_phase * 2.0) * 0.03 _rig.position.x = 0.0 _rig.rotation.x = sin(_bob_phase) * 0.03 _rig.rotation.z = sin(_bob_phase) * 0.02 for i in _legs.size(): var swing := limb_swing(_bob_phase, i, stride) _legs[i].rotation.x = swing # Bend the knee as the leg travels behind the body. var bend := maxf(0.0, -swing) * (1.7 if reaction_mode == "flee" else 1.15) _knees[i].rotation.x = lerpf(_knees[i].rotation.x, bend, 0.35) _arms[i].rotation.x = limb_swing(_bob_phase, i, -0.34 * urgency) _elbows[i].rotation.x = -0.18 - absf(swing) * 0.55 elif pose != "idle" or not is_zero_approx(_rig.position.y): # Idle theater: seated, photographing, filming, or performing. _theater_t += delta _idle_t = 0.0 _bob_phase = 0.0 _rig.position.y = lerpf(_rig.position.y, crouch, 0.12) _rig.position.x = lerpf(_rig.position.x, 0.0, 0.2) _rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2) var lift := prop_lift_for_pose(pose, _theater_t) if _photo_prop: _photo_prop.position.y = _photo_prop_base_y + lift _photo_prop.rotation.x = -lift * 1.4 var sway := sway_for_pose(pose, _theater_t) if _sign_prop: _sign_prop.rotation.z = sway _sign_prop.position.y = _sign_prop_base_y + absf(sway) * 0.25 if _legs.size() == 2: # Negative pitch swings the legs forward (toward the way the body faces). var leg_angle := deg_to_rad(-78.0) if pose == "sit" else 0.0 _legs[0].rotation.x = lerpf(_legs[0].rotation.x, leg_angle, 0.12) _legs[1].rotation.x = lerpf(_legs[1].rotation.x, leg_angle, 0.12) var knee_angle := deg_to_rad(80.0) if pose == "sit" else 0.0 _knees[0].rotation.x = lerpf(_knees[0].rotation.x, knee_angle, 0.12) _knees[1].rotation.x = lerpf(_knees[1].rotation.x, knee_angle, 0.12) for arm in _arms: arm.rotation.x = lerpf(arm.rotation.x, 0.0, 0.14) for elbow in _elbows: elbow.rotation.x = lerpf(elbow.rotation.x, -0.1, 0.14) else: _bob_phase = 0.0 _rig.position.y = lerpf(_rig.position.y, 0.0, 0.2) _rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2) for i in _legs.size(): _legs[i].rotation.x = lerpf(_legs[i].rotation.x, 0.0, 0.2) _knees[i].rotation.x = lerpf(_knees[i].rotation.x, 0.0, 0.2) for i in _arms.size(): _arms[i].rotation.x = lerpf(_arms[i].rotation.x, 0.0, 0.18) _elbows[i].rotation.x = lerpf(_elbows[i].rotation.x, -0.08, 0.18) _idle_t += delta _idle_layer(pose, escalation) # The focus circle billboards via its material; no per-frame look_at needed. # Attention cue: head turns toward the guard when awareness was raised. if _head != null: if npc != null and npc.guard_noticed: if _guard_ref == null or not is_instance_valid(_guard_ref): _guard_ref = get_tree().get_first_node_in_group("guard") as Node3D if _guard_ref: var to_guard := (_guard_ref.global_position - global_position) to_guard.y = 0.0 var world_yaw := atan2(to_guard.x, to_guard.z) var fwd := -_rig.global_transform.basis.z var parent_yaw := atan2(fwd.x, fwd.z) _head.rotation.y = lerp_angle(_head.rotation.y, world_yaw - parent_yaw, 0.1) elif speed <= 0.3 and pose == "idle": # Idle look-around, unless the person is already engaged. _head.rotation.y = lerpf(_head.rotation.y, sin(_idle_t * 0.45) * 0.3, 0.05) _update_reaction_label(escalation) _sync_skeleton() ## Idle behaviour layered on top of the breathing pose: phone, clipboard, ## crossed arms, or looking around, plus escalation body language. func _idle_layer(pose: String, escalation: int) -> void: if _rig == null: return # Breathing and a slow weight shift keep idle people from looking frozen. var breath := 1.0 + sin(_idle_t * 1.8) * 0.006 if _skeleton == null: _rig.scale.y = lerpf(_rig.scale.y, _height_scale * breath, 0.15) else: # Skinned meshes must scale uniformly; non-uniform parent scale shears # the skeleton's skin matrices. _rig.scale = Vector3.ONE * lerpf(_rig.scale.x, _height_scale * breath, 0.15) _rig.position.x = lerpf(_rig.position.x, sin(_idle_t * 0.7) * 0.015, 0.1) _rig.rotation.z = lerpf(_rig.rotation.z, sin(_idle_t * 0.7) * 0.015, 0.1) if pose != "idle" or _arms.size() < 2: return var right := _arms[1] var right_elbow := _elbows[1] if escalation >= 3: # Both hands up: the person is being blocked or warned. _arms[0].rotation.x = lerpf(_arms[0].rotation.x, -1.75, 0.14) right.rotation.x = lerpf(right.rotation.x, -1.75, 0.14) _elbows[0].rotation.x = lerpf(_elbows[0].rotation.x, -0.5, 0.14) right_elbow.rotation.x = lerpf(right_elbow.rotation.x, -0.5, 0.14) _rig.rotation.x = lerpf(_rig.rotation.x, -0.06, 0.1) return if escalation >= 2: # One arm out, pointing forward while arguing. right.rotation.x = lerpf(right.rotation.x, -1.15, 0.12) right_elbow.rotation.x = lerpf(right_elbow.rotation.x, -0.15, 0.12) return match _idle_style: "phone": # Phone held up near the head in bursts. var raise := 0.55 + maxf(0.0, sin(_idle_t * 0.6)) * 0.45 right.rotation.x = lerpf(right.rotation.x, -1.35 * raise, 0.08) right_elbow.rotation.x = lerpf(right_elbow.rotation.x, -1.75 * raise, 0.1) "clipboard": _arms[0].rotation.x = lerpf(_arms[0].rotation.x, -0.55, 0.1) _elbows[0].rotation.x = lerpf(_elbows[0].rotation.x, -0.9, 0.1) right.rotation.x = lerpf(right.rotation.x, -0.45, 0.1) right_elbow.rotation.x = lerpf(right_elbow.rotation.x, -0.8, 0.1) "crossed": _arms[0].rotation.x = lerpf(_arms[0].rotation.x, -0.55, 0.1) right.rotation.x = lerpf(right.rotation.x, -0.55, 0.1) _elbows[0].rotation.x = lerpf(_elbows[0].rotation.x, -1.25, 0.1) right_elbow.rotation.x = lerpf(right_elbow.rotation.x, -1.25, 0.1) "look": pass _: pass func _update_reaction_label(escalation: int) -> void: if _reaction_tag == null: return var text := reaction_label(reaction_mode, escalation) _reaction_tag.visible = text != "" if text == "": return _reaction_tag.text = text if reaction_mode == "flee": _reaction_tag.modulate = Color(1.0, 0.26, 0.2) elif reaction_mode == "watch": _reaction_tag.modulate = Color(1.0, 0.72, 0.2) elif escalation >= 3: _reaction_tag.modulate = Color(1.0, 0.35, 0.3) else: _reaction_tag.modulate = Color(1.0, 0.85, 0.4) ## Build the far-distance impostor: two billboard quads (body + head) in the ## avatar's clothing colours. func _build_impostor() -> void: _impostor = Node3D.new() _impostor.name = "Impostor" _impostor.visible = false add_child(_impostor) var body := MeshInstance3D.new() var quad := QuadMesh.new() quad.size = Vector2(0.56, 1.15) body.mesh = quad body.position = Vector3(0, 0.95, 0) body.material_override = _billboard_material(_shirt_color) _impostor.add_child(body) var head := MeshInstance3D.new() var head_quad := QuadMesh.new() head_quad.size = Vector2(0.34, 0.34) head.mesh = head_quad head.position = Vector3(0, 1.63, 0) head.material_override = _billboard_material(_skin_color) _impostor.add_child(head) func _billboard_material(color: Color) -> StandardMaterial3D: var material := StandardMaterial3D.new() material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED material.albedo_color = color material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED return material ## Apply a LOD tier: 0 full detail, 1 no small props/shadows, 2 impostor. func apply_lod(tier: int) -> void: _lod_tier = clampi(tier, 0, 2) if _rig != null: _rig.visible = _lod_tier < 2 if _impostor != null: _impostor.visible = _lod_tier == 2 for node in _detail_nodes: if is_instance_valid(node): node.visible = _lod_tier == 0 var shadows := _lod_tier == 0 for node in find_children("*", "MeshInstance3D", true, false): (node as MeshInstance3D).cast_shadow = \ GeometryInstance3D.SHADOW_CASTING_SETTING_ON if shadows \ else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF func _lod_tier_for(distance: float) -> int: # Hysteresis: stay in the current tier until the distance clears the edge. if _lod_tier == 0: return 1 if distance > LOD_TIERS[0] + LOD_HYSTERESIS else 0 if _lod_tier == 1: if distance < LOD_TIERS[0] - LOD_HYSTERESIS: return 0 return 2 if distance > LOD_TIERS[1] + LOD_HYSTERESIS else 1 if distance < LOD_TIERS[1] - LOD_HYSTERESIS: return 1 if distance >= LOD_TIERS[0] else 0 return 2 func set_queue_offset(offset: Vector3) -> void: _queue_offset = offset func _tick_lod(delta: float) -> void: _lod_check_s -= delta if _lod_check_s > 0.0: return _lod_check_s = 0.5 if not is_inside_tree(): return var camera := get_viewport().get_camera_3d() if camera == null: return var distance := global_position.distance_to(camera.global_position) var desired := _lod_tier_for(distance) if desired != _lod_tier: apply_lod(desired) func _advance_fsm(delta: float) -> void: # Routine traffic despawns after a while once it has finished its walk, so # the crowd cap is not permanently occupied by idle standers. if ephemeral and arrived and npc.fsm.current_state != "LEAVING": idle_s += delta if idle_s >= lifetime_s: leave_requested_by_lifetime = true return # Returning-trespasser: an NPC directed away may circle back to the door. if npc.tick_return(delta): arrived = false awaiting_interaction = false target_position = door_position return if npc.leave_requested or leave_requested_by_lifetime: var away := global_position + (global_position - spawn_position).normalized() * 40.0 away.y = 0.5 target_position = away if (global_position - away).length() < 2.0: queue_free() return if arrived and not awaiting_interaction: awaiting_interaction = true var leave_requested_by_lifetime := false func _move_toward_target(delta: float) -> void: _avoidance_active = false if agent == null: _move_direct(delta) return # Re-assigning the target every physics frame invalidates the path, so # get_next_path_position() keeps returning the current position and the # avatar never moves. Only update the agent when the target changes. if agent.target_position != target_position: agent.target_position = target_position if agent.is_navigation_finished(): if (global_position - target_position).length() < 3.0: _arrive() else: # Path finished but we are far away: navigation is unusable, fall back. _move_direct(delta) return # Learn whether the navmesh actually serves this spot. if not _nav_usable: _nav_try_frames += 1 if agent.is_target_reachable(): _nav_usable = true elif _nav_try_frames > 10: _move_direct(delta) return var next := agent.get_next_path_position() # Godot reports the current position as the first path point (on the navmesh # plane, so below the body) and only advances past it once the agent is within # path_desired_distance. Steering at the point we are standing on gives a # zero-length direction, and steering at any point merely "far enough" away can # pick the old start behind us. Follow the first path point that is genuinely # closer to the destination than we are. var path := agent.get_current_navigation_path() if path.size() >= 2: var goal: Vector3 = path[path.size() - 1] var here_to_goal := Vector2(goal.x - global_position.x, goal.z - global_position.z).length() for i in range(1, path.size()): var candidate: Vector3 = path[i] candidate.y = global_position.y var candidate_to_goal := Vector2(goal.x - candidate.x, goal.z - candidate.z).length() if candidate_to_goal < here_to_goal - 0.05: next = candidate break var dir := (next - global_position) dir.y = 0.0 if dir.length() < 0.2: velocity = Vector3.ZERO return dir = dir.normalized() var speed := FLEE_SPEED if reaction_mode == "flee" else 1.3 rotation.y = yaw_towards(dir) if agent.avoidance_enabled: # The navigation server returns the obstacle-aware velocity through # velocity_computed; never dash straight at the point in the meantime. _avoidance_active = true agent.velocity = Vector3(dir.x * speed, 0.0, dir.z * speed) else: velocity.x = dir.x * speed velocity.z = dir.z * speed velocity.y -= 9.8 * delta move_and_slide() ## Safe velocity from NavigationServer avoidance: move with the steered vector. func _on_safe_velocity(safe: Vector3) -> void: if not _avoidance_active: return velocity.x = safe.x velocity.z = safe.z if is_on_floor(): velocity.y = 0.0 else: velocity.y -= 9.8 * get_physics_process_delta_time() move_and_slide() ## Fallback used when no navigation mesh is available (e.g. minimal scenes). func _move_direct(delta: float) -> void: _avoidance_active = false var to_target := target_position - global_position to_target.y = 0.0 var dist := to_target.length() if dist < 0.7: _arrive() return var dir := to_target.normalized() var speed := FLEE_SPEED if reaction_mode == "flee" else 1.3 velocity.x = dir.x * speed velocity.z = dir.z * speed velocity.y -= 9.8 * delta rotation.y = yaw_towards(dir) move_and_slide() func _arrive() -> void: arrived = true velocity = Vector3.ZERO if npc.intent == "enter" or npc.intent == "trespass" or npc.intent == "deliver" or npc.intent == "arrive": target_position = door_position if (global_position - door_position).length() < 0.8: awaiting_interaction = true func _on_state_changed(_node: Object, _from: String, to_state: String) -> void: match to_state: "LEAVING": npc.leave_requested = true "THREATENING", "FORCING_ENTRY": # Advance toward the door posture; keep it simple for the prototype. pass