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") 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 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 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") _build_body(profile.npc_type) _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 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 _head: Node3D = null var _bob_phase := 0.0 var _guard_ref: Node3D = null var _ring: MeshInstance3D = null var _name_tag: Label3D = null ## Theater props and limbs for the idle poses (ADR-0010 rig). var _photo_prop: MeshInstance3D = null var _sign_prop: MeshInstance3D = null var _legs: Array[MeshInstance3D] = [] var _theater_t := 0.0 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)] func _build_body(npc_type: int) -> void: _rig = Node3D.new() _rig.name = "Rig" add_child(_rig) var base_color: Color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7)) # Per-seed variation: slight height and brightness differences so crowds # read as individuals (deterministic LCG, no RNG side effects). var frac := float((_visual_seed * 1103515245 + 12345) % 1000) / 1000.0 var height_scale := 0.92 + 0.16 * frac _rig.scale.y = height_scale var shader_delta := 0.12 * frac base_color = base_color.lightened(shader_delta) if frac < 0.5 else base_color.darkened(shader_delta) var limbs_color := base_color.darkened(0.25) # Torso _add_capsule(_rig, 0.30, 0.95, Vector3(0, 1.05, 0), base_color) # Legs _legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), PANTS_COLOR)) _legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), PANTS_COLOR)) # Arms _add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0) _add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0) # Neck, head, face, hair, shoes so the body reads as a person (ADR-0010). _add_capsule(_rig, 0.08, 0.16, Vector3(0, 1.48, 0), SKIN_TONE) _head = Node3D.new() _head.name = "Head" _head.position = Vector3(0, 1.62, 0) _add_sphere(_head, 0.17, Vector3.ZERO, SKIN_TONE) var hair_i := (_visual_seed * 7) % HAIR_COLORS.size() _add_sphere(_head, 0.18, Vector3(0, 0.08, -0.02), HAIR_COLORS[hair_i]) _add_sphere(_head, 0.035, Vector3(-0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1)) _add_sphere(_head, 0.035, Vector3(0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1)) _rig.add_child(_head) _add_box_prop(_rig, Vector3(0.16, 0.08, 0.28), Vector3(-0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14)) _add_box_prop(_rig, Vector3(0.16, 0.08, 0.28), Vector3(0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14)) _build_focus_ring() _add_props(npc_type) func _build_focus_ring() -> void: _ring = MeshInstance3D.new() _ring.name = "FocusRing" var torus := TorusMesh.new() torus.inner_radius = 0.26 torus.outer_radius = 0.34 torus.rings = 12 torus.ring_segments = 16 _ring.mesh = torus var mat := StandardMaterial3D.new() mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED mat.albedo_color = Color(0.95, 0.82, 0.28, 0.9) mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA _ring.material_override = mat _ring.position = Vector3(0, 0.28, 0) _ring.visible = false _head.add_child(_ring) _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) func set_focus_visual(focused: bool, menu_open: bool = false) -> void: if _ring: _ring.visible = focused var mat := _ring.material_override as StandardMaterial3D if mat: mat.albedo_color = Color(1.0, 0.92, 0.35, 1.0) if menu_open else Color(0.95, 0.82, 0.28, 0.85) _ring.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) -> 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) parent.add_child(mi) return mi func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color) -> void: 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) parent.add_child(mi) func _material(color: Color) -> StandardMaterial3D: var mat := StandardMaterial3D.new() mat.albedo_color = color return mat ## 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_base_y = _photo_prop.position.y _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)) 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_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 var target_yaw := atan2(-to_point.x, -to_point.z) rotation.y = lerp_angle(rotation.y, target_yaw, clampf(delta * 4.0, 0.0, 1.0)) func _physics_process(delta: float) -> void: if npc == null or npc.fsm == null: return _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 ## 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 crouch := crouch_for_pose(pose) if speed > 0.3: _bob_phase += delta * (6.0 + speed) _rig.position.y = sin(_bob_phase) * 0.035 _rig.rotation.x = sin(_bob_phase * 0.5) * 0.04 elif pose != "idle" or not is_zero_approx(_rig.position.y): # Idle theater: seated, photographing, filming, or performing. _theater_t += delta _bob_phase = 0.0 _rig.position.y = lerpf(_rig.position.y, crouch, 0.12) _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) 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) if _ring and _ring.visible: var cam := get_viewport().get_camera_3d() if get_viewport() else null if cam: _ring.look_at(cam.global_position, Vector3.UP) # Attention cue: head turns toward the guard when awareness was raised. if _head != 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) 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: 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 velocity.x = dir.x * speed velocity.z = dir.z * speed velocity.y -= 9.8 * delta look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP) move_and_slide() ## Fallback used when no navigation mesh is available (e.g. minimal scenes). func _move_direct(delta: float) -> void: 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 look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP) 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