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 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)) -> void: npc.setup("npc_%d" % seed_value, profile, intent, 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) _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 shape.height = 1.6 collider.shape = shape collider.position = Vector3(0, 0.8, 0) add_child(collider) func _build_body(npc_type: int) -> void: var body := MeshInstance3D.new() var capsule := CapsuleMesh.new() capsule.radius = 0.35 capsule.height = 1.6 body.mesh = capsule var mat := StandardMaterial3D.new() mat.albedo_color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7)) body.material_override = mat add_child(body) var id_tag := MeshInstance3D.new() var sphere := SphereMesh.new() sphere.radius = 0.12 sphere.height = 0.24 var tag_mat := StandardMaterial3D.new() tag_mat.albedo_color = Color(1, 1, 1) id_tag.mesh = sphere id_tag.material_override = tag_mat id_tag.position = Vector3(0, 1.05, 0) id_tag.scale = Vector3(1, 0.4, 1) add_child(id_tag) func _physics_process(delta: float) -> void: if npc == null or npc.fsm == null: return _advance_fsm(delta) _move_toward_target(delta) func _advance_fsm(delta: float) -> void: # 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: var away := global_position + (global_position - spawn_position).normalized() * 40.0 away.y = 0.5 target_position = away return if arrived and not awaiting_interaction: awaiting_interaction = true func _move_toward_target(delta: float) -> void: if agent == null: _move_direct(delta) return 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() var dir := (next - global_position) dir.y = 0.0 if dir.length() < 0.2: velocity = Vector3.ZERO return dir = dir.normalized() velocity.x = dir.x * 1.3 velocity.z = dir.z * 1.3 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() velocity.x = dir.x * 1.3 velocity.z = dir.z * 1.3 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