Let shopkeepers roam safely around their shops
This commit is contained in:
parent
134ece4a10
commit
76b4f19e4d
9 changed files with 990 additions and 25 deletions
|
|
@ -12,6 +12,9 @@ const GeneratedLakePool: FishPool = preload(
|
||||||
const GeneratedRiverPool: FishPool = preload(
|
const GeneratedRiverPool: FishPool = preload(
|
||||||
"res://fish/pools/generated_river_pool.tres"
|
"res://fish/pools/generated_river_pool.tres"
|
||||||
)
|
)
|
||||||
|
const FishingSurfaceResolverType = preload(
|
||||||
|
"res://fishing/fishing_surface_resolver.gd"
|
||||||
|
)
|
||||||
const Gatherables: GatherableCatalog = preload(
|
const Gatherables: GatherableCatalog = preload(
|
||||||
"res://gathering/catalog/gatherable_catalog.tres"
|
"res://gathering/catalog/gatherable_catalog.tres"
|
||||||
)
|
)
|
||||||
|
|
@ -415,13 +418,51 @@ func _validate_generated_region(
|
||||||
|
|
||||||
var shop := region.get_node("Interactables/FishingShopWorld") as Node3D
|
var shop := region.get_node("Interactables/FishingShopWorld") as Node3D
|
||||||
var storage := region.get_node("Interactables/PlayerStorageBox") 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(shop != null and shop.has_node("Shopkeeper"))
|
||||||
assert(storage != null and storage.has_node("InteractionArea"))
|
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 spawn_chunk_center := generator.chunk_position(center)
|
||||||
var shop_offset := shop.position - spawn_chunk_center
|
var shop_offset := shop.position - spawn_chunk_center
|
||||||
var storage_offset := storage.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(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)
|
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_fishing_shop() != null)
|
||||||
assert(region.get_player_storage() != null)
|
assert(region.get_player_storage() != null)
|
||||||
assert(region.get_player_spawn_transform().origin.y > 0.0)
|
assert(region.get_player_spawn_transform().origin.y > 0.0)
|
||||||
|
|
@ -468,6 +509,8 @@ func _validate_generated_region(
|
||||||
== 20 + river_placement_count
|
== 20 + river_placement_count
|
||||||
)
|
)
|
||||||
var river_body_count := 0
|
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():
|
for child: Node in fresh_root.get_children():
|
||||||
var fresh := child as WaterBodyAuthoring
|
var fresh := child as WaterBodyAuthoring
|
||||||
assert(fresh != null)
|
assert(fresh != null)
|
||||||
|
|
@ -527,7 +570,44 @@ func _validate_generated_region(
|
||||||
else:
|
else:
|
||||||
assert(&"pond" in fresh.location_tags)
|
assert(&"pond" in fresh.location_tags)
|
||||||
assert(fresh.fish_pool == GeneratedPondPool)
|
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)
|
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_authored_chunk_surfaces(region, generator)
|
||||||
_validate_projected_terrain_materials(
|
_validate_projected_terrain_materials(
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,7 @@ func _validate_world_switching() -> void:
|
||||||
assert(world.get_fishing_shop() != null)
|
assert(world.get_fishing_shop() != null)
|
||||||
assert(world.get_player_storage() != null)
|
assert(world.get_player_storage() != null)
|
||||||
assert(not world.get_fishable_water_regions().is_empty())
|
assert(not world.get_fishable_water_regions().is_empty())
|
||||||
|
_validate_starter_shopkeeper_roaming(world)
|
||||||
var starter_safe_points := world.get_safe_respawn_points()
|
var starter_safe_points := world.get_safe_respawn_points()
|
||||||
assert(not starter_safe_points.is_empty())
|
assert(not starter_safe_points.is_empty())
|
||||||
var authored_safe_point: SafeRespawnPoint = starter_safe_points.back()
|
var authored_safe_point: SafeRespawnPoint = starter_safe_points.back()
|
||||||
|
|
@ -117,6 +118,44 @@ func _validate_world_switching() -> void:
|
||||||
await process_frame
|
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:
|
func _validate_world_boundary_clearance(world: TestWorld) -> void:
|
||||||
var active_region := world.get("_active_region") as WorldRegion
|
var active_region := world.get("_active_region") as WorldRegion
|
||||||
assert(active_region != null)
|
assert(active_region != null)
|
||||||
|
|
|
||||||
|
|
@ -6,27 +6,82 @@ signal local_player_range_changed(in_range: bool)
|
||||||
const DIALOGUE_TEXT: String = (
|
const DIALOGUE_TEXT: String = (
|
||||||
"Listen, you may suck at fishing but I've got some things that might help."
|
"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_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: Player
|
||||||
var _local_player_in_range: bool = false
|
var _local_player_in_range: bool = false
|
||||||
var _look_character: WorldCharacterDisplay
|
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:
|
func _ready() -> void:
|
||||||
_look_character = get_node_or_null(
|
_look_character = get_node_or_null(
|
||||||
look_character_path
|
look_character_path
|
||||||
) as WorldCharacterDisplay
|
) 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_entered.connect(_on_body_entered)
|
||||||
body_exited.connect(_on_body_exited)
|
body_exited.connect(_on_body_exited)
|
||||||
|
set_process(false)
|
||||||
|
set_physics_process(false)
|
||||||
|
|
||||||
|
|
||||||
func setup_local_player(player: Player) -> void:
|
func setup_local_player(player: Player) -> void:
|
||||||
_local_player = player
|
_local_player = player
|
||||||
if _look_character != null:
|
if _look_character != null:
|
||||||
_look_character.set_head_look_target(player)
|
_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:
|
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:
|
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 (
|
return (
|
||||||
avatar != null
|
offset.length()
|
||||||
and is_instance_valid(avatar)
|
<= get_authority_interaction_radius()
|
||||||
and avatar in get_overlapping_bodies()
|
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:
|
func get_dialogue_text(_home_tier: int = -1) -> String:
|
||||||
return DIALOGUE_TEXT
|
return DIALOGUE_TEXT
|
||||||
|
|
||||||
|
|
||||||
func get_prompt_anchor_position() -> Vector3:
|
func get_prompt_anchor_position() -> Vector3:
|
||||||
if _look_character != null:
|
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
|
return global_position + Vector3.UP * 1.25
|
||||||
|
|
||||||
|
|
||||||
func _refresh_overlaps() -> void:
|
func set_engaged(engaged: bool) -> void:
|
||||||
var is_overlapping: bool = (
|
if _engaged == engaged:
|
||||||
_local_player != null
|
return
|
||||||
and is_instance_valid(_local_player)
|
_engaged = engaged
|
||||||
and _local_player in get_overlapping_bodies()
|
_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:
|
func _on_body_entered(body: Node3D) -> void:
|
||||||
if body == _local_player:
|
if body == _local_player:
|
||||||
_set_local_player_in_range(true)
|
_refresh_local_player_proximity()
|
||||||
|
|
||||||
|
|
||||||
func _on_body_exited(body: Node3D) -> void:
|
func _on_body_exited(body: Node3D) -> void:
|
||||||
if body == _local_player:
|
if body == _local_player:
|
||||||
_set_local_player_in_range(false)
|
_refresh_local_player_proximity()
|
||||||
|
|
||||||
|
|
||||||
func _set_local_player_in_range(in_range: bool) -> void:
|
func _set_local_player_in_range(in_range: bool) -> void:
|
||||||
if _look_character != null:
|
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:
|
if _local_player_in_range == in_range:
|
||||||
return
|
return
|
||||||
_local_player_in_range = in_range
|
_local_player_in_range = in_range
|
||||||
|
|
|
||||||
|
|
@ -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 WATER_RECOVERY_SEARCH_EXPANSIONS: Array[float] = [1.5, 4.0, 12.0]
|
||||||
const HOME_GROUND_HEIGHT_TOLERANCE := 0.5
|
const HOME_GROUND_HEIGHT_TOLERANCE := 0.5
|
||||||
const HOME_ELEVATION_HEIGHT_TOLERANCE := 0.6
|
const HOME_ELEVATION_HEIGHT_TOLERANCE := 0.6
|
||||||
|
const SHOPKEEPER_MINIMUM_SEPARATION := 3.7
|
||||||
const PROP_SURFACE_SAMPLE_DIRECTIONS: Array[Vector2] = [
|
const PROP_SURFACE_SAMPLE_DIRECTIONS: Array[Vector2] = [
|
||||||
Vector2(1.0, 0.0),
|
Vector2(1.0, 0.0),
|
||||||
Vector2(0.70710678, 0.70710678),
|
Vector2(0.70710678, 0.70710678),
|
||||||
|
|
@ -706,13 +707,15 @@ func _assign_biomes(
|
||||||
func _place_spawn_amenities(center: Vector3) -> void:
|
func _place_spawn_amenities(center: Vector3) -> void:
|
||||||
_player_spawn.position = center + Vector3(0.0, 0.18, 2.2)
|
_player_spawn.position = center + Vector3(0.0, 0.18, 2.2)
|
||||||
_safe_spawn.position = _player_spawn.position
|
_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
|
_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
|
_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
|
_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
|
_rv_upgrade_shop_root.rotation.y = PI
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
[sub_resource type="SphereShape3D" id="InteractionShape"]
|
[sub_resource type="SphereShape3D" id="InteractionShape"]
|
||||||
resource_local_to_scene = true
|
resource_local_to_scene = true
|
||||||
radius = 2.1
|
radius = 1.5
|
||||||
|
|
||||||
[node name="DecorShopWorld" type="Node3D"]
|
[node name="DecorShopWorld" type="Node3D"]
|
||||||
|
|
||||||
|
|
@ -26,7 +26,7 @@ autoplay = "idle"
|
||||||
|
|
||||||
[node name="InteractionArea" type="Area3D" parent="."]
|
[node name="InteractionArea" type="Area3D" parent="."]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
position = Vector3(0, 1, -0.3)
|
position = Vector3(0, 1, 0)
|
||||||
collision_layer = 0
|
collision_layer = 0
|
||||||
collision_mask = 2
|
collision_mask = 2
|
||||||
script = ExtResource("1_shop")
|
script = ExtResource("1_shop")
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
[sub_resource type="SphereShape3D" id="InteractionShape"]
|
[sub_resource type="SphereShape3D" id="InteractionShape"]
|
||||||
resource_local_to_scene = true
|
resource_local_to_scene = true
|
||||||
radius = 2.1
|
radius = 1.5
|
||||||
|
|
||||||
[node name="FishingShopWorld" type="Node3D"]
|
[node name="FishingShopWorld" type="Node3D"]
|
||||||
|
|
||||||
|
|
@ -26,7 +26,7 @@ autoplay = "idle"
|
||||||
|
|
||||||
[node name="InteractionArea" type="Area3D" parent="."]
|
[node name="InteractionArea" type="Area3D" parent="."]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
position = Vector3(0, 1, -0.3)
|
position = Vector3(0, 1, 0)
|
||||||
collision_layer = 0
|
collision_layer = 0
|
||||||
collision_mask = 2
|
collision_mask = 2
|
||||||
script = ExtResource("1_shop")
|
script = ExtResource("1_shop")
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
[sub_resource type="SphereShape3D" id="InteractionShape"]
|
[sub_resource type="SphereShape3D" id="InteractionShape"]
|
||||||
resource_local_to_scene = true
|
resource_local_to_scene = true
|
||||||
radius = 2.1
|
radius = 1.5
|
||||||
|
|
||||||
[node name="RVUpgradeShopWorld" type="Node3D"]
|
[node name="RVUpgradeShopWorld" type="Node3D"]
|
||||||
|
|
||||||
|
|
@ -26,7 +26,7 @@ autoplay = "idle"
|
||||||
|
|
||||||
[node name="InteractionArea" type="Area3D" parent="."]
|
[node name="InteractionArea" type="Area3D" parent="."]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
position = Vector3(0, 1, -0.3)
|
position = Vector3(0, 1, 0)
|
||||||
collision_layer = 0
|
collision_layer = 0
|
||||||
collision_mask = 2
|
collision_mask = 2
|
||||||
script = ExtResource("1_shop")
|
script = ExtResource("1_shop")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
position = Vector3(-3.25, 3.42, 11.25)
|
||||||
|
|
||||||
[node name="DecorShopWorld" parent="Interactables" instance=ExtResource("25_decor_shop")]
|
[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)
|
rotation = Vector3(0, 1.5707964, 0)
|
||||||
|
|
||||||
[node name="RVUpgradeShopWorld" parent="Interactables" instance=ExtResource("26_rv_upgrade")]
|
[node name="RVUpgradeShopWorld" parent="Interactables" instance=ExtResource("26_rv_upgrade")]
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,8 @@ var _skeleton: Skeleton3D
|
||||||
var _look_target: Node3D
|
var _look_target: Node3D
|
||||||
var _look_target_proxy: Node3D
|
var _look_target_proxy: Node3D
|
||||||
var _head_look_requested: bool = false
|
var _head_look_requested: bool = false
|
||||||
|
var _animation_player: AnimationPlayer
|
||||||
|
var _locomotion_walking: bool = false
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
|
@ -62,7 +64,9 @@ func _ready() -> void:
|
||||||
appearance["nose"] = nose_id
|
appearance["nose"] = nose_id
|
||||||
appearance["mouth"] = mouth_id
|
appearance["mouth"] = mouth_id
|
||||||
PlayerVisualPresenterType.apply_appearance(self, appearance)
|
PlayerVisualPresenterType.apply_appearance(self, appearance)
|
||||||
|
_animation_player = find_child("AnimationPlayer", true, false) as AnimationPlayer
|
||||||
_prepare_head_look()
|
_prepare_head_look()
|
||||||
|
_apply_locomotion_animation()
|
||||||
|
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
|
|
@ -93,6 +97,28 @@ func set_head_look_active(active: bool) -> void:
|
||||||
_head_look_requested = active
|
_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:
|
func get_head_anchor_position() -> Vector3:
|
||||||
if _skeleton == null:
|
if _skeleton == null:
|
||||||
return global_position + Vector3.UP * 1.8
|
return global_position + Vector3.UP * 1.8
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue