Improve nearby water recovery and camera prompts

Recover players on generated worlds to the nearest safe grass or sand surface. Use the active gameplay camera when positioning shop and storage prompts.
This commit is contained in:
Alexander Sellite 2026-08-29 17:43:11 -04:00
parent ae802493a0
commit 4575830857
10 changed files with 396 additions and 25 deletions

View file

@ -1006,7 +1006,8 @@ func _initialize_application(dedicated: bool) -> void:
_game_ui,
_game_ui.get_screen_fade(),
_test_world.get_player_water_triggers(),
_test_world.get_safe_respawn_points()
_test_world.get_safe_respawn_points(),
Callable(_test_world, "get_water_recovery_position"),
)
_ui_pixelation.effective_pixel_size_changed.connect(
_on_effective_ui_pixel_size_changed
@ -2344,17 +2345,9 @@ func _on_remote_recovery_requested(
peer_id,
"Fishing attempt ended."
)
var target_position: Vector3 = _test_world.get_player_spawn_transform().origin
var nearest_distance: float = INF
for point: SafeRespawnPoint in _test_world.get_safe_respawn_points():
if point == null or not point.enabled:
continue
var distance: float = point.get_horizontal_distance_squared(
var target_position := _test_world.get_water_recovery_position(
entry_position
)
if distance < nearest_distance:
nearest_distance = distance
target_position = point.global_position
target_position.y += _water_recovery.respawn_height_offset
avatar.global_position = target_position
avatar.velocity = Vector3.ZERO
@ -2432,6 +2425,7 @@ func _apply_world(
spawn_transform,
_test_world.get_player_water_triggers(),
_test_world.get_safe_respawn_points(),
Callable(_test_world, "get_water_recovery_position"),
)
_shoreline_ambience.configure(
_player,

View file

@ -416,6 +416,12 @@ func _run() -> void:
)
assert(normal_camera.is_position_behind(chat_anchor))
assert(not free_camera.is_position_behind(chat_anchor))
var shop_prompt := game_ui.get_node("%ShopPrompt") as Control
var storage_prompt := game_ui.get_node("%StoragePrompt") as Control
game_ui.set_shop_prompt_visible(true, chat_anchor)
game_ui.set_storage_prompt_visible(true, chat_anchor)
assert(shop_prompt.visible)
assert(storage_prompt.visible)
chat_ui.show_local_speech("hello there friend")
await process_frame
assert(bool(player.get("_speech_mouth_active")))

View file

@ -265,6 +265,15 @@ func _run() -> void:
assert(bool(water_recovery.get("_prior_movement_enabled")))
assert(bool(water_recovery.get("_prior_camera_input_enabled")))
assert(not pause_menu.visible)
var test_world := main.get_node("TestWorld") as TestWorld
assert(test_world != null)
var recovery_entry := water_recovery.get("_entry_position") as Vector3
var resolved_recovery := water_recovery.call(
"_resolve_recovery_position"
) as Vector3
assert(resolved_recovery.is_equal_approx(
test_world.get_water_recovery_position(recovery_entry)
))
water_recovery.call("_finish_recovery")
assert(player.is_movement_enabled())

View file

@ -536,6 +536,11 @@ func _validate_generated_region(
-INF,
0.35,
)
_validate_water_recovery_positions(
region,
grass_prop_surface_triangles,
sand_prop_surface_triangles,
)
var anchors := region.get_node(
"GatherableAnchors/ReachableTreeTrunks"
) as GatherableAnchorSet3D
@ -1268,6 +1273,76 @@ func _triangle_surface_height_at(
return highest
func _validate_water_recovery_positions(
region: GeneratedWorldRegion,
grass_triangles: Array[PackedVector3Array],
sand_triangles: Array[PackedVector3Array],
) -> void:
var fresh_water_root := region.get_node(
"WaterBodies/FreshWaterBodies"
) as Node3D
assert(fresh_water_root != null and fresh_water_root.get_child_count() > 0)
var fresh_water := fresh_water_root.get_child(0) as WaterBodyAuthoring
assert(fresh_water != null)
var entry_position := fresh_water.global_position
var fallback_position := region.get_player_spawn_transform().origin
var recovery_position := region.get_water_recovery_position(
entry_position,
fallback_position,
)
assert(recovery_position.is_finite())
assert(
_horizontal_distance_squared(entry_position, recovery_position)
< _horizontal_distance_squared(entry_position, fallback_position),
"Fresh-water recovery returned the distant generated-world spawn.",
)
assert(
recovery_position.y
> GeneratedWorldRegion.WATER_HEIGHT
+ GeneratedWorldRegion.WATER_RECOVERY_MINIMUM_GROUND_CLEARANCE
)
var recovery_triangles: Array[PackedVector3Array] = []
recovery_triangles.append_array(grass_triangles)
recovery_triangles.append_array(sand_triangles)
var center_height := _triangle_surface_height_at(
recovery_position,
recovery_triangles,
)
assert(center_height > -INF)
assert(absf(center_height - recovery_position.y) <= 0.01)
for direction: Vector2 in GeneratedWorldRegion.PROP_SURFACE_SAMPLE_DIRECTIONS:
var footprint_position := recovery_position + Vector3(
direction.x * GeneratedWorldRegion.WATER_RECOVERY_FOOTPRINT_RADIUS,
0.0,
direction.y * GeneratedWorldRegion.WATER_RECOVERY_FOOTPRINT_RADIUS,
)
var footprint_height := _triangle_surface_height_at(
footprint_position,
recovery_triangles,
)
assert(footprint_height > -INF)
assert(
absf(footprint_height - recovery_position.y)
<= GeneratedWorldRegion.PROP_MAXIMUM_SUPPORT_HEIGHT_DIFFERENCE
)
assert(
region.get_water_recovery_position(
entry_position,
fallback_position,
).is_equal_approx(recovery_position)
)
assert(
region.get_water_recovery_position(
Vector3(INF, 0.0, 0.0),
fallback_position,
).is_equal_approx(fallback_position)
)
func _horizontal_distance_squared(a: Vector3, b: Vector3) -> float:
return Vector2(a.x - b.x, a.z - b.z).length_squared()
func _validate_tree_gatherable_anchors(
region: GeneratedWorldRegion,
decorations: Node3D,

View file

@ -100,6 +100,14 @@ 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())
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()
assert(
world.get_water_recovery_position(
authored_safe_point.global_position
).is_equal_approx(authored_safe_point.global_position)
)
assert(world.activate_world(WorldLayout.GENERATED, 24680))
assert(world.get_world_layout() == WorldLayout.GENERATED)
assert(world.get_generation_seed() == 24680)

View file

@ -1892,7 +1892,7 @@ func _position_storage_prompt(world_anchor: Vector3) -> void:
if _player == null:
_storage_prompt.hide()
return
var camera := _player.get_gameplay_camera()
var camera := _player.get_active_gameplay_camera()
if camera == null or camera.is_position_behind(world_anchor):
_storage_prompt.hide()
return
@ -2006,7 +2006,7 @@ func _position_shop_prompt(world_anchor: Vector3) -> void:
if _player == null:
_shop_prompt.hide()
return
var camera: Camera3D = _player.get_gameplay_camera()
var camera: Camera3D = _player.get_active_gameplay_camera()
if camera == null or camera.is_position_behind(world_anchor):
_shop_prompt.hide()
return

View file

@ -43,6 +43,12 @@ const PROP_MINIMUM_GROUND_CLEARANCE := 0.05
const PROP_MAXIMUM_SUPPORT_HEIGHT_DIFFERENCE := 0.35
const PROP_CHANCE_SCALE := 10000
const PROP_SELECTION_WEIGHT_SCALE := 1000
const WATER_RECOVERY_MINIMUM_GROUND_CLEARANCE := 0.05
const WATER_RECOVERY_MINIMUM_UP_DOT := 0.6
const WATER_RECOVERY_FOOTPRINT_RADIUS := 0.6
const WATER_RECOVERY_INSET_STEP := 0.25
const WATER_RECOVERY_MAXIMUM_INSET := 1.5
const WATER_RECOVERY_SEARCH_EXPANSIONS: Array[float] = [1.5, 4.0, 12.0]
const PROP_SURFACE_SAMPLE_DIRECTIONS: Array[Vector2] = [
Vector2(1.0, 0.0),
Vector2(0.70710678, 0.70710678),
@ -90,6 +96,7 @@ var _placed_prop_clearance_radii: Array[float] = []
var _placed_prop_groups: Array[StringName] = []
var _placed_group_coordinates: Dictionary[StringName, Array] = {}
var _biome_assignments: Dictionary[Vector2i, StringName] = {}
var _water_recovery_triangles_by_coordinate: Dictionary[Vector2i, Array] = {}
func _ready() -> void:
@ -190,6 +197,37 @@ func get_spawn_surface_triangles(
return triangles
func get_water_recovery_position(
entry_position: Vector3,
fallback_position: Vector3,
) -> Vector3:
if (
not entry_position.is_finite()
or _water_recovery_triangles_by_coordinate.is_empty()
):
return super(entry_position, fallback_position)
var nearest_surface_distance := _nearest_recovery_surface_distance(
entry_position
)
if not is_finite(nearest_surface_distance):
return super(entry_position, fallback_position)
for expansion: float in WATER_RECOVERY_SEARCH_EXPANSIONS:
var maximum_distance := nearest_surface_distance + expansion
var result: Variant = _nearest_safe_recovery_surface_position(
entry_position,
maximum_distance,
)
if result is Vector3:
return result as Vector3
var map_wide_result: Variant = _nearest_safe_recovery_surface_position(
entry_position,
get_playable_half_extents().length() * 2.0,
)
if map_wide_result is Vector3:
return map_wide_result as Vector3
return super(entry_position, fallback_position)
func _on_generation_completed(summary: Dictionary) -> void:
_diggable_beach.invalidate_surface_cache()
var records: Array[Dictionary] = _generator.placement_records()
@ -325,6 +363,10 @@ func _on_generation_completed(summary: Dictionary) -> void:
grass_surface_triangles_by_coordinate,
sand_surface_triangles_by_coordinate,
)
_cache_water_recovery_surfaces(
grass_surface_triangles_by_coordinate,
sand_surface_triangles_by_coordinate,
)
_place_spawn_amenities(spawn_position)
_configure_fresh_water(records)
_configure_diggable_area()
@ -1261,6 +1303,203 @@ func _terrain_surface_triangles_by_coordinate(
return triangles_by_coordinate
func _cache_water_recovery_surfaces(
grass_triangles: Dictionary[Vector2i, Array],
sand_triangles: Dictionary[Vector2i, Array],
) -> void:
_water_recovery_triangles_by_coordinate.clear()
_append_water_recovery_surfaces(grass_triangles)
_append_water_recovery_surfaces(sand_triangles)
func _append_water_recovery_surfaces(
source: Dictionary[Vector2i, Array],
) -> void:
for coordinate: Vector2i in source:
var eligible: Array = _water_recovery_triangles_by_coordinate.get(
coordinate,
[],
)
for triangle: PackedVector3Array in source[coordinate]:
if triangle.size() != 3:
continue
if minf(
triangle[0].y,
minf(triangle[1].y, triangle[2].y),
) <= WATER_HEIGHT + WATER_RECOVERY_MINIMUM_GROUND_CLEARANCE:
continue
var cross := (triangle[1] - triangle[0]).cross(
triangle[2] - triangle[0]
)
if (
cross.length_squared() <= 0.0000001
or absf(cross.normalized().dot(Vector3.UP))
< WATER_RECOVERY_MINIMUM_UP_DOT
):
continue
eligible.append(triangle)
if not eligible.is_empty():
_water_recovery_triangles_by_coordinate[coordinate] = eligible
func _nearest_recovery_surface_distance(entry_position: Vector3) -> float:
var entry_horizontal := Vector2(entry_position.x, entry_position.z)
var nearest_distance_squared := INF
for coordinate: Vector2i in _water_recovery_triangles_by_coordinate:
for triangle: PackedVector3Array in (
_water_recovery_triangles_by_coordinate[coordinate]
):
var closest := _closest_horizontal_point_on_triangle(
entry_horizontal,
triangle,
)
nearest_distance_squared = minf(
nearest_distance_squared,
entry_horizontal.distance_squared_to(closest),
)
return sqrt(nearest_distance_squared)
func _nearest_safe_recovery_surface_position(
entry_position: Vector3,
maximum_distance: float,
) -> Variant:
var entry_horizontal := Vector2(entry_position.x, entry_position.z)
var maximum_distance_squared := maximum_distance * maximum_distance
var best_distance_squared := INF
var best_position: Variant = null
for coordinate: Vector2i in _water_recovery_triangles_by_coordinate:
for triangle: PackedVector3Array in (
_water_recovery_triangles_by_coordinate[coordinate]
):
var closest := _closest_horizontal_point_on_triangle(
entry_horizontal,
triangle,
)
if (
entry_horizontal.distance_squared_to(closest)
> maximum_distance_squared
):
continue
var centroid := Vector2(
(triangle[0].x + triangle[1].x + triangle[2].x) / 3.0,
(triangle[0].z + triangle[1].z + triangle[2].z) / 3.0,
)
var inset_distance := minf(
closest.distance_to(centroid),
WATER_RECOVERY_MAXIMUM_INSET,
)
var inset_steps := ceili(
inset_distance / WATER_RECOVERY_INSET_STEP
)
for inset_index: int in inset_steps + 1:
var candidate_horizontal := closest.move_toward(
centroid,
minf(
float(inset_index) * WATER_RECOVERY_INSET_STEP,
inset_distance,
),
)
var distance_squared := entry_horizontal.distance_squared_to(
candidate_horizontal
)
if (
distance_squared > maximum_distance_squared
or distance_squared >= best_distance_squared
):
continue
var candidate := Vector3(
candidate_horizontal.x,
entry_position.y,
candidate_horizontal.y,
)
var safe_position: Variant = _safe_recovery_surface_position(
candidate
)
if safe_position is Vector3:
best_distance_squared = distance_squared
best_position = safe_position
return best_position
func _safe_recovery_surface_position(candidate: Vector3) -> Variant:
var nearby_triangles := _water_recovery_surface_triangles_near(candidate)
var surface_height := _surface_height_at(candidate, nearby_triangles)
if (
surface_height <= WATER_HEIGHT + WATER_RECOVERY_MINIMUM_GROUND_CLEARANCE
or not _surface_supports_prop_footprint(
candidate,
surface_height,
WATER_RECOVERY_FOOTPRINT_RADIUS,
nearby_triangles,
)
or not _has_prop_clearance(
candidate,
WATER_RECOVERY_FOOTPRINT_RADIUS,
)
):
return null
candidate.y = surface_height
return candidate
func _water_recovery_surface_triangles_near(
position: Vector3,
) -> Array[PackedVector3Array]:
var result: Array[PackedVector3Array] = []
if _generator.catalog == null or _generator.catalog.chunk_size <= 0.0:
return result
var local_position := _generator.to_local(position)
var half_grid := Vector2(
float(_generator.grid_size.x - 1) * 0.5,
float(_generator.grid_size.y - 1) * 0.5,
)
var coordinate := Vector2i(
roundi(local_position.x / _generator.catalog.chunk_size + half_grid.x),
roundi(local_position.z / _generator.catalog.chunk_size + half_grid.y),
)
for offset_x: int in range(-1, 2):
for offset_y: int in range(-1, 2):
var nearby_coordinate := coordinate + Vector2i(offset_x, offset_y)
result.append_array(
_water_recovery_triangles_by_coordinate.get(
nearby_coordinate,
[],
)
)
return result
func _closest_horizontal_point_on_triangle(
point: Vector2,
triangle: PackedVector3Array,
) -> Vector2:
var polygon := PackedVector2Array([
Vector2(triangle[0].x, triangle[0].z),
Vector2(triangle[1].x, triangle[1].z),
Vector2(triangle[2].x, triangle[2].z),
])
if Geometry2D.is_point_in_polygon(point, polygon):
return point
var closest := Geometry2D.get_closest_point_to_segment(
point,
polygon[0],
polygon[1],
)
var nearest_distance_squared := point.distance_squared_to(closest)
for edge_index: int in range(1, 3):
var edge_closest := Geometry2D.get_closest_point_to_segment(
point,
polygon[edge_index],
polygon[(edge_index + 1) % 3],
)
var distance_squared := point.distance_squared_to(edge_closest)
if distance_squared < nearest_distance_squared:
nearest_distance_squared = distance_squared
closest = edge_closest
return closest
func _terrain_coordinate_for(mesh_instance: MeshInstance3D) -> Vector2i:
var current: Node = mesh_instance
while current != null and current != _generator:

View file

@ -72,6 +72,24 @@ func get_safe_respawn_points() -> Array[SafeRespawnPoint]:
return points
func get_water_recovery_position(
entry_position: Vector3,
fallback_position: Vector3,
) -> Vector3:
if not entry_position.is_finite():
return fallback_position
var target_position := fallback_position
var nearest_distance := INF
for point: SafeRespawnPoint in get_safe_respawn_points():
if point == null or not is_instance_valid(point) or not point.enabled:
continue
var distance := point.get_horizontal_distance_squared(entry_position)
if distance < nearest_distance:
nearest_distance = distance
target_position = point.global_position
return target_position
func get_diggable_areas() -> Array[DiggableArea3D]:
var areas: Array[DiggableArea3D] = []
var root: Node = get_node_or_null(diggable_area_root)

View file

@ -49,6 +49,16 @@ func get_safe_respawn_points() -> Array[SafeRespawnPoint]:
)
func get_water_recovery_position(entry_position: Vector3) -> Vector3:
if _active_region == null:
return global_position
var fallback_position := get_player_spawn_transform().origin
return _active_region.get_water_recovery_position(
entry_position,
fallback_position,
)
func get_fishing_shop() -> FishingShopInteractionType:
return _active_region.get_fishing_shop()

View file

@ -25,6 +25,7 @@ var _game_ui: GameUI
var _screen_fade: ScreenFade
var _water_triggers: Array[PlayerWaterTrigger] = []
var _safe_points: Array[SafeRespawnPoint] = []
var _recovery_position_resolver := Callable()
var _initial_spawn_transform: Transform3D
var _entry_position: Vector3
var _bob_base_position: Vector3
@ -43,6 +44,7 @@ func setup(
screen_fade: ScreenFade,
water_triggers: Array[PlayerWaterTrigger],
safe_points: Array[SafeRespawnPoint],
recovery_position_resolver: Callable = Callable(),
) -> void:
_player = player
_fishing_spot = fishing_spot
@ -50,6 +52,7 @@ func setup(
_screen_fade = screen_fade
_water_triggers = water_triggers
_safe_points = safe_points
_recovery_position_resolver = recovery_position_resolver
_initial_spawn_transform = player.global_transform
for water_trigger: PlayerWaterTrigger in _water_triggers:
if (
@ -73,6 +76,7 @@ func update_world_context(
initial_spawn_transform: Transform3D,
water_triggers: Array[PlayerWaterTrigger],
safe_points: Array[SafeRespawnPoint],
recovery_position_resolver: Callable = Callable(),
) -> void:
for water_trigger: PlayerWaterTrigger in _water_triggers:
if (
@ -88,6 +92,7 @@ func update_world_context(
_initial_spawn_transform = initial_spawn_transform
_water_triggers = water_triggers
_safe_points = safe_points
_recovery_position_resolver = recovery_position_resolver
for water_trigger: PlayerWaterTrigger in _water_triggers:
if (
water_trigger != null
@ -183,17 +188,7 @@ func _on_fade_transition_completed(
func _respawn_player() -> void:
var target_position: Vector3 = _initial_spawn_transform.origin
var nearest_distance: float = INF
for point: SafeRespawnPoint in _safe_points:
if point == null or not is_instance_valid(point) or not point.enabled:
continue
var distance: float = point.get_horizontal_distance_squared(
_entry_position
)
if distance < nearest_distance:
nearest_distance = distance
target_position = point.global_position
var target_position := _resolve_recovery_position()
target_position.y += respawn_height_offset
var respawn_transform: Transform3D = _player.global_transform
respawn_transform.basis = _recovery_root_basis
@ -204,6 +199,23 @@ func _respawn_player() -> void:
local_respawn_completed.emit(_entry_position)
func _resolve_recovery_position() -> Vector3:
if _recovery_position_resolver.is_valid():
var resolved: Variant = _recovery_position_resolver.call(_entry_position)
if resolved is Vector3 and (resolved as Vector3).is_finite():
return resolved as Vector3
var target_position := _initial_spawn_transform.origin
var nearest_distance := INF
for point: SafeRespawnPoint in _safe_points:
if point == null or not is_instance_valid(point) or not point.enabled:
continue
var distance := point.get_horizontal_distance_squared(_entry_position)
if distance < nearest_distance:
nearest_distance = distance
target_position = point.global_position
return target_position
func _finish_recovery() -> void:
_player.restore_gameplay_orientation_after_recovery()
_player.set_water_recovery_active(false)