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 @onready var task_glyph_root: Node3D = $TaskGlyphRoot @onready var task_glyph: MeshInstance3D = $TaskGlyphRoot/TaskGlyph 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() var debug_overlay_visible := true 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) set_task_presentation(npc.current_task, npc.task_state) 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] profession_label.visible = debug_overlay_visible 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_task_presentation(action_id: StringName, task_state: StringName) -> void: if task_glyph_root == null or task_glyph == null: return if is_dead_visual or not _should_show_task_glyph(action_id, task_state): task_glyph_root.visible = false return task_glyph.mesh = _create_task_glyph_mesh(action_id) task_glyph.material_override = _create_task_glyph_material(action_id) task_glyph_root.visible = task_glyph.mesh != null func set_debug_overlay_visible(is_visible: bool) -> void: debug_overlay_visible = is_visible if profession_label != null: profession_label.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 to_next := 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 if not is_on_floor(): velocity.y -= 30.0 * delta else: 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 profession_label.visible = false task_glyph_root.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") func _should_show_task_glyph(action_id: StringName, task_state: StringName) -> bool: if task_state not in [SimNPC.TASK_STATE_TRAVELING, SimNPC.TASK_STATE_WORKING]: return false return action_id not in [ SimulationIds.ACTION_IDLE, SimulationIds.ACTION_DEAD, SimulationIds.ACTION_WANDER, ] func _create_task_glyph_mesh(action_id: StringName) -> PrimitiveMesh: task_glyph.rotation_degrees = Vector3.ZERO match action_id: SimulationIds.ACTION_GATHER_FOOD, SimulationIds.ACTION_EAT, SimulationIds.ACTION_WITHDRAW_FOOD: var food := SphereMesh.new() food.radius = 0.18 food.height = 0.26 return food SimulationIds.ACTION_GATHER_WOOD: var wood := BoxMesh.new() wood.size = Vector3(0.18, 0.42, 0.18) task_glyph.rotation_degrees = Vector3(0, 0, -18) return wood SimulationIds.ACTION_DEPOSIT_FOOD: var crate := BoxMesh.new() crate.size = Vector3(0.34, 0.22, 0.34) return crate SimulationIds.ACTION_PATROL: var shield := CylinderMesh.new() shield.top_radius = 0.2 shield.bottom_radius = 0.2 shield.height = 0.1 task_glyph.rotation_degrees = Vector3(90, 0, 0) return shield SimulationIds.ACTION_STUDY: var book := BoxMesh.new() book.size = Vector3(0.36, 0.08, 0.24) return book SimulationIds.ACTION_REST: var rest := SphereMesh.new() rest.radius = 0.2 rest.height = 0.12 return rest return null func _create_task_glyph_material(action_id: StringName) -> StandardMaterial3D: var material := StandardMaterial3D.new() material.roughness = 0.65 material.emission_enabled = true material.emission_energy_multiplier = 0.35 var color := _get_task_glyph_color(action_id) material.albedo_color = color material.emission = color return material func _get_task_glyph_color(action_id: StringName) -> Color: match action_id: SimulationIds.ACTION_GATHER_FOOD, SimulationIds.ACTION_EAT, SimulationIds.ACTION_WITHDRAW_FOOD: return Color(0.95, 0.42, 0.25, 1.0) SimulationIds.ACTION_GATHER_WOOD: return Color(0.58, 0.34, 0.16, 1.0) SimulationIds.ACTION_DEPOSIT_FOOD: return Color(0.95, 0.72, 0.32, 1.0) SimulationIds.ACTION_PATROL: return Color(0.36, 0.58, 0.95, 1.0) SimulationIds.ACTION_STUDY: return Color(0.62, 0.46, 0.9, 1.0) SimulationIds.ACTION_REST: return Color(0.4, 0.78, 0.72, 1.0) return Color(0.9, 0.9, 0.85, 1.0)