Let shopkeepers roam safely around their shops

This commit is contained in:
Alexander Sellite 2026-09-01 17:30:27 -04:00
parent 134ece4a10
commit 76b4f19e4d
9 changed files with 990 additions and 25 deletions

View file

@ -12,6 +12,9 @@ const GeneratedLakePool: FishPool = preload(
const GeneratedRiverPool: FishPool = preload(
"res://fish/pools/generated_river_pool.tres"
)
const FishingSurfaceResolverType = preload(
"res://fishing/fishing_surface_resolver.gd"
)
const Gatherables: GatherableCatalog = preload(
"res://gathering/catalog/gatherable_catalog.tres"
)
@ -415,13 +418,51 @@ func _validate_generated_region(
var shop := region.get_node("Interactables/FishingShopWorld") as Node3D
var storage := region.get_node("Interactables/PlayerStorageBox") as Node3D
var decor_shop := region.get_node(
"Interactables/DecorShopWorld"
) as Node3D
var rv_upgrade_shop := region.get_node(
"Interactables/RVUpgradeShopWorld"
) as Node3D
assert(shop != null and shop.has_node("Shopkeeper"))
assert(storage != null and storage.has_node("InteractionArea"))
assert(decor_shop != null and decor_shop.has_node("Shopkeeper"))
assert(rv_upgrade_shop != null and rv_upgrade_shop.has_node("Shopkeeper"))
for shop_root: Node3D in [shop, decor_shop, rv_upgrade_shop]:
var interaction := shop_root.get_node(
"InteractionArea"
) as FishingShopInteraction
var interaction_shape := shop_root.get_node(
"InteractionArea/InteractionShape"
) as CollisionShape3D
var sphere := interaction_shape.shape as SphereShape3D
assert(sphere != null and is_equal_approx(sphere.radius, 1.5))
assert(interaction != null)
assert(interaction.roaming_radius >= 2.75)
assert(interaction.minimum_route_distance >= 1.25)
assert(is_equal_approx(interaction.local_interaction_reach, 1.5))
assert(
is_equal_approx(
interaction.get_authority_interaction_radius(),
interaction.roaming_radius + interaction.local_interaction_reach,
)
)
var spawn_chunk_center := generator.chunk_position(center)
var shop_offset := shop.position - spawn_chunk_center
var storage_offset := storage.position - spawn_chunk_center
assert(absf(shop_offset.x) < 5.0 and absf(shop_offset.z) < 5.0)
assert(absf(storage_offset.x) < 5.0 and absf(storage_offset.z) < 5.0)
for pair: Array in [
[shop, decor_shop],
[shop, rv_upgrade_shop],
[decor_shop, rv_upgrade_shop],
]:
var first_shop := pair[0] as Node3D
var second_shop := pair[1] as Node3D
assert(
first_shop.position.distance_to(second_shop.position)
>= GeneratedWorldRegion.SHOPKEEPER_MINIMUM_SEPARATION - 0.001
)
assert(region.get_fishing_shop() != null)
assert(region.get_player_storage() != null)
assert(region.get_player_spawn_transform().origin.y > 0.0)
@ -468,6 +509,8 @@ func _validate_generated_region(
== 20 + river_placement_count
)
var river_body_count := 0
var surface_resolver := FishingSurfaceResolverType.new()
var space_state := region.get_world_3d().direct_space_state
for child: Node in fresh_root.get_children():
var fresh := child as WaterBodyAuthoring
assert(fresh != null)
@ -527,7 +570,44 @@ func _validate_generated_region(
else:
assert(&"pond" in fresh.location_tags)
assert(fresh.fish_pool == GeneratedPondPool)
# Generated fresh water shares the ocean's visible plane. Confirm that
# every habitat collider wins at runtime rather than merely checking the
# resource assigned to an otherwise-unused body.
var probe_position := fresh.global_position
if fresh.surface_polygon.size() >= 3:
var probe_indices := Geometry2D.triangulate_polygon(
fresh.surface_polygon,
)
assert(probe_indices.size() >= 3)
var probe_local := Vector2.ZERO
for probe_offset: int in 3:
probe_local += fresh.surface_polygon[
probe_indices[probe_offset]
]
probe_local /= 3.0
probe_position = fresh.to_global(Vector3(
probe_local.x,
0.0,
probe_local.y,
))
var selected_region: FishableWaterRegion = surface_resolver.call(
"_find_highest_water_region",
space_state,
probe_position,
probe_position.y + 100.0,
probe_position.y - 100.0,
)
assert(selected_region == fresh.get_node("FishingRegion"))
assert(selected_region.fish_pool == fresh.fish_pool)
assert(river_body_count == river_placement_count)
var resolved_ocean := surface_resolver.resolve_surface(
space_state,
Vector3(1000.0, GeneratedWorldRegion.WATER_HEIGHT, 1000.0),
GeneratedWorldRegion.WATER_HEIGHT + 2.0,
)
assert(resolved_ocean.is_fishable())
assert(resolved_ocean.water_region == ocean.get_node("FishingRegion"))
assert(resolved_ocean.water_region.water_type == WaterType.Type.SALT_WATER)
_validate_authored_chunk_surfaces(region, generator)
_validate_projected_terrain_materials(

View file

@ -100,6 +100,7 @@ func _validate_world_switching() -> void:
assert(world.get_fishing_shop() != null)
assert(world.get_player_storage() != null)
assert(not world.get_fishable_water_regions().is_empty())
_validate_starter_shopkeeper_roaming(world)
var starter_safe_points := world.get_safe_respawn_points()
assert(not starter_safe_points.is_empty())
var authored_safe_point: SafeRespawnPoint = starter_safe_points.back()
@ -117,6 +118,44 @@ func _validate_world_switching() -> void:
await process_frame
func _validate_starter_shopkeeper_roaming(world: TestWorld) -> void:
var starter_region := world.get_node(
"Regions/StarterIslandRegion"
) as StarterIslandRegion
assert(starter_region != null)
for interaction: FishingShopInteraction in [
starter_region.get_fishing_shop(),
starter_region.get_decor_shop(),
starter_region.get_rv_upgrade_shop(),
]:
assert(interaction != null)
# Presentation patrols are inactive on a dedicated/headless world until a
# local player is bound, so they cannot spend physics-query budget there.
assert(
not interaction.is_processing()
and not interaction.is_physics_processing()
)
var visual := interaction.get_node("../Shopkeeper") as Node3D
var visual_transform := visual.transform
var fixed_area_transform := interaction.global_transform
var route_found := false
for _attempt_cycle: int in 4:
interaction.call("_choose_roaming_destination")
if interaction.is_roaming_walking():
route_found = true
break
assert(route_found)
assert(not (interaction.get("_roaming_route") as Array).is_empty())
for _movement_slice: int in 24:
interaction.call("_advance_roaming_walk", 0.05)
if visual.position.distance_to(visual_transform.origin) > 0.01:
break
assert(visual.position.distance_to(visual_transform.origin) > 0.01)
assert(interaction.global_transform.is_equal_approx(fixed_area_transform))
visual.transform = visual_transform
interaction.call("_schedule_idle")
func _validate_world_boundary_clearance(world: TestWorld) -> void:
var active_region := world.get("_active_region") as WorldRegion
assert(active_region != null)

View file

@ -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

View file

@ -62,6 +62,7 @@ const WATER_RECOVERY_MAXIMUM_INSET := 1.5
const WATER_RECOVERY_SEARCH_EXPANSIONS: Array[float] = [1.5, 4.0, 12.0]
const HOME_GROUND_HEIGHT_TOLERANCE := 0.5
const HOME_ELEVATION_HEIGHT_TOLERANCE := 0.6
const SHOPKEEPER_MINIMUM_SEPARATION := 3.7
const PROP_SURFACE_SAMPLE_DIRECTIONS: Array[Vector2] = [
Vector2(1.0, 0.0),
Vector2(0.70710678, 0.70710678),
@ -706,13 +707,15 @@ func _assign_biomes(
func _place_spawn_amenities(center: Vector3) -> void:
_player_spawn.position = center + Vector3(0.0, 0.18, 2.2)
_safe_spawn.position = _player_spawn.position
_shop_root.position = center + Vector3(2.35, 0.0, -1.35)
# Keep each shopkeeper in a distinct approach lane. Their compact prompt
# areas retain only a narrow seam overlap for a continuous handoff.
_shop_root.position = center + Vector3(3.0, 0.0, -2.0)
_shop_root.rotation.y = PI
_storage_root.position = center + Vector3(-2.4, 0.0, 1.2)
_storage_root.position = center + Vector3(-0.5, 0.0, 3.2)
_storage_root.rotation.y = PI * 0.5
_decor_shop_root.position = center + Vector3(-2.4, 0.0, 1.2)
_decor_shop_root.position = center + Vector3(-3.0, 0.0, 1.7)
_decor_shop_root.rotation.y = PI * 0.5
_rv_upgrade_shop_root.position = center + Vector3(-2.35, 0.0, -1.35)
_rv_upgrade_shop_root.position = center + Vector3(-3.0, 0.0, -2.0)
_rv_upgrade_shop_root.rotation.y = PI

View file

@ -6,7 +6,7 @@
[sub_resource type="SphereShape3D" id="InteractionShape"]
resource_local_to_scene = true
radius = 2.1
radius = 1.5
[node name="DecorShopWorld" type="Node3D"]
@ -26,7 +26,7 @@ autoplay = "idle"
[node name="InteractionArea" type="Area3D" parent="."]
unique_name_in_owner = true
position = Vector3(0, 1, -0.3)
position = Vector3(0, 1, 0)
collision_layer = 0
collision_mask = 2
script = ExtResource("1_shop")

View file

@ -6,7 +6,7 @@
[sub_resource type="SphereShape3D" id="InteractionShape"]
resource_local_to_scene = true
radius = 2.1
radius = 1.5
[node name="FishingShopWorld" type="Node3D"]
@ -26,7 +26,7 @@ autoplay = "idle"
[node name="InteractionArea" type="Area3D" parent="."]
unique_name_in_owner = true
position = Vector3(0, 1, -0.3)
position = Vector3(0, 1, 0)
collision_layer = 0
collision_mask = 2
script = ExtResource("1_shop")

View file

@ -6,7 +6,7 @@
[sub_resource type="SphereShape3D" id="InteractionShape"]
resource_local_to_scene = true
radius = 2.1
radius = 1.5
[node name="RVUpgradeShopWorld" type="Node3D"]
@ -26,7 +26,7 @@ autoplay = "idle"
[node name="InteractionArea" type="Area3D" parent="."]
unique_name_in_owner = true
position = Vector3(0, 1, -0.3)
position = Vector3(0, 1, 0)
collision_layer = 0
collision_mask = 2
script = ExtResource("1_shop")

View file

@ -292,7 +292,7 @@ transform = Transform3D(-0.9976046, 0, -0.06917416, 0, 1, 0, 0.06917416, 0, -0.9
position = Vector3(-3.25, 3.42, 11.25)
[node name="DecorShopWorld" parent="Interactables" instance=ExtResource("25_decor_shop")]
position = Vector3(-3.25, 3.42, 11.25)
position = Vector3(-3.15, 3.42, 11.25)
rotation = Vector3(0, 1.5707964, 0)
[node name="RVUpgradeShopWorld" parent="Interactables" instance=ExtResource("26_rv_upgrade")]

View file

@ -50,6 +50,8 @@ var _skeleton: Skeleton3D
var _look_target: Node3D
var _look_target_proxy: Node3D
var _head_look_requested: bool = false
var _animation_player: AnimationPlayer
var _locomotion_walking: bool = false
func _ready() -> void:
@ -62,7 +64,9 @@ func _ready() -> void:
appearance["nose"] = nose_id
appearance["mouth"] = mouth_id
PlayerVisualPresenterType.apply_appearance(self, appearance)
_animation_player = find_child("AnimationPlayer", true, false) as AnimationPlayer
_prepare_head_look()
_apply_locomotion_animation()
func _process(delta: float) -> void:
@ -93,6 +97,28 @@ func set_head_look_active(active: bool) -> void:
_head_look_requested = active
func set_locomotion_walking(walking: bool) -> void:
if _locomotion_walking == walking:
return
_locomotion_walking = walking
_apply_locomotion_animation()
func is_locomotion_walking() -> bool:
return _locomotion_walking
func _apply_locomotion_animation() -> void:
if _animation_player == null:
return
var requested: StringName = &"walking" if _locomotion_walking else &"idle"
if (
_animation_player.has_animation(requested)
and _animation_player.current_animation != requested
):
_animation_player.play(requested, 0.18)
func get_head_anchor_position() -> Vector3:
if _skeleton == null:
return global_position + Vector3.UP * 1.8