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
This commit is contained in:
+46
-14
@@ -1,12 +1,14 @@
|
|||||||
extends CharacterBody3D
|
extends CharacterBody3D
|
||||||
|
|
||||||
signal arrived_at_target(sim_id: int)
|
signal arrived_at_target(sim_id: int)
|
||||||
|
signal navigation_failed(sim_id: int)
|
||||||
|
|
||||||
const DEBUG_LOGS := true
|
const DEBUG_LOGS := true
|
||||||
|
|
||||||
@export var move_speed := 3.0
|
@export var move_speed := 3.0
|
||||||
@export var rotation_speed := 8.0
|
@export var rotation_speed := 8.0
|
||||||
@export var waypoint_reached_distance := 0.35
|
@export var waypoint_reached_distance := 0.35
|
||||||
|
@export var stuck_timeout := 1.5
|
||||||
|
|
||||||
@onready var nav_agent: NavigationAgent3D = $NavigationAgent3D
|
@onready var nav_agent: NavigationAgent3D = $NavigationAgent3D
|
||||||
|
|
||||||
@@ -18,8 +20,10 @@ var profession: String = ""
|
|||||||
var current_path: PackedVector3Array = []
|
var current_path: PackedVector3Array = []
|
||||||
var path_index := 0
|
var path_index := 0
|
||||||
var current_target := Vector3.INF
|
var current_target := Vector3.INF
|
||||||
var arrival_distance := 1.5
|
var arrival_distance := 0.6
|
||||||
var stuck_counter := 0
|
var stuck_time := 0.0
|
||||||
|
var path_request_id := 0
|
||||||
|
var path_pending := false
|
||||||
|
|
||||||
var is_dead_visual := false
|
var is_dead_visual := false
|
||||||
var dead_material := StandardMaterial3D.new()
|
var dead_material := StandardMaterial3D.new()
|
||||||
@@ -38,8 +42,12 @@ func setup_from_sim(npc: SimNPC) -> void:
|
|||||||
name = "NPC_%s_%s" % [sim_id, npc_name]
|
name = "NPC_%s_%s" % [sim_id, npc_name]
|
||||||
|
|
||||||
func set_target_position(pos: Vector3) -> void:
|
func set_target_position(pos: Vector3) -> void:
|
||||||
|
path_request_id += 1
|
||||||
|
var request_id := path_request_id
|
||||||
current_target = pos
|
current_target = pos
|
||||||
has_reported_arrival = false
|
has_reported_arrival = false
|
||||||
|
stuck_time = 0.0
|
||||||
|
path_pending = false
|
||||||
|
|
||||||
if global_position.distance_to(pos) <= arrival_distance:
|
if global_position.distance_to(pos) <= arrival_distance:
|
||||||
current_path = PackedVector3Array()
|
current_path = PackedVector3Array()
|
||||||
@@ -48,9 +56,13 @@ func set_target_position(pos: Vector3) -> void:
|
|||||||
return
|
return
|
||||||
|
|
||||||
nav_agent.target_position = pos
|
nav_agent.target_position = pos
|
||||||
|
path_pending = true
|
||||||
|
|
||||||
await get_tree().physics_frame
|
await get_tree().physics_frame
|
||||||
|
|
||||||
|
if request_id != path_request_id or is_dead_visual:
|
||||||
|
return
|
||||||
|
|
||||||
current_path = NavigationServer3D.map_get_path(
|
current_path = NavigationServer3D.map_get_path(
|
||||||
get_world_3d().navigation_map,
|
get_world_3d().navigation_map,
|
||||||
global_position,
|
global_position,
|
||||||
@@ -58,11 +70,12 @@ func set_target_position(pos: Vector3) -> void:
|
|||||||
true
|
true
|
||||||
)
|
)
|
||||||
|
|
||||||
|
path_pending = false
|
||||||
path_index = 0
|
path_index = 0
|
||||||
|
|
||||||
if current_path.is_empty():
|
if current_path.is_empty():
|
||||||
debug_log("Path is empty, stuck counter will handle fallback.")
|
debug_log("Path is empty")
|
||||||
nav_agent.target_position = global_position
|
_report_navigation_failure_once()
|
||||||
else:
|
else:
|
||||||
debug_log("New target: %s Path points: %s" % [pos, current_path.size()])
|
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()
|
move_and_slide()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if path_pending:
|
||||||
|
velocity = Vector3.ZERO
|
||||||
|
move_and_slide()
|
||||||
|
return
|
||||||
|
|
||||||
if global_position.distance_to(current_target) <= arrival_distance:
|
if global_position.distance_to(current_target) <= arrival_distance:
|
||||||
_report_arrival_once()
|
_report_arrival_once()
|
||||||
velocity = Vector3.ZERO
|
velocity = Vector3.ZERO
|
||||||
@@ -86,10 +104,7 @@ func _physics_process(delta: float) -> void:
|
|||||||
if current_path.is_empty() or path_index >= current_path.size():
|
if current_path.is_empty() or path_index >= current_path.size():
|
||||||
velocity = Vector3.ZERO
|
velocity = Vector3.ZERO
|
||||||
move_and_slide()
|
move_and_slide()
|
||||||
stuck_counter += 1
|
_report_navigation_failure_once()
|
||||||
if stuck_counter > 90:
|
|
||||||
debug_log("Stuck with no valid path, reporting arrival")
|
|
||||||
_report_arrival_once()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
var next_pos := current_path[path_index]
|
var next_pos := current_path[path_index]
|
||||||
@@ -109,14 +124,15 @@ func _physics_process(delta: float) -> void:
|
|||||||
|
|
||||||
move_and_slide()
|
move_and_slide()
|
||||||
|
|
||||||
if global_position.distance_squared_to(prev_pos) < 0.001:
|
var minimum_progress := move_speed * delta * 0.1
|
||||||
stuck_counter += 1
|
if global_position.distance_squared_to(prev_pos) < minimum_progress * minimum_progress:
|
||||||
if stuck_counter > 90:
|
stuck_time += delta
|
||||||
debug_log("Stuck while navigating, reporting arrival")
|
if stuck_time >= stuck_timeout:
|
||||||
_report_arrival_once()
|
debug_log("Movement remained blocked")
|
||||||
|
_report_navigation_failure_once()
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
stuck_counter = 0
|
stuck_time = 0.0
|
||||||
|
|
||||||
var target_angle := atan2(direction.x, direction.z)
|
var target_angle := atan2(direction.x, direction.z)
|
||||||
rotation.y = lerp_angle(rotation.y, target_angle, rotation_speed * delta)
|
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")
|
debug_log("Arrived at target")
|
||||||
arrived_at_target.emit(sim_id)
|
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:
|
func apply_dead_visual_state() -> void:
|
||||||
is_dead_visual = true
|
is_dead_visual = true
|
||||||
|
path_request_id += 1
|
||||||
velocity = Vector3.ZERO
|
velocity = Vector3.ZERO
|
||||||
current_path = PackedVector3Array()
|
current_path = PackedVector3Array()
|
||||||
path_index = 0
|
path_index = 0
|
||||||
current_target = Vector3.INF
|
current_target = Vector3.INF
|
||||||
|
path_pending = false
|
||||||
has_reported_arrival = true
|
has_reported_arrival = true
|
||||||
|
|
||||||
scale = Vector3(0.8, 0.25, 1.2)
|
scale = Vector3(0.8, 0.25, 1.2)
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ var task_duration := 0.0
|
|||||||
var task_progress := 0.0
|
var task_progress := 0.0
|
||||||
var task_complete := true
|
var task_complete := true
|
||||||
var target_id: StringName = &""
|
var target_id: StringName = &""
|
||||||
var retry_count := 0
|
|
||||||
var last_task := ""
|
var last_task := ""
|
||||||
|
|
||||||
func _init(
|
func _init(
|
||||||
@@ -247,7 +246,6 @@ func calculate_task_score(
|
|||||||
return score
|
return score
|
||||||
|
|
||||||
func set_task(task: String, duration: float) -> void:
|
func set_task(task: String, duration: float) -> void:
|
||||||
last_task = current_task
|
|
||||||
current_task = task
|
current_task = task
|
||||||
task_duration = duration
|
task_duration = duration
|
||||||
task_progress = 0.0
|
task_progress = 0.0
|
||||||
|
|||||||
@@ -87,21 +87,25 @@ func simulate_tick() -> void:
|
|||||||
|
|
||||||
if npc.target_id != &"":
|
if npc.target_id != &"":
|
||||||
var node := ResourceNode.get_by_id(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()
|
var extracted := node.extract()
|
||||||
if extracted > 0:
|
if extracted > 0:
|
||||||
village.apply_resource_delta(node.resource_id, extracted)
|
village.apply_resource_delta(node.resource_id, extracted)
|
||||||
if DEBUG_LOGS:
|
if DEBUG_LOGS:
|
||||||
print("[SimulationManager] ", npc.npc_name, " extracted ", extracted, " ", node.resource_id, " from ", node.node_id)
|
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)
|
release_npc_reservation(npc.id)
|
||||||
else:
|
elif completed_task not in ["gather_food", "gather_wood"]:
|
||||||
village.apply_npc_task(npc)
|
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
|
village_was_changed = true
|
||||||
npc.retry_count = 0
|
|
||||||
|
|
||||||
npc.task_complete = true
|
npc.task_complete = true
|
||||||
npc.task_state = SimNPC.TASK_STATE_IDLE
|
npc.task_state = SimNPC.TASK_STATE_IDLE
|
||||||
|
npc.last_task = completed_task
|
||||||
npc.current_task = "idle"
|
npc.current_task = "idle"
|
||||||
|
|
||||||
if needs_immediate_food_after_gather and village.food > 0:
|
if needs_immediate_food_after_gather and village.food > 0:
|
||||||
@@ -111,15 +115,6 @@ func simulate_tick() -> void:
|
|||||||
if DEBUG_LOGS:
|
if DEBUG_LOGS:
|
||||||
print("[SimulationManager] ", npc.npc_name, " gathered food while starving and is going to eat immediately.")
|
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:
|
if DEBUG_LOGS:
|
||||||
print(
|
print(
|
||||||
npc.npc_name,
|
npc.npc_name,
|
||||||
@@ -184,10 +179,7 @@ func notify_npc_arrived(npc_id: int) -> void:
|
|||||||
if node == null or not node.can_extract():
|
if node == null or not node.can_extract():
|
||||||
if DEBUG_LOGS:
|
if DEBUG_LOGS:
|
||||||
print("[SimulationManager] ", npc.npc_name, " arrived but target ", npc.target_id, " is unavailable, replanning")
|
print("[SimulationManager] ", npc.npc_name, " arrived but target ", npc.target_id, " is unavailable, replanning")
|
||||||
release_npc_reservation(npc.id)
|
notify_npc_navigation_failed(npc.id)
|
||||||
npc.task_state = SimNPC.TASK_STATE_IDLE
|
|
||||||
npc.current_task = ""
|
|
||||||
npc.retry_count += 1
|
|
||||||
return
|
return
|
||||||
|
|
||||||
npc.start_working()
|
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)
|
print("[SimulationManager] ", npc.npc_name, " arrived and started working on: ", npc.current_task)
|
||||||
return
|
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:
|
func eat_food(amount: float) -> void:
|
||||||
village.food -= amount
|
village.food -= amount
|
||||||
village_changed.emit(village)
|
village_changed.emit(village)
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ func spawn_npc_visual(npc: SimNPC) -> void:
|
|||||||
else:
|
else:
|
||||||
push_error("WorldViewManager: NPCVisual has no arrived_at_target signal")
|
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
|
active_npc_visuals[npc.id] = visual
|
||||||
debug_log("Spawned visual for %s at %s" % [npc.npc_name, npc.position])
|
debug_log("Spawned visual for %s at %s" % [npc.npc_name, npc.position])
|
||||||
|
|
||||||
@@ -83,18 +88,23 @@ func update_npc_targets() -> void:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
var visual = active_npc_visuals[npc.id]
|
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)
|
var resource_pos = _try_get_resource_target(npc, npc.current_task)
|
||||||
if resource_pos != null:
|
if resource_pos != null:
|
||||||
visual.set_target_position(resource_pos)
|
visual.set_target_position(resource_pos)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
var task = npc.current_task
|
|
||||||
if task == "wander":
|
if task == "wander":
|
||||||
var offset = Vector3(randf_range(-6.0, 6.0), 0.0, randf_range(-6.0, 6.0))
|
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)
|
visual.set_target_position(visual.global_position + offset)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if task in ["gather_food", "gather_wood"]:
|
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
|
continue
|
||||||
|
|
||||||
var target = get_target_for_task(task)
|
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)
|
var node := ResourceNode.find_available(action_id, npc.id, visual.global_position)
|
||||||
if node == null:
|
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
|
return null
|
||||||
|
|
||||||
if not node.reserve(npc.id):
|
if not node.reserve(npc.id):
|
||||||
@@ -145,7 +155,6 @@ func _try_get_resource_target(npc: SimNPC, task: String) -> Variant:
|
|||||||
return null
|
return null
|
||||||
|
|
||||||
npc.target_id = node.node_id
|
npc.target_id = node.node_id
|
||||||
npc.retry_count = 0
|
|
||||||
debug_log("%s reserved %s for %s" % [npc.npc_name, node.node_id, task])
|
debug_log("%s reserved %s for %s" % [npc.npc_name, node.node_id, task])
|
||||||
return node.interaction_point.global_position
|
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):
|
if not active_npc_visuals.has(npc.id):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if new_task in ["", "idle", "dead"]:
|
||||||
|
return
|
||||||
|
|
||||||
var visual = active_npc_visuals[npc.id]
|
var visual = active_npc_visuals[npc.id]
|
||||||
var resource_pos = _try_get_resource_target(npc, new_task)
|
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
|
return
|
||||||
|
|
||||||
if new_task in ["gather_food", "gather_wood"]:
|
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
|
return
|
||||||
|
|
||||||
var target := get_target_for_task(new_task)
|
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)
|
simulation_manager.notify_npc_arrived(sim_id)
|
||||||
else:
|
else:
|
||||||
push_error("WorldViewManager: SimulationManager has no notify_npc_arrived method")
|
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:
|
func _on_npc_died(npc: SimNPC) -> void:
|
||||||
if not active_npc_visuals.has(npc.id):
|
if not active_npc_visuals.has(npc.id):
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user