class_name FishingShopInteraction extends Area3D signal local_player_range_changed(in_range: bool) const DIALOGUE_TEXT: String = ( "Listen, you may suck at fishing but I've got some things that might help." ) const TERRAIN_COLLISION_MASK: int = 1 const WATER_COLLISION_MASK: int = 4 | 8 const SHOPKEEPER_INTERACTION_GROUP: StringName = &"shopkeeper_interactions" const ROAMING_ORIGIN_META: StringName = &"shopkeeper_roaming_origin" @export_node_path("Node3D") var look_character_path: NodePath = ^"../Shopkeeper" @export_category("Shopkeeper Roaming") @export var roaming_enabled: bool = true @export_range(0.1, 5.0, 0.05, "suffix:m") var roaming_radius: float = 2.8 @export_range(0.1, 4.0, 0.05, "suffix:m") var minimum_route_distance: float = 1.5 @export_range(0.1, 5.0, 0.05, "suffix:m") var maximum_route_distance: float = 2.35 @export_range(0.1, 2.0, 0.05, "suffix:m/s") var roaming_speed: float = 0.55 @export_range(0.1, 10.0, 0.1, "suffix:s") var minimum_idle_seconds: float = 2.0 @export_range(0.1, 15.0, 0.1, "suffix:s") var maximum_idle_seconds: float = 5.0 @export_range(90.0, 1080.0, 15.0, "degrees/s") var turn_speed_degrees: float = 600.0 @export_category("Shopkeeper Route Safety") @export_range(0.1, 1.0, 0.05, "suffix:m") var route_sample_spacing: float = 0.45 @export_range(0.0, 1.0, 0.01) var minimum_ground_up_dot: float = 0.78 @export_range(0.05, 1.0, 0.05, "suffix:m") var maximum_step_height: float = 0.3 @export_range(0.1, 2.0, 0.05, "suffix:m") var maximum_route_height_change: float = 0.55 @export_range(0.1, 2.0, 0.05, "suffix:m") var other_shopkeeper_clearance: float = 1.2 @export_range(0.1, 2.0, 0.05, "suffix:m") var local_player_clearance: float = 0.85 @export_category("Shopkeeper Interaction") @export_range(0.5, 3.0, 0.05, "suffix:m") var local_interaction_reach: float = 1.5 @export_range(0.5, 5.0, 0.05, "suffix:m") var interaction_height_tolerance: float = 1.25 var _local_player: Player var _local_player_in_range: bool = false var _look_character: WorldCharacterDisplay var _shopkeeper_root: Node3D var _roaming_origin: Vector3 var _roaming_destination: Vector3 var _roaming_initialized: bool = false var _roaming_walking: bool = false var _roaming_route: Array[Vector3] = [] var _roaming_route_index: int = 0 var _idle_seconds_remaining: float = 0.0 var _engaged: bool = false var _roaming_rng := RandomNumberGenerator.new() var _roaming_heading := Vector2.ZERO var _yielding_to_priority_key: String = "" var _separation_retry_seconds: float = 0.0 func _ready() -> void: _look_character = get_node_or_null( look_character_path ) as WorldCharacterDisplay # Only the presentation roams. The authoritative interaction Area3D stays at # its authored location so host/client purchase validation never depends on # unsynchronised ambient motion. _shopkeeper_root = _look_character _roaming_rng.seed = hash(get_path()) var initial_heading_angle := _roaming_rng.randf_range(0.0, TAU) _roaming_heading = Vector2( cos(initial_heading_angle), sin(initial_heading_angle), ) _initialize_roaming_origin() add_to_group(SHOPKEEPER_INTERACTION_GROUP) body_entered.connect(_on_body_entered) body_exited.connect(_on_body_exited) set_process(false) set_physics_process(false) func setup_local_player(player: Player) -> void: _local_player = player if _look_character != null: _look_character.set_head_look_target(player) _initialize_roaming_origin() _schedule_idle() # Local eligibility follows the moving presentation even when roaming is # disabled at runtime, so the authored Area3D is never treated as its proxy. set_physics_process(true) _refresh_local_player_proximity() func is_local_player_in_range() -> bool: return _local_player_in_range func is_avatar_in_range(avatar: Player) -> bool: if avatar == null or not is_instance_valid(avatar): return false var offset := avatar.global_position - _get_roaming_origin_global() var vertical_distance := absf(offset.y) offset.y = 0.0 # Ambient presentation motion is intentionally local-only. The host cannot # trust an unsynchronised visual position, so purchase authorization uses a # conservative envelope around the fixed authored origin. The Area3D itself # remains fixed and is never enlarged or moved by the patrol. return ( offset.length() <= get_authority_interaction_radius() and vertical_distance <= interaction_height_tolerance ) func get_authority_interaction_radius() -> float: return roaming_radius + local_interaction_reach func get_dialogue_text(_home_tier: int = -1) -> String: return DIALOGUE_TEXT func get_prompt_anchor_position() -> Vector3: if _look_character != null: # Keep the world prompt tied to the stable character root. Bone-driven # anchors bob during walking and make a compact interaction glyph jitter. return _look_character.global_position + Vector3.UP * 2.05 return global_position + Vector3.UP * 1.25 func set_engaged(engaged: bool) -> void: if _engaged == engaged: return _engaged = engaged _roaming_walking = false _roaming_route.clear() _roaming_route_index = 0 _yielding_to_priority_key = "" _separation_retry_seconds = 0.0 if _look_character != null: _look_character.set_locomotion_walking(false) _look_character.set_head_look_active( engaged or _local_player_in_range ) if not engaged: _schedule_idle() func is_engaged() -> bool: return _engaged func get_shopkeeper_position() -> Vector3: if _look_character != null and is_instance_valid(_look_character): return _look_character.global_position return global_position func _physics_process(delta: float) -> void: _process(delta) # Kept as a separate update entry point so focused runtime validations can # advance deterministic slices without waiting on the SceneTree clock. func _process(delta: float) -> void: _refresh_local_player_proximity() if _shopkeeper_root == null or not is_instance_valid(_shopkeeper_root): return if _engaged: _face_local_player(delta) return if not roaming_enabled: _set_roaming_walking(false) return if not _roaming_initialized: _initialize_roaming_origin() if _separation_retry_seconds > 0.0: _separation_retry_seconds = maxf( _separation_retry_seconds - delta, 0.0, ) return if _separate_from_overlapping_shopkeeper(delta): return if _roaming_walking: _advance_roaming_walk(delta) return _idle_seconds_remaining -= delta if _idle_seconds_remaining <= 0.0: _choose_roaming_destination() func get_local_player_distance_squared() -> float: if _local_player == null or not is_instance_valid(_local_player): return INF var character_position: Vector3 = global_position if _look_character != null and is_instance_valid(_look_character): # Use the character root rather than the animated head/prompt anchor so # nearest-shop handoffs stay stable while the NPC idles or looks around. character_position = _look_character.global_position var offset: Vector3 = _local_player.global_position - character_position # Shop selection is about which character the player is standing beside. # A small terrain-height difference must not let a farther NPC win. offset.y = 0.0 return offset.length_squared() func _initialize_roaming_origin() -> void: if _shopkeeper_root == null or not is_instance_valid(_shopkeeper_root): return if _roaming_initialized: return # The origin is local to the shop root, so it is safe to capture at _ready() # and survives later generated-world root placement. # Never recapture after a patrol has started: setup can run again when active # world bindings refresh, and turning the current waypoint into a new origin # would let the safety envelope drift indefinitely. if _shopkeeper_root.has_meta(ROAMING_ORIGIN_META): _roaming_origin = _shopkeeper_root.get_meta( ROAMING_ORIGIN_META, _shopkeeper_root.position, ) as Vector3 else: _roaming_origin = _shopkeeper_root.position _shopkeeper_root.set_meta(ROAMING_ORIGIN_META, _roaming_origin) _roaming_destination = _roaming_origin _roaming_initialized = true func _get_roaming_origin_global() -> Vector3: if ( _shopkeeper_root != null and is_instance_valid(_shopkeeper_root) and _shopkeeper_root.get_parent_node_3d() != null ): var local_origin := ( _roaming_origin if _roaming_initialized else _shopkeeper_root.position ) return _shopkeeper_root.get_parent_node_3d().to_global(local_origin) # Area3D is authored one metre above the character's feet. return global_position - Vector3.UP func _schedule_idle() -> void: _set_roaming_walking(false) _roaming_route.clear() _roaming_route_index = 0 _yielding_to_priority_key = "" _idle_seconds_remaining = _roaming_rng.randf_range( minf(minimum_idle_seconds, maximum_idle_seconds), maxf(minimum_idle_seconds, maximum_idle_seconds), ) func _schedule_route_retry() -> void: _set_roaming_walking(false) _roaming_route.clear() _roaming_route_index = 0 _idle_seconds_remaining = _roaming_rng.randf_range(0.35, 0.8) func _choose_roaming_destination() -> void: if _shopkeeper_root == null or not is_instance_valid(_shopkeeper_root): return var current := _shopkeeper_root.position var current_2d := Vector2(current.x, current.z) var origin_2d := Vector2(_roaming_origin.x, _roaming_origin.z) var origin_offset := current_2d - origin_2d var safe_radius := maxf(roaming_radius, 0.1) var requested_minimum := minf( minimum_route_distance, safe_radius * 0.8, ) var requested_maximum := maxf( requested_minimum, minf(maximum_route_distance, safe_radius * 1.25), ) var base_heading := _roaming_heading.normalized() if base_heading.is_zero_approx(): var fallback_angle := _roaming_rng.randf_range(0.0, TAU) base_heading = Vector2(cos(fallback_angle), sin(fallback_angle)) # This is a correlated random walk: most legs continue in roughly the same # direction from the NPC's CURRENT position. Only near the safety boundary # does it gradually steer back inward. The authored origin is a fence, not a # behavioral target, so the result reads as exploring instead of orbiting. var boundary_ratio := clampf(origin_offset.length() / safe_radius, 0.0, 1.0) if boundary_ratio > 0.68 and not origin_offset.is_zero_approx(): var inward := -origin_offset.normalized() var inward_weight := remap(boundary_ratio, 0.68, 1.0, 0.15, 0.82) base_heading = base_heading.lerp(inward, inward_weight).normalized() # Route selection is intentionally capped: even a completely blocked patch # cannot turn an ambient idle transition into a large one-frame query spike. for attempt: int in 4: var turn_limit := deg_to_rad(38.0 + minf(float(attempt), 8.0) * 6.0) var candidate_heading := base_heading.rotated( _roaming_rng.randf_range(-turn_limit, turn_limit) ).normalized() var route_distance := _roaming_rng.randf_range( requested_minimum, requested_maximum, ) var candidate_2d := current_2d + candidate_heading * route_distance var candidate_from_origin := candidate_2d - origin_2d if candidate_from_origin.length() > safe_radius: continue var candidate := Vector3(candidate_2d.x, current.y, candidate_2d.y) var safe_destination := _resolve_safe_route_destination(candidate) if safe_destination.is_empty(): continue _roaming_destination = safe_destination["position"] as Vector3 _roaming_route.clear() for waypoint: Vector3 in safe_destination.get("waypoints", []): _roaming_route.append(waypoint) _roaming_route_index = 0 _yielding_to_priority_key = "" var accepted_offset := _roaming_destination - current _roaming_heading = Vector2( accepted_offset.x, accepted_offset.z, ).normalized() _set_roaming_walking(true) return # Staying put is preferable to walking through a tree, over a cliff, or into # water. Spread retries across frames so a boxed-in NPC cannot spend hundreds # of physics queries in one idle transition on a low-end device. _schedule_route_retry() func _resolve_safe_route_destination(candidate: Vector3) -> Dictionary: if ( _shopkeeper_root == null or not is_instance_valid(_shopkeeper_root) or not is_inside_tree() ): return {} var parent := _shopkeeper_root.get_parent_node_3d() if parent == null or get_world_3d() == null: return {} var current_world := _shopkeeper_root.global_position var candidate_world := parent.to_global(candidate) var horizontal_route := candidate_world - current_world horizontal_route.y = 0.0 var route_length := horizontal_route.length() if route_length < maxf(minimum_route_distance, 0.05): return {} if _route_conflicts_with_reserved_corridors(current_world, candidate_world): return {} var sample_count := maxi( 2, ceili(route_length / maxf(route_sample_spacing, 0.1)), ) var first_ground := _probe_walkable_ground(current_world, current_world.y) if first_ground.is_empty(): return {} var first_position := first_ground["position"] as Vector3 var previous_position := first_position var waypoints: Array[Vector3] = [] for sample_index: int in range(1, sample_count + 1): var progress := float(sample_index) / float(sample_count) var sample_horizontal := current_world.lerp(candidate_world, progress) sample_horizontal.y = previous_position.y var ground := _probe_walkable_ground( sample_horizontal, previous_position.y, ) if ground.is_empty(): return {} var ground_position := ground["position"] as Vector3 if ( absf(ground_position.y - previous_position.y) > maximum_step_height or absf(ground_position.y - first_position.y) > maximum_route_height_change or _segment_has_obstacle(previous_position, ground_position) or _is_too_close_to_other_shopkeeper(ground_position) ): return {} previous_position = ground_position waypoints.append(parent.to_local(ground_position)) return { "position": parent.to_local(previous_position), "waypoints": waypoints, } func _probe_walkable_ground( world_position: Vector3, reference_height: float, ) -> Dictionary: var space_state := get_world_3d().direct_space_state var ray_start := Vector3( world_position.x, reference_height + 1.75, world_position.z, ) var ray_end := Vector3( world_position.x, reference_height - 2.5, world_position.z, ) var query := PhysicsRayQueryParameters3D.create( ray_start, ray_end, TERRAIN_COLLISION_MASK, ) query.collide_with_areas = false query.collide_with_bodies = true var result := space_state.intersect_ray(query) if result.is_empty(): return {} var normal := result.get("normal", Vector3.ZERO) as Vector3 if normal.dot(Vector3.UP) < minimum_ground_up_dot: return {} var ground_position := result.get("position", world_position) as Vector3 if _is_water_position(ground_position + Vector3.UP * 0.15): return {} return { "position": ground_position, "normal": normal, } func _segment_has_obstacle(from_ground: Vector3, to_ground: Vector3) -> bool: var route := to_ground - from_ground var horizontal := Vector3(route.x, 0.0, route.z) if horizontal.length_squared() <= 0.000001: return false var side := Vector3.UP.cross(horizontal.normalized()) * 0.22 var space_state := get_world_3d().direct_space_state for side_offset: Vector3 in [-side, Vector3.ZERO, side]: var query := PhysicsRayQueryParameters3D.create( from_ground + side_offset + Vector3.UP * 0.55, to_ground + side_offset + Vector3.UP * 0.55, TERRAIN_COLLISION_MASK, ) query.collide_with_areas = false query.collide_with_bodies = true if not space_state.intersect_ray(query).is_empty(): return true return false func _is_water_position(world_position: Vector3) -> bool: var query := PhysicsPointQueryParameters3D.new() query.position = world_position query.collision_mask = WATER_COLLISION_MASK query.collide_with_areas = true query.collide_with_bodies = false return not get_world_3d().direct_space_state.intersect_point( query, 8, ).is_empty() func _is_too_close_to_other_shopkeeper(world_position: Vector3) -> bool: if other_shopkeeper_clearance <= 0.0: return false var minimum_distance_squared := other_shopkeeper_clearance * other_shopkeeper_clearance for node: Node in get_tree().get_nodes_in_group( SHOPKEEPER_INTERACTION_GROUP ): var other := node as FishingShopInteraction if other == null or other == self or not is_instance_valid(other): continue var offset := other.get_shopkeeper_position() - world_position offset.y = 0.0 if offset.length_squared() < minimum_distance_squared: return true return false func _route_conflicts_with_reserved_corridors( from_world: Vector3, to_world: Vector3, ) -> bool: var from_2d := Vector2(from_world.x, from_world.z) var to_2d := Vector2(to_world.x, to_world.z) var npc_clearance_squared := ( other_shopkeeper_clearance * other_shopkeeper_clearance ) for node: Node in get_tree().get_nodes_in_group( SHOPKEEPER_INTERACTION_GROUP ): var other := node as FishingShopInteraction if other == null or other == self or not is_instance_valid(other): continue var other_from_world := other.get_shopkeeper_position() var other_to_world := other.get_roaming_destination_global() var other_from := Vector2(other_from_world.x, other_from_world.z) var other_to := Vector2(other_to_world.x, other_to_world.z) if not other.is_roaming_walking(): other_to = other_from if ( _segment_distance_squared(from_2d, to_2d, other_from, other_to) < npc_clearance_squared ): return true if _local_player != null and is_instance_valid(_local_player): var player_position := Vector2( _local_player.global_position.x, _local_player.global_position.z, ) if ( _point_segment_distance_squared(player_position, from_2d, to_2d) < local_player_clearance * local_player_clearance ): return true return false func _segment_distance_squared( first_start: Vector2, first_end: Vector2, second_start: Vector2, second_end: Vector2, ) -> float: if Geometry2D.segment_intersects_segment( first_start, first_end, second_start, second_end, ) != null: return 0.0 return minf( minf( _point_segment_distance_squared( first_start, second_start, second_end, ), _point_segment_distance_squared( first_end, second_start, second_end, ), ), minf( _point_segment_distance_squared( second_start, first_start, first_end, ), _point_segment_distance_squared( second_end, first_start, first_end, ), ), ) func _point_segment_distance_squared( point: Vector2, segment_start: Vector2, segment_end: Vector2, ) -> float: var segment := segment_end - segment_start var segment_length_squared := segment.length_squared() if segment_length_squared <= 0.000001: return point.distance_squared_to(segment_start) var progress := clampf( (point - segment_start).dot(segment) / segment_length_squared, 0.0, 1.0, ) return point.distance_squared_to(segment_start + segment * progress) func get_roaming_destination_global() -> Vector3: if ( _shopkeeper_root != null and is_instance_valid(_shopkeeper_root) and _shopkeeper_root.get_parent_node_3d() != null ): return _shopkeeper_root.get_parent_node_3d().to_global( _roaming_destination ) return get_shopkeeper_position() func is_roaming_walking() -> bool: return _roaming_walking func _advance_roaming_walk(delta: float) -> void: if _separate_from_overlapping_shopkeeper(delta): return if _roaming_route_index >= _roaming_route.size(): _schedule_route_retry() return var route_target := _roaming_route[_roaming_route_index] var offset := route_target - _shopkeeper_root.position if offset.length_squared() <= 0.0025: _shopkeeper_root.position = route_target _roaming_route_index += 1 if _roaming_route_index >= _roaming_route.size(): _shopkeeper_root.position = _roaming_destination _schedule_idle() return var horizontal_offset := offset horizontal_offset.y = 0.0 if horizontal_offset.length_squared() <= 0.000001: _shopkeeper_root.position = _shopkeeper_root.position.move_toward( route_target, roaming_speed * delta, ) return if _should_yield_for_live_traffic(route_target, delta): return _turn_root_toward( _shopkeeper_root.get_parent_node_3d().to_global(route_target), delta, ) var local_forward := _shopkeeper_root.basis.z.normalized() var travel_direction := horizontal_offset.normalized() var facing_alignment := local_forward.dot(travel_direction) # Turn in place briefly rather than skating sideways while the body catches # up to a newly selected patrol leg. if facing_alignment < cos(deg_to_rad(10.0)): if _look_character != null: _look_character.set_locomotion_walking(false) return if _look_character != null: _look_character.set_locomotion_walking(true) _shopkeeper_root.position = _shopkeeper_root.position.move_toward( route_target, roaming_speed * delta, ) func _separate_from_overlapping_shopkeeper(delta: float) -> bool: var current_world := get_shopkeeper_position() var nearest: FishingShopInteraction var nearest_offset := Vector3.ZERO var nearest_distance_squared := INF var clearance_squared := ( other_shopkeeper_clearance * other_shopkeeper_clearance ) for node: Node in get_tree().get_nodes_in_group( SHOPKEEPER_INTERACTION_GROUP ): var other := node as FishingShopInteraction if other == null or other == self or not is_instance_valid(other): continue var offset := current_world - other.get_shopkeeper_position() offset.y = 0.0 var distance_squared := offset.length_squared() if ( distance_squared < clearance_squared and distance_squared < nearest_distance_squared ): nearest = other nearest_offset = offset nearest_distance_squared = distance_squared if nearest == null: return false var away := nearest_offset.normalized() if away.is_zero_approx(): # Exact overlaps use opposite deterministic axes, so both characters move # apart instead of choosing the same random escape direction. away = ( Vector3.RIGHT if _traffic_priority_key() < nearest._traffic_priority_key() else Vector3.LEFT ) var turn_sign := ( 1.0 if _traffic_priority_key() < nearest._traffic_priority_key() else -1.0 ) var inward := _get_roaming_origin_global() - current_world inward.y = 0.0 var escape_directions: Array[Vector3] = [ away, away.rotated(Vector3.UP, turn_sign * PI * 0.25), away.rotated(Vector3.UP, turn_sign * PI * 0.5), ] if not inward.is_zero_approx(): escape_directions.append(inward.normalized()) escape_directions.append( inward.normalized().rotated( Vector3.UP, turn_sign * PI * 0.25, ) ) for escape_direction: Vector3 in escape_directions: if _try_live_separation_step(escape_direction, delta): _separation_retry_seconds = 0.0 return true # If terrain or props block every deterministic escape, avoid repeating the # same raycast set every physics frame. Another NPC can still move clear while # this one pauses, then normal route planning resumes after the short cooldown. _separation_retry_seconds = 0.25 return true func _try_live_separation_step(world_direction: Vector3, delta: float) -> bool: var parent := _shopkeeper_root.get_parent_node_3d() if parent == null: return false var direction := world_direction direction.y = 0.0 direction = direction.normalized() if direction.is_zero_approx(): return false var current_world := _shopkeeper_root.global_position var candidate_world := ( current_world + direction * roaming_speed * maxf(delta, 0.001) ) var origin_offset := candidate_world - _get_roaming_origin_global() origin_offset.y = 0.0 if origin_offset.length() > roaming_radius: return false var ground := _probe_walkable_ground(candidate_world, current_world.y) if ground.is_empty(): return false var ground_position := ground["position"] as Vector3 if ( absf(ground_position.y - current_world.y) > maximum_step_height or _segment_has_obstacle(current_world, ground_position) ): return false _roaming_route.clear() _roaming_route_index = 0 _roaming_destination = parent.to_local( ground_position + direction * other_shopkeeper_clearance ) _roaming_walking = true _yielding_to_priority_key = "" _turn_root_toward(ground_position + direction, delta) var local_direction := parent.global_basis.inverse() * direction local_direction.y = 0.0 local_direction = local_direction.normalized() if ( _shopkeeper_root.basis.z.normalized().dot(local_direction) < cos(deg_to_rad(12.0)) ): if _look_character != null: _look_character.set_locomotion_walking(false) return true if _look_character != null: _look_character.set_locomotion_walking(true) _shopkeeper_root.global_position = ground_position return true func _should_yield_for_live_traffic( local_target: Vector3, delta: float, ) -> bool: var current_world := get_shopkeeper_position() var target_world := _shopkeeper_root.get_parent_node_3d().to_global( local_target ) var proposed_world := current_world.move_toward( target_world, roaming_speed * maxf(delta, 0.001), ) var current_2d := Vector2(current_world.x, current_world.z) var proposed_2d := Vector2(proposed_world.x, proposed_world.z) var clearance_squared := ( other_shopkeeper_clearance * other_shopkeeper_clearance ) for node: Node in get_tree().get_nodes_in_group( SHOPKEEPER_INTERACTION_GROUP ): var other := node as FishingShopInteraction if other == null or other == self or not is_instance_valid(other): continue if other._yielding_to_priority_key == _traffic_priority_key(): continue var other_current_world := other.get_shopkeeper_position() var other_target_world := other.get_next_roaming_target_global() var other_current := Vector2( other_current_world.x, other_current_world.z, ) var other_target := Vector2( other_target_world.x, other_target_world.z, ) if not other.is_roaming_walking(): other_target = other_current if ( _segment_distance_squared( current_2d, proposed_2d, other_current, other_target, ) >= clearance_squared ): continue if ( other.is_roaming_walking() and _traffic_priority_key() < other._traffic_priority_key() ): continue _yielding_to_priority_key = other._traffic_priority_key() _schedule_route_retry() return true if _local_player != null and is_instance_valid(_local_player): var player_2d := Vector2( _local_player.global_position.x, _local_player.global_position.z, ) if ( _point_segment_distance_squared( player_2d, current_2d, proposed_2d, ) < local_player_clearance * local_player_clearance ): _yielding_to_priority_key = "" _schedule_route_retry() return true return false func get_next_roaming_target_global() -> Vector3: if ( _shopkeeper_root != null and is_instance_valid(_shopkeeper_root) and _shopkeeper_root.get_parent_node_3d() != null and _roaming_route_index < _roaming_route.size() ): return _shopkeeper_root.get_parent_node_3d().to_global( _roaming_route[_roaming_route_index] ) return get_roaming_destination_global() func _traffic_priority_key() -> String: return str(get_path()) func _set_roaming_walking(walking: bool) -> void: if _roaming_walking == walking: return _roaming_walking = walking if _look_character != null: # A new patrol leg starts with a short idle turn-in-place. Translation # switches to walking once the body is within ten degrees of its route. _look_character.set_locomotion_walking(false) func _face_local_player(delta: float) -> void: if _local_player == null or not is_instance_valid(_local_player): return var target := _local_player.global_position if _shopkeeper_root != null and is_instance_valid(_shopkeeper_root): target.y = _shopkeeper_root.global_position.y _turn_root_toward(target, delta) func _turn_root_toward(world_target: Vector3, delta: float) -> void: if _shopkeeper_root == null or not is_instance_valid(_shopkeeper_root): return var local_target := _shopkeeper_root.get_parent_node_3d().to_local( world_target ) var direction := local_target - _shopkeeper_root.position direction.y = 0.0 if direction.length_squared() <= 0.000001: return # The imported standalone character faces local +Z (unlike Player's # additional 180-degree rig wrapper). var target_yaw := atan2(direction.x, direction.z) _shopkeeper_root.rotation.y = rotate_toward( _shopkeeper_root.rotation.y, target_yaw, deg_to_rad(turn_speed_degrees) * delta, ) func _refresh_local_player_proximity() -> void: if _local_player == null or not is_instance_valid(_local_player): _set_local_player_in_range(false) return var offset := _local_player.global_position - get_shopkeeper_position() _set_local_player_in_range( offset.length() <= local_interaction_reach ) func _on_body_entered(body: Node3D) -> void: if body == _local_player: _refresh_local_player_proximity() func _on_body_exited(body: Node3D) -> void: if body == _local_player: _refresh_local_player_proximity() func _set_local_player_in_range(in_range: bool) -> void: if _look_character != null: _look_character.set_head_look_active(in_range or _engaged) if _local_player_in_range == in_range: return _local_player_in_range = in_range local_player_range_changed.emit(in_range)