From b8752f8835f234ac70d8e608e93dc13606a911ed Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Sat, 4 Jul 2026 19:24:30 +0200 Subject: [PATCH] feat: add navigation_failed signal with time-based stuck detection and simulation pipeline NpcVisual: - Add navigation_failed(sim_id) signal distinct from arrived_at_target so the simulation layer can distinguish genuine arrival from failure - Replace integer stuck_counter with delta-based stuck_time and exported stuck_timeout (1.5s) for frame-rate-independent detection - Add path_request_id / path_pending mechanism to reject stale async path results when a new target is set or NPC dies - On empty path or stuck movement, emit navigation_failed instead of arriving; only emit arrived_at_target when truly close to target - Reduce arrival_distance back to 0.6 now that navigation_failure is properly signalled (no longer needed as a workaround) - clamp arrival_distance reference in apply_dead_visual_state SimNPC: - Remove retry_count (replaced by navigation_failed signal pathway) - Move last_task assignment from set_task() to task completion in SimulationManager so it reflects the task that actually finished SimulationManager: - Add notify_npc_navigation_failed(): release reservation, record last_task, send NPC to wander for 2s, emit task_changed signal - Add notify_npc_target_unavailable() as public alias of above - Guard extraction with node.reserved_by == npc.id to prevent stale/illegitimate extraction when reservation is lost - Guard fallback zone task completion (apply_npc_task) so it only fires for non-resource tasks (gather_food/wood with no target silently skip instead of applying zone values) - Remove retry_count safety net (fully replaced by signal pipeline) WorldViewManager: - Connect navigation_failed signal in spawn_npc_visual() - Skip target assignment for empty/idle/dead task states - When no resource node available for gather_food/wood, call notify_npc_target_unavailable() so NPC wanders instead of idling - Add _on_npc_visual_navigation_failed handler delegating to SimulationManager.notify_npc_navigation_failed() - Remove retry_count references --- player/npc/NpcVisual.gd | 60 +++++++++++++++++++++++++-------- simulation/SimNPC.gd | 2 -- simulation/SimulationManager.gd | 44 +++++++++++++++--------- world/world_view_manager.gd | 37 +++++++++++++++++--- 4 files changed, 106 insertions(+), 37 deletions(-) diff --git a/player/npc/NpcVisual.gd b/player/npc/NpcVisual.gd index 2367314..9dbd792 100644 --- a/player/npc/NpcVisual.gd +++ b/player/npc/NpcVisual.gd @@ -1,12 +1,14 @@ extends CharacterBody3D signal arrived_at_target(sim_id: int) +signal navigation_failed(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 +@export var stuck_timeout := 1.5 @onready var nav_agent: NavigationAgent3D = $NavigationAgent3D @@ -18,8 +20,10 @@ var profession: String = "" var current_path: PackedVector3Array = [] var path_index := 0 var current_target := Vector3.INF -var arrival_distance := 1.5 -var stuck_counter := 0 +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() @@ -38,8 +42,12 @@ func setup_from_sim(npc: SimNPC) -> void: 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() @@ -48,9 +56,13 @@ func set_target_position(pos: Vector3) -> void: 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, @@ -58,11 +70,12 @@ func set_target_position(pos: Vector3) -> void: true ) + path_pending = false path_index = 0 if current_path.is_empty(): - debug_log("Path is empty, stuck counter will handle fallback.") - nav_agent.target_position = global_position + debug_log("Path is empty") + _report_navigation_failure_once() else: debug_log("New target: %s Path points: %s" % [pos, current_path.size()]) @@ -77,6 +90,11 @@ func _physics_process(delta: float) -> void: 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 @@ -86,10 +104,7 @@ func _physics_process(delta: float) -> void: if current_path.is_empty() or path_index >= current_path.size(): velocity = Vector3.ZERO move_and_slide() - stuck_counter += 1 - if stuck_counter > 90: - debug_log("Stuck with no valid path, reporting arrival") - _report_arrival_once() + _report_navigation_failure_once() return var next_pos := current_path[path_index] @@ -109,14 +124,15 @@ func _physics_process(delta: float) -> void: move_and_slide() - if global_position.distance_squared_to(prev_pos) < 0.001: - stuck_counter += 1 - if stuck_counter > 90: - debug_log("Stuck while navigating, reporting arrival") - _report_arrival_once() + 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_counter = 0 + stuck_time = 0.0 var target_angle := atan2(direction.x, direction.z) rotation.y = lerp_angle(rotation.y, target_angle, rotation_speed * delta) @@ -132,12 +148,28 @@ func _report_arrival_once() -> void: 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) diff --git a/simulation/SimNPC.gd b/simulation/SimNPC.gd index c570ae3..6753b9b 100644 --- a/simulation/SimNPC.gd +++ b/simulation/SimNPC.gd @@ -28,7 +28,6 @@ var task_duration := 0.0 var task_progress := 0.0 var task_complete := true var target_id: StringName = &"" -var retry_count := 0 var last_task := "" func _init( @@ -247,7 +246,6 @@ func calculate_task_score( return score func set_task(task: String, duration: float) -> void: - last_task = current_task current_task = task task_duration = duration task_progress = 0.0 diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index 1cc68d3..db3a94a 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -87,21 +87,25 @@ func simulate_tick() -> void: if npc.target_id != &"": var node := ResourceNode.get_by_id(npc.target_id) - if node != null: + if node != null and node.reserved_by == npc.id: var extracted := node.extract() if extracted > 0: village.apply_resource_delta(node.resource_id, extracted) if DEBUG_LOGS: print("[SimulationManager] ", npc.npc_name, " extracted ", extracted, " ", node.resource_id, " from ", node.node_id) + elif DEBUG_LOGS: + print("[SimulationManager] ", npc.npc_name, " could not complete ", completed_task, ": target reservation was invalid") release_npc_reservation(npc.id) - else: + elif completed_task not in ["gather_food", "gather_wood"]: village.apply_npc_task(npc) + elif DEBUG_LOGS: + print("[SimulationManager] ", npc.npc_name, " could not complete ", completed_task, ": no resource target") village_was_changed = true - npc.retry_count = 0 npc.task_complete = true npc.task_state = SimNPC.TASK_STATE_IDLE + npc.last_task = completed_task npc.current_task = "idle" if needs_immediate_food_after_gather and village.food > 0: @@ -111,15 +115,6 @@ func simulate_tick() -> void: if DEBUG_LOGS: print("[SimulationManager] ", npc.npc_name, " gathered food while starving and is going to eat immediately.") - if npc.task_state == SimNPC.TASK_STATE_TRAVELING and npc.target_id == &"": - npc.retry_count += 1 - if npc.retry_count >= 5: - if DEBUG_LOGS: - print("[SimulationManager] ", npc.npc_name, " stuck traveling without target for ", npc.retry_count, " attempts, replanning") - npc.task_state = SimNPC.TASK_STATE_IDLE - npc.current_task = "" - npc.retry_count = 0 - if DEBUG_LOGS: print( npc.npc_name, @@ -184,10 +179,7 @@ func notify_npc_arrived(npc_id: int) -> void: if node == null or not node.can_extract(): if DEBUG_LOGS: print("[SimulationManager] ", npc.npc_name, " arrived but target ", npc.target_id, " is unavailable, replanning") - release_npc_reservation(npc.id) - npc.task_state = SimNPC.TASK_STATE_IDLE - npc.current_task = "" - npc.retry_count += 1 + notify_npc_navigation_failed(npc.id) return npc.start_working() @@ -196,6 +188,26 @@ func notify_npc_arrived(npc_id: int) -> void: print("[SimulationManager] ", npc.npc_name, " arrived and started working on: ", npc.current_task) return +func notify_npc_navigation_failed(npc_id: int) -> void: + for npc in npcs: + if npc.id != npc_id: + continue + if npc.is_dead or npc.task_state != SimNPC.TASK_STATE_TRAVELING: + return + + var failed_task := npc.current_task + release_npc_reservation(npc.id) + npc.last_task = failed_task + npc.set_task("wander", 2.0) + npc_task_changed.emit(npc, failed_task, npc.current_task) + + if DEBUG_LOGS: + print("[SimulationManager] ", npc.npc_name, " could not navigate for ", failed_task, " and is trying a wander target") + return + +func notify_npc_target_unavailable(npc_id: int) -> void: + notify_npc_navigation_failed(npc_id) + func eat_food(amount: float) -> void: village.food -= amount village_changed.emit(village) diff --git a/world/world_view_manager.gd b/world/world_view_manager.gd index 9afcf0c..273c6c5 100644 --- a/world/world_view_manager.gd +++ b/world/world_view_manager.gd @@ -71,6 +71,11 @@ func spawn_npc_visual(npc: SimNPC) -> void: else: push_error("WorldViewManager: NPCVisual has no arrived_at_target signal") + if visual.has_signal("navigation_failed"): + visual.navigation_failed.connect(_on_npc_visual_navigation_failed) + else: + push_error("WorldViewManager: NPCVisual has no navigation_failed signal") + active_npc_visuals[npc.id] = visual debug_log("Spawned visual for %s at %s" % [npc.npc_name, npc.position]) @@ -83,18 +88,23 @@ func update_npc_targets() -> void: continue var visual = active_npc_visuals[npc.id] + var task = npc.current_task + if task in ["", "idle", "dead"]: + continue + var resource_pos = _try_get_resource_target(npc, npc.current_task) if resource_pos != null: visual.set_target_position(resource_pos) continue - var task = npc.current_task if task == "wander": var offset = Vector3(randf_range(-6.0, 6.0), 0.0, randf_range(-6.0, 6.0)) visual.set_target_position(visual.global_position + offset) continue if task in ["gather_food", "gather_wood"]: + if simulation_manager.has_method("notify_npc_target_unavailable"): + simulation_manager.notify_npc_target_unavailable(npc.id) continue var target = get_target_for_task(task) @@ -137,7 +147,7 @@ func _try_get_resource_target(npc: SimNPC, task: String) -> Variant: var node := ResourceNode.find_available(action_id, npc.id, visual.global_position) if node == null: - debug_log("No available resource node for %s, using zone fallback" % task) + debug_log("No available resource node for %s" % task) return null if not node.reserve(npc.id): @@ -145,7 +155,6 @@ func _try_get_resource_target(npc: SimNPC, task: String) -> Variant: return null npc.target_id = node.node_id - npc.retry_count = 0 debug_log("%s reserved %s for %s" % [npc.npc_name, node.node_id, task]) return node.interaction_point.global_position @@ -155,6 +164,9 @@ func _on_npc_task_changed(npc: SimNPC, old_task: String, new_task: String) -> vo if not active_npc_visuals.has(npc.id): return + + if new_task in ["", "idle", "dead"]: + return var visual = active_npc_visuals[npc.id] var resource_pos = _try_get_resource_target(npc, new_task) @@ -168,6 +180,10 @@ func _on_npc_task_changed(npc: SimNPC, old_task: String, new_task: String) -> vo return if new_task in ["gather_food", "gather_wood"]: + if simulation_manager.has_method("notify_npc_target_unavailable"): + simulation_manager.notify_npc_target_unavailable(npc.id) + else: + push_error("WorldViewManager: SimulationManager cannot handle unavailable targets") return var target := get_target_for_task(new_task) @@ -186,8 +202,19 @@ func _on_npc_visual_arrived(sim_id: int) -> void: simulation_manager.notify_npc_arrived(sim_id) else: push_error("WorldViewManager: SimulationManager has no notify_npc_arrived method") - - + +func _on_npc_visual_navigation_failed(sim_id: int) -> void: + debug_log("NPC visual navigation failed. sim_id=%s" % sim_id) + + if simulation_manager == null: + push_error("WorldViewManager: simulation_manager missing during navigation failure") + return + + if simulation_manager.has_method("notify_npc_navigation_failed"): + simulation_manager.notify_npc_navigation_failed(sim_id) + else: + push_error("WorldViewManager: SimulationManager has no notify_npc_navigation_failed method") + func _on_npc_died(npc: SimNPC) -> void: if not active_npc_visuals.has(npc.id): return