|
|
|
|
@ -6,27 +6,82 @@ 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)
|
|
|
|
|
_refresh_overlaps()
|
|
|
|
|
_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:
|
|
|
|
|
@ -34,45 +89,807 @@ func is_local_player_in_range() -> bool:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 (
|
|
|
|
|
avatar != null
|
|
|
|
|
and is_instance_valid(avatar)
|
|
|
|
|
and avatar in get_overlapping_bodies()
|
|
|
|
|
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:
|
|
|
|
|
return _look_character.get_head_anchor_position()
|
|
|
|
|
# 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 _refresh_overlaps() -> void:
|
|
|
|
|
var is_overlapping: bool = (
|
|
|
|
|
_local_player != null
|
|
|
|
|
and is_instance_valid(_local_player)
|
|
|
|
|
and _local_player in get_overlapping_bodies()
|
|
|
|
|
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
|
|
|
|
|
)
|
|
|
|
|
_set_local_player_in_range(is_overlapping)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _on_body_entered(body: Node3D) -> void:
|
|
|
|
|
if body == _local_player:
|
|
|
|
|
_set_local_player_in_range(true)
|
|
|
|
|
_refresh_local_player_proximity()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _on_body_exited(body: Node3D) -> void:
|
|
|
|
|
if body == _local_player:
|
|
|
|
|
_set_local_player_in_range(false)
|
|
|
|
|
_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)
|
|
|
|
|
_look_character.set_head_look_active(in_range or _engaged)
|
|
|
|
|
if _local_player_in_range == in_range:
|
|
|
|
|
return
|
|
|
|
|
_local_player_in_range = in_range
|
|
|
|
|
|