From 76b4f19e4d45988341d1247ebe2f6bc61945a6a7 Mon Sep 17 00:00:00 2001 From: Voyager Date: Tue, 1 Sep 2026 17:30:27 -0400 Subject: [PATCH] Let shopkeepers roam safely around their shops --- tests/generated_world_runtime_validation.gd | 80 ++ tests/world_layout_validation.gd | 39 + world/fishing_shop_interaction.gd | 845 +++++++++++++++++- world/generation/generated_world_region.gd | 11 +- world/interactables/decor_shop_world.tscn | 4 +- world/interactables/fishing_shop_world.tscn | 4 +- .../interactables/rv_upgrade_shop_world.tscn | 4 +- world/regions/starter_island_region.tscn | 2 +- world/world_character_display.gd | 26 + 9 files changed, 990 insertions(+), 25 deletions(-) diff --git a/tests/generated_world_runtime_validation.gd b/tests/generated_world_runtime_validation.gd index ac2f61a..9df5f1d 100644 --- a/tests/generated_world_runtime_validation.gd +++ b/tests/generated_world_runtime_validation.gd @@ -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( diff --git a/tests/world_layout_validation.gd b/tests/world_layout_validation.gd index 6c7f728..f21120c 100644 --- a/tests/world_layout_validation.gd +++ b/tests/world_layout_validation.gd @@ -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) diff --git a/world/fishing_shop_interaction.gd b/world/fishing_shop_interaction.gd index 9628ee6..3289bf8 100644 --- a/world/fishing_shop_interaction.gd +++ b/world/fishing_shop_interaction.gd @@ -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 diff --git a/world/generation/generated_world_region.gd b/world/generation/generated_world_region.gd index e567369..9c59692 100644 --- a/world/generation/generated_world_region.gd +++ b/world/generation/generated_world_region.gd @@ -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 diff --git a/world/interactables/decor_shop_world.tscn b/world/interactables/decor_shop_world.tscn index 7f94ec4..cd51771 100644 --- a/world/interactables/decor_shop_world.tscn +++ b/world/interactables/decor_shop_world.tscn @@ -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") diff --git a/world/interactables/fishing_shop_world.tscn b/world/interactables/fishing_shop_world.tscn index 040b558..4daee51 100644 --- a/world/interactables/fishing_shop_world.tscn +++ b/world/interactables/fishing_shop_world.tscn @@ -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") diff --git a/world/interactables/rv_upgrade_shop_world.tscn b/world/interactables/rv_upgrade_shop_world.tscn index 9fd0ed2..83f9126 100644 --- a/world/interactables/rv_upgrade_shop_world.tscn +++ b/world/interactables/rv_upgrade_shop_world.tscn @@ -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") diff --git a/world/regions/starter_island_region.tscn b/world/regions/starter_island_region.tscn index 7682491..6f7f10b 100644 --- a/world/regions/starter_island_region.tscn +++ b/world/regions/starter_island_region.tscn @@ -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")] diff --git a/world/world_character_display.gd b/world/world_character_display.gd index a145f3b..5a5a91c 100644 --- a/world/world_character_display.gd +++ b/world/world_character_display.gd @@ -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