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 @onready var body_mesh: MeshInstance3D = $MeshInstance3D @onready var profession_prop: MeshInstance3D = $ProfessionProp @onready var profession_label: Label3D = $ProfessionLabel @onready var carried_food_visual: MeshInstance3D = $CarriedFood 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] _apply_profession_presentation() set_carried_food_visible(npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) > 0.0) func _apply_profession_presentation() -> void: var definition := SimulationDefinitions.get_profession(profession) if definition == null: profession_label.text = npc_name profession_prop.visible = false return var body_material := StandardMaterial3D.new() body_material.albedo_color = definition.visual_color body_material.roughness = 0.82 body_mesh.material_override = body_material var prop_material := StandardMaterial3D.new() prop_material.albedo_color = definition.prop_color prop_material.roughness = 0.78 profession_prop.material_override = prop_material profession_prop.mesh = _create_profession_prop_mesh() profession_prop.visible = profession_prop.mesh != null profession_label.text = "%s\n%s" % [npc_name, definition.display_name] func _create_profession_prop_mesh() -> PrimitiveMesh: profession_prop.position = Vector3(0.48, 0.85, 0.0) profession_prop.rotation_degrees = Vector3.ZERO match profession: SimulationIds.PROFESSION_FARMER: var basket := SphereMesh.new() basket.radius = 0.24 basket.height = 0.34 return basket SimulationIds.PROFESSION_WOODCUTTER: var tool := BoxMesh.new() tool.size = Vector3(0.12, 0.72, 0.16) profession_prop.position = Vector3(0.48, 0.92, 0.0) profession_prop.rotation_degrees.z = -18.0 return tool SimulationIds.PROFESSION_GUARD: var shield := CylinderMesh.new() shield.top_radius = 0.3 shield.bottom_radius = 0.3 shield.height = 0.12 profession_prop.rotation_degrees.x = 90.0 return shield SimulationIds.PROFESSION_SCHOLAR: var book := BoxMesh.new() book.size = Vector3(0.42, 0.1, 0.3) return book SimulationIds.PROFESSION_WANDERER: var pack := SphereMesh.new() pack.radius = 0.28 pack.height = 0.5 profession_prop.position = Vector3(-0.42, 0.86, 0.0) return pack return null func set_carried_food_visible(is_visible: bool) -> void: carried_food_visual.visible = is_visible and not is_dead_visual 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 carried_food_visual.visible = false profession_prop.visible = false 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")