extends CharacterBody3D signal arrived_at_target(sim_id: int) signal navigation_failed(sim_id: int) signal position_changed(sim_id: int, active_position: Vector3) const DEBUG_LOGS := true @export var move_speed := 3.0 @export var rotation_speed := 8.0 @export var waypoint_reached_distance := 0.35 @export var stuck_timeout := 1.5 @onready var nav_agent: NavigationAgent3D = $NavigationAgent3D var has_reported_arrival := false var sim_id: int = -1 var npc_name: String = "" var profession: StringName var current_path: PackedVector3Array = [] var path_index := 0 var current_target := Vector3.INF var arrival_distance := 0.6 var stuck_time := 0.0 var path_request_id := 0 var path_pending := false 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: path_request_id += 1 var request_id := path_request_id current_target = pos has_reported_arrival = false stuck_time = 0.0 path_pending = 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 path_pending = true await get_tree().physics_frame if request_id != path_request_id or is_dead_visual: return current_path = NavigationServer3D.map_get_path( get_world_3d().navigation_map, global_position, pos, true ) path_pending = false path_index = 0 if current_path.is_empty(): debug_log("Path is empty") _report_navigation_failure_once() 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 path_pending: 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() _report_navigation_failure_once() 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() var prev_pos := global_position velocity.x = direction.x * move_speed velocity.z = direction.z * move_speed velocity.y = 0.0 move_and_slide() if not global_position.is_equal_approx(prev_pos): position_changed.emit(sim_id, global_position) var minimum_progress := move_speed * delta * 0.1 if global_position.distance_squared_to(prev_pos) < minimum_progress * minimum_progress: stuck_time += delta if stuck_time >= stuck_timeout: debug_log("Movement remained blocked") _report_navigation_failure_once() return else: stuck_time = 0.0 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 _report_navigation_failure_once() -> void: if has_reported_arrival: return has_reported_arrival = true current_path = PackedVector3Array() path_index = 0 current_target = Vector3.INF path_pending = false velocity = Vector3.ZERO debug_log("Navigation failed") navigation_failed.emit(sim_id) func apply_dead_visual_state() -> void: is_dead_visual = true path_request_id += 1 velocity = Vector3.ZERO current_path = PackedVector3Array() path_index = 0 current_target = Vector3.INF path_pending = false 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 collision_layer = 0 collision_mask = 0 debug_log("Applied dead visual state")