Let shopkeepers roam safely around their shops

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

View file

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

View file

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