extends CharacterBody3D signal arrived_at_target(sim_id: int) const DEBUG_LOGS := true @export var move_speed := 3.0 @export var rotation_speed := 8.0 @export var waypoint_reached_distance := 0.35 @onready var nav_agent: NavigationAgent3D = $NavigationAgent3D var has_reported_arrival := false var sim_id: int = -1 var npc_name: String = "" var profession: String = "" var current_path: PackedVector3Array = [] var path_index := 0 var current_target := Vector3.INF var arrival_distance := 0.6 var is_dead_visual := false var dead_material := StandardMaterial3D.new() func debug_log(message: String) -> void: if DEBUG_LOGS: print("[NPCVisual] ", name, " | ", message) func _ready() -> void: dead_material.albedo_color = Color(0.25, 0.25, 0.25, 1.0) func setup_from_sim(npc: SimNPC) -> void: sim_id = npc.id npc_name = npc.npc_name profession = npc.profession name = "NPC_%s_%s" % [sim_id, npc_name] func set_target_position(pos: Vector3) -> void: current_target = pos has_reported_arrival = false if global_position.distance_to(pos) <= arrival_distance: current_path = PackedVector3Array() path_index = 0 debug_log("Already close to target, will report arrival") return nav_agent.target_position = pos await get_tree().physics_frame current_path = NavigationServer3D.map_get_path( get_world_3d().navigation_map, global_position, pos, true ) path_index = 0 if current_path.is_empty(): debug_log("Path is empty. Will report arrival/failure fallback.") else: debug_log("New target: %s Path points: %s" % [pos, current_path.size()]) func _physics_process(delta: float) -> void: if is_dead_visual: velocity = Vector3.ZERO move_and_slide() return if current_target == Vector3.INF: velocity = Vector3.ZERO move_and_slide() return if global_position.distance_to(current_target) <= arrival_distance: _report_arrival_once() velocity = Vector3.ZERO move_and_slide() return if current_path.is_empty() or path_index >= current_path.size(): velocity = Vector3.ZERO move_and_slide() return var next_pos := current_path[path_index] var flat_next_pos := Vector3(next_pos.x, global_position.y, next_pos.z) var to_next := flat_next_pos - global_position if to_next.length() <= waypoint_reached_distance: path_index += 1 return var direction := to_next.normalized() velocity.x = direction.x * move_speed velocity.z = direction.z * move_speed velocity.y = 0.0 move_and_slide() var target_angle := atan2(direction.x, direction.z) rotation.y = lerp_angle(rotation.y, target_angle, rotation_speed * delta) func _report_arrival_once() -> void: if has_reported_arrival: return has_reported_arrival = true current_path = PackedVector3Array() path_index = 0 debug_log("Arrived at target") arrived_at_target.emit(sim_id) func apply_dead_visual_state() -> void: is_dead_visual = true velocity = Vector3.ZERO current_path = PackedVector3Array() path_index = 0 current_target = Vector3.INF has_reported_arrival = true scale = Vector3(0.8, 0.25, 1.2) rotation_degrees.x = 90.0 for child in get_children(): if child is MeshInstance3D: child.material_override = dead_material debug_log("Applied dead visual state")