From 9afa60f6a56df1139e44db3491e15396d92e3a86 Mon Sep 17 00:00:00 2001 From: Voyager Date: Sat, 15 Aug 2026 09:52:50 -0400 Subject: [PATCH] Refactor fishing surfaces around authored water --- docs/DEVELOPMENT.md | 7 +- fishing/fishing_presentation.gd | 16 --- fishing/fishing_spot.gd | 5 +- fishing/fishing_surface_resolver.gd | 7 +- fishing/fishing_surface_sample.gd | 10 +- fishing/remote_fishing_presentation.gd | 12 +- main/main.tscn | 25 +---- tests/fishing_surface_validation.gd | 94 +++++++++++----- world/materials/stylized_water.gdshader | 7 +- world/regions/starter_island_region.tscn | 54 +++------ world/water_body_authoring.gd | 133 ++++++++++++++++------- 11 files changed, 198 insertions(+), 172 deletions(-) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index a3853b4..0b7a65c 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -181,8 +181,11 @@ node names, pool filenames, coordinates, or water height. Select `WaterBodies/Pond` to move or resize the pond. Its transform is the authoritative surface position; surface size and fishing/recovery coverage are owned together. Select `WaterBodies/Ocean` to move the surrounding water as one -feature. Its explicit visual, fishing, and recovery lobes remain local to that -root. +feature. Its fishing coverage is derived from the visible water mesh and the +terrain collision determines the shoreline. New or revised land meshes do not +require hand-authored fishing exclusions. Recovery volumes remain explicit so +they can follow the playable world bounds rather than the horizon-sized visual +ocean. Placed-feature ownership: diff --git a/fishing/fishing_presentation.gd b/fishing/fishing_presentation.gd index 71fedac..947976c 100644 --- a/fishing/fishing_presentation.gd +++ b/fishing/fishing_presentation.gd @@ -1,10 +1,6 @@ class_name FishingPresentation extends Node3D -const WaterSurfaceMotionType = preload( - "res://world/water_surface_motion.gd" -) - signal cast_completed signal outcome_completed(outcome: StringName) signal presentation_interrupted @@ -58,7 +54,6 @@ var _rod_tip: Marker3D var _rod_neutral_rotation: Vector3 var _cast_arrival_position: Vector3 var _active_cast_arc_height: float = 0.0 -var _cast_tracks_water_surface: bool = false var _bobber_surface_position: Vector3 var _bobber_idle_elapsed: float = 0.0 var _bobber_base_scale: Vector3 = Vector3.ONE @@ -76,9 +71,6 @@ func _process(delta: float) -> void: if _mode == VisualMode.FISHING and _bobber.visible: _bobber_idle_elapsed += delta _apply_bobber_idle_motion() - var bobber_position := _bobber.global_position - bobber_position.y += WaterSurfaceMotionType.get_default_height_offset() - _bobber.global_position = bobber_position if _line_mode != LineMode.HIDDEN: _update_line(delta) @@ -173,8 +165,6 @@ func begin_cast( if resolved_arc_height >= 0.0 else cast_arc_height ) - _cast_tracks_water_surface = not cast_is_blocked - var cast_start: Vector3 = _rod_tip.global_position _bobber.global_position = cast_start _begin_rod_release() @@ -311,7 +301,6 @@ func cleanup() -> void: _bobber.rotation = Vector3.ZERO _cast_arrival_position = Vector3.ZERO _active_cast_arc_height = 0.0 - _cast_tracks_water_surface = false _bobber_surface_position = Vector3.ZERO _bobber_idle_elapsed = 0.0 set_line_mode(LineMode.HIDDEN) @@ -329,11 +318,6 @@ func _set_cast_sample( ) -> void: var sample: Vector3 = start.lerp(target, progress) sample.y += sin(progress * PI) * _active_cast_arc_height - if _cast_tracks_water_surface: - sample.y += ( - WaterSurfaceMotionType.get_default_height_offset() - * smoothstep(0.72, 1.0, progress) - ) _bobber.global_position = sample diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index e42883b..43770c3 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -93,7 +93,7 @@ enum FishingState { @export_range(1.0, 200.0, 1.0) var preview_ray_start_height: float = 100.0 @export_range(1.0, 400.0, 1.0) var preview_surface_ray_length: float = 240.0 @export_range(0.01, 1.0, 0.01) var preview_marker_vertical_offset: float = 0.06 -@export_range(0.0, 0.5, 0.01) var water_occlusion_tolerance: float = 0.04 +@export_range(0.0, 0.5, 0.01) var solid_probe_clearance: float = 0.04 @export_range(0.01, 0.5, 0.01) var solid_bobber_clearance: float = 0.13 @export_category("Withdrawal") @@ -1457,7 +1457,6 @@ func _configure_surface_resolver() -> void: _surface_resolver.query_radius = fishable_query_radius _surface_resolver.ray_start_height = preview_ray_start_height _surface_resolver.ray_length = preview_surface_ray_length - _surface_resolver.water_occlusion_tolerance = water_occlusion_tolerance func resolve_fishing_surface( @@ -1479,7 +1478,7 @@ func resolve_fishing_surface( get_world_3d().direct_space_state, target, resolved_reference_y, - water_occlusion_tolerance, + solid_probe_clearance, ) diff --git a/fishing/fishing_surface_resolver.gd b/fishing/fishing_surface_resolver.gd index 821d8cc..c439aac 100644 --- a/fishing/fishing_surface_resolver.gd +++ b/fishing/fishing_surface_resolver.gd @@ -13,7 +13,6 @@ var solid_surface_mask: int = 1 var query_radius: float = 0.08 var ray_start_height: float = 100.0 var ray_length: float = 240.0 -var water_occlusion_tolerance: float = 0.04 var arc_sample_spacing: float = 0.3 var _water_query_shape: CylinderShape3D = CylinderShape3D.new() @@ -52,9 +51,9 @@ func resolve_surface( var solid_is_above_water: bool = false if not solid_hit.is_empty(): var solid_position: Vector3 = solid_hit["position"] - solid_is_above_water = ( - solid_position.y >= water_height - water_occlusion_tolerance - ) + # The water body's authored height is the exact shoreline authority: + # submerged terrain is fishable and terrain at or above it is dry. + solid_is_above_water = solid_position.y >= water_height if not solid_is_above_water: sample.has_surface = true sample.position = Vector3( diff --git a/fishing/fishing_surface_sample.gd b/fishing/fishing_surface_sample.gd index 121658b..4d2dd2c 100644 --- a/fishing/fishing_surface_sample.gd +++ b/fishing/fishing_surface_sample.gd @@ -4,9 +4,6 @@ extends RefCounted const FishableWaterRegionType = preload( "res://world/fishable_water_region.gd" ) -const WaterSurfaceMotionType = preload( - "res://world/water_surface_motion.gd" -) var has_surface: bool = false var position: Vector3 = Vector3.ZERO @@ -22,12 +19,7 @@ func is_fishable() -> bool: func get_marker_position(vertical_offset: float) -> Vector3: if not has_surface: return position - var presentation_offset := vertical_offset - if is_water_surface: - presentation_offset += ( - WaterSurfaceMotionType.get_default_height_offset() - ) - return position + normal.normalized() * presentation_offset + return position + normal.normalized() * vertical_offset func get_bobber_position(solid_clearance: float) -> Vector3: diff --git a/fishing/remote_fishing_presentation.gd b/fishing/remote_fishing_presentation.gd index 5a5b05b..06224de 100644 --- a/fishing/remote_fishing_presentation.gd +++ b/fishing/remote_fishing_presentation.gd @@ -1,9 +1,6 @@ class_name RemoteFishingPresentation extends Node3D -const WaterSurfaceMotionType = preload( - "res://world/water_surface_motion.gd" -) const LINE_THICKNESS: float = 0.016 signal return_completed @@ -157,10 +154,6 @@ func _set_cast_sample( ) -> void: _target = start.lerp(target, progress) _target.y += sin(progress * PI) * 2.0 - _target.y += ( - WaterSurfaceMotionType.get_default_height_offset() - * smoothstep(0.72, 1.0, progress) - ) _bobber.global_position = _target @@ -175,10 +168,7 @@ func _on_cast_finished() -> void: func _apply_bobber_idle_motion() -> void: var phase: float = _bobber_idle_elapsed * 0.7 * TAU - _target = _pending_target + Vector3.UP * ( - WaterSurfaceMotionType.get_default_height_offset() - + sin(phase) * 0.035 - ) + _target = _pending_target + Vector3.UP * sin(phase) * 0.035 _bobber.global_position = _target _bobber.rotation.z = deg_to_rad(4.0) * sin(phase * 0.73) diff --git a/main/main.tscn b/main/main.tscn index bdc7c78..9a88c42 100644 --- a/main/main.tscn +++ b/main/main.tscn @@ -109,19 +109,7 @@ size = Vector2(10000, 10000) [sub_resource type="BoxShape3D" id="BoxShape3D_umo15"] resource_local_to_scene = true -size = Vector3(17, 2, 80) - -[sub_resource type="BoxShape3D" id="BoxShape3D_e4lmg"] -resource_local_to_scene = true -size = Vector3(42, 2, 80) - -[sub_resource type="BoxShape3D" id="BoxShape3D_eb3g5"] -resource_local_to_scene = true -size = Vector3(48, 2, 18) - -[sub_resource type="BoxShape3D" id="BoxShape3D_wsqap"] -resource_local_to_scene = true -size = Vector3(48, 2, 18) +size = Vector3(10000, 2, 10000) [sub_resource type="BoxShape3D" id="BoxShape3D_1fdkm"] resource_local_to_scene = true @@ -336,18 +324,9 @@ shape = SubResource("BoxShape3D_l6210") [node name="VisualWater" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean" index="0" unique_id=1176408937] mesh = SubResource("PlaneMesh_6upbg") -[node name="WestShape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/FishingRegions/OceanFishingRegion" index="0" unique_id=357406062] +[node name="Shape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/FishingRegions/OceanFishingRegion" index="0" unique_id=357406062] shape = SubResource("BoxShape3D_umo15") -[node name="EastShape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/FishingRegions/OceanFishingRegion" index="1" unique_id=772224738] -shape = SubResource("BoxShape3D_e4lmg") - -[node name="NorthShape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/FishingRegions/OceanFishingRegion" index="2" unique_id=646147646] -shape = SubResource("BoxShape3D_eb3g5") - -[node name="SouthShape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/FishingRegions/OceanFishingRegion" index="3" unique_id=894083854] -shape = SubResource("BoxShape3D_wsqap") - [node name="WestShape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/RecoveryRegions/OceanRecoveryRegion" index="0" unique_id=1183368645] shape = SubResource("BoxShape3D_1fdkm") diff --git a/tests/fishing_surface_validation.gd b/tests/fishing_surface_validation.gd index 5b52b41..b3ca57c 100644 --- a/tests/fishing_surface_validation.gd +++ b/tests/fishing_surface_validation.gd @@ -51,6 +51,8 @@ func _validate_resolver_layers() -> void: _add_water_region(world, Vector3(0.0, 2.0, 0.0), Vector2(8.0, 8.0), PondPool) _add_solid_box(world, Vector3(1.0, 1.0, 1.0), Vector3(2.0, 2.25, 0.0)) _add_solid_box(world, Vector3(1.0, 1.0, 1.0), Vector3(-2.0, 6.0, 0.0)) + _add_solid_box(world, Vector3(1.0, 1.0, 1.0), Vector3(0.0, 1.49, 2.0)) + _add_solid_box(world, Vector3(1.0, 1.0, 1.0), Vector3(0.0, 1.5, -2.0)) _add_water_region(world, Vector3(10.0, 5.0, 0.0), Vector2(6.0, 6.0), PondPool) _add_water_region(world, Vector3(20.0, 3.0, 0.0), Vector2(6.0, 6.0), null) await physics_frame @@ -73,6 +75,21 @@ func _validate_resolver_layers() -> void: ) assert(water_under_overhang.is_fishable()) assert(is_equal_approx(water_under_overhang.position.y, 2.0)) + var shallow_water: FishingSurfaceSampleType = resolver.resolve_surface( + space_state, + Vector3(0.0, 2.0, 2.0), + 4.0, + 0.04, + ) + assert(shallow_water.is_fishable()) + var exact_shore_contact: FishingSurfaceSampleType = resolver.resolve_surface( + space_state, + Vector3(0.0, 2.0, -2.0), + 4.0, + 0.04, + ) + assert(exact_shore_contact.has_surface) + assert(not exact_shore_contact.is_fishable()) var covered_water: FishingSurfaceSampleType = resolver.resolve_surface( space_state, @@ -158,38 +175,44 @@ func _validate_portable_water_body() -> void: water_body.queue_free() await process_frame + var mesh_derived_body := WaterBodyScene.instantiate() as WaterBodyAuthoring + mesh_derived_body.position = Vector3(-4.0, 3.0, 6.0) + mesh_derived_body.derive_coverage_from_visual_mesh = true + mesh_derived_body.fishing_depth = 3.0 + mesh_derived_body.fish_pool = PondPool + var derived_visual := mesh_derived_body.get_node( + "VisualWater" + ) as MeshInstance3D + var derived_mesh := derived_visual.mesh as PlaneMesh + derived_mesh.size = Vector2(18.0, 11.0) + root.add_child(mesh_derived_body) + await process_frame + + var derived_shape_node := mesh_derived_body.get_node( + "FishingRegion/Shape" + ) as CollisionShape3D + var derived_shape := derived_shape_node.shape as BoxShape3D + assert(derived_shape.size.is_equal_approx(Vector3(18.0, 3.0, 11.0))) + assert(derived_shape_node.position.is_equal_approx(Vector3(0.0, -1.5, 0.0))) + assert(derived_mesh.size.is_equal_approx(Vector2(18.0, 11.0))) + + mesh_derived_body.queue_free() + await process_frame + func _validate_starter_region_surfaces() -> void: var region := StarterRegionScene.instantiate() as Node3D root.add_child(region) + var ocean_body := region.get_node( + "WaterBodies/Ocean" + ) as WaterBodyAuthoring + assert(ocean_body != null) + assert(ocean_body.derive_coverage_from_visual_mesh) + assert(not ocean_body.manage_recovery_coverage) var ocean_region := region.get_node( "WaterBodies/Ocean/FishingRegions/OceanFishingRegion" ) as FishableWaterRegionType - var minimum_x: float = INF - var maximum_x: float = -INF - var minimum_z: float = INF - var maximum_z: float = -INF - for child: Node in ocean_region.get_children(): - var shape_node := child as CollisionShape3D - assert(shape_node != null and shape_node.shape is BoxShape3D) - var box := shape_node.shape as BoxShape3D - assert(is_equal_approx(box.size.y, 2.0)) - minimum_x = minf(minimum_x, shape_node.position.x - box.size.x * 0.5) - maximum_x = maxf(maximum_x, shape_node.position.x + box.size.x * 0.5) - minimum_z = minf(minimum_z, shape_node.position.z - box.size.z * 0.5) - maximum_z = maxf(maximum_z, shape_node.position.z + box.size.z * 0.5) - var ocean_footprint := Vector2( - maximum_x - minimum_x, - maximum_z - minimum_z, - ) - assert(is_equal_approx( - ocean_footprint.x * ocean_footprint.y, - 12840.0, - )) - assert(is_equal_approx( - ocean_footprint.x / 107.0, - ocean_footprint.y / 80.0, - )) + assert(ocean_region.get_child_count() == 1) var fishing_spot := FishingSpotScene.instantiate() as FishingSpotType root.add_child(fishing_spot) await physics_frame @@ -207,6 +230,27 @@ func _validate_starter_region_surfaces() -> void: ) assert(ocean.is_fishable()) assert(is_equal_approx(ocean.position.y, -0.45)) + var ocean_visual := region.get_node( + "WaterBodies/Ocean/VisualWater" + ) as MeshInstance3D + assert(ocean_visual != null) + assert(is_equal_approx(ocean_visual.global_position.y, ocean.position.y)) + assert(not ocean_visual.is_processing()) + var ocean_shapes := region.get_node( + "WaterBodies/Ocean/FishingRegions/OceanFishingRegion" + ).get_children() + assert(ocean_shapes.size() == 1) + var ocean_shape := ocean_shapes[0] as CollisionShape3D + assert(ocean_shape != null) + var ocean_box := ocean_shape.shape as BoxShape3D + assert(ocean_box != null) + var ocean_mesh := ocean_visual.mesh as PlaneMesh + assert(ocean_mesh != null) + assert(ocean_box.size.is_equal_approx(Vector3( + ocean_mesh.size.x, + 2.0, + ocean_mesh.size.y, + ))) var expanded_ocean: FishingSurfaceSampleType = ( fishing_spot.resolve_fishing_surface( Vector3(72.0, -0.45, 0.0), diff --git a/world/materials/stylized_water.gdshader b/world/materials/stylized_water.gdshader index fa6c266..998fdc3 100644 --- a/world/materials/stylized_water.gdshader +++ b/world/materials/stylized_water.gdshader @@ -515,9 +515,10 @@ void fragment() { water_alpha + shoreline_foam * shoreline_opacity_boost, 0.98 ); - // Preserve most of the water layer at exact terrain contact. Fully fading - // it exposed the sand texture as a broken tan seam along the water plane. - water_alpha *= mix(0.82, 1.0, contact_stability); + // The authored water-body height is also the casting authority. Fade the + // visual layer completely at exact terrain contact so dry land cannot look + // fishable while the stable sub-surface water remains visible. + water_alpha *= contact_stability; float surface_mottle = value_noise( world_position.xz * surface_mottle_scale diff --git a/world/regions/starter_island_region.tscn b/world/regions/starter_island_region.tscn index d956feb..67cddc3 100644 --- a/world/regions/starter_island_region.tscn +++ b/world/regions/starter_island_region.tscn @@ -12,7 +12,6 @@ [ext_resource type="Material" path="res://world/materials/stylized_water.tres" id="13_water"] [ext_resource type="ArrayMesh" path="res://world/generated/shorelines/starter_ocean_shoreline.tres" id="17_ocean_shoreline"] [ext_resource type="Material" path="res://world/materials/stylized_water_fresh.tres" id="19_fresh_water"] -[ext_resource type="Script" path="res://world/water_surface_motion.gd" id="20_surface_motion"] [ext_resource type="Script" path="res://world/water/shoreline_ribbon_baker.gd" id="21_shoreline_baker"] [ext_resource type="Script" path="res://world/water/shoreline_ribbon_config.gd" id="22_shoreline_config"] @@ -35,25 +34,9 @@ size = Vector3(16.1, 4.8, 12.3675) resource_local_to_scene = true size = Vector2(10000, 10000) -[sub_resource type="BoxShape3D" id="OceanWestShape"] +[sub_resource type="BoxShape3D" id="OceanFishingShape"] resource_local_to_scene = true -size = Vector3(20.820662814, 2, 97.979589711) - -[sub_resource type="BoxShape3D" id="OceanEastShape"] -resource_local_to_scene = true -size = Vector3(51.439284598, 2, 97.979589711) - -[sub_resource type="BoxShape3D" id="OceanNorthShape"] -resource_local_to_scene = true -size = Vector3(58.787753827, 2, 22.045407685) - -[sub_resource type="BoxShape3D" id="OceanSouthShape"] -resource_local_to_scene = true -size = Vector3(58.787753827, 2, 22.045407685) - -[sub_resource type="BoxShape3D" id="OceanCenterShape"] -resource_local_to_scene = true -size = Vector3(58.787753827, 2, 53.888774341) +size = Vector3(10000, 2, 10000) [sub_resource type="BoxShape3D" id="OceanWestRecoveryShape"] resource_local_to_scene = true @@ -161,11 +144,20 @@ shape = SubResource("PondRecoveryShape") [node name="Ocean" type="Node3D" parent="WaterBodies" unique_id=552655799] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 12, -0.45, 0) +script = ExtResource("10_water_body") +derive_coverage_from_visual_mesh = true +water_material = ExtResource("13_water") +fishing_depth = 2.0 +fish_pool = ExtResource("4_pool") +water_type = 1 +location_tags = Array[StringName]([&"coast", &"ocean"]) +manage_recovery_coverage = false +fishing_shape_path = NodePath("FishingRegions/OceanFishingRegion/Shape") +fishing_region_path = NodePath("FishingRegions/OceanFishingRegion") [node name="VisualWater" type="MeshInstance3D" parent="WaterBodies/Ocean" unique_id=1176408937] material_override = ExtResource("13_water") mesh = SubResource("OceanWaterMesh") -script = ExtResource("20_surface_motion") [node name="FishingRegions" type="Node3D" parent="WaterBodies/Ocean" unique_id=205598473] @@ -179,25 +171,9 @@ water_type = 1 fish_pool = ExtResource("4_pool") surface_height_mode = 1 -[node name="WestShape" type="CollisionShape3D" parent="WaterBodies/Ocean/FishingRegions/OceanFishingRegion" unique_id=357406062] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -55.613519213, -1, 0) -shape = SubResource("OceanWestShape") - -[node name="EastShape" type="CollisionShape3D" parent="WaterBodies/Ocean/FishingRegions/OceanFishingRegion" unique_id=772224738] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 39.30420832, -1, 0) -shape = SubResource("OceanEastShape") - -[node name="NorthShape" type="CollisionShape3D" parent="WaterBodies/Ocean/FishingRegions/OceanFishingRegion" unique_id=646147646] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -15.809310892, -1, 37.967091013) -shape = SubResource("OceanNorthShape") - -[node name="SouthShape" type="CollisionShape3D" parent="WaterBodies/Ocean/FishingRegions/OceanFishingRegion" unique_id=894083854] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -15.809310892, -1, -37.967091013) -shape = SubResource("OceanSouthShape") - -[node name="CenterShape" type="CollisionShape3D" parent="WaterBodies/Ocean/FishingRegions/OceanFishingRegion"] -position = Vector3(-15.809310892, -1, 0) -shape = SubResource("OceanCenterShape") +[node name="Shape" type="CollisionShape3D" parent="WaterBodies/Ocean/FishingRegions/OceanFishingRegion" unique_id=357406062] +position = Vector3(0, -1, 0) +shape = SubResource("OceanFishingShape") [node name="RecoveryRegions" type="Node3D" parent="WaterBodies/Ocean" unique_id=463706703] diff --git a/world/water_body_authoring.gd b/world/water_body_authoring.gd index 70ead79..8a2a3a9 100644 --- a/world/water_body_authoring.gd +++ b/world/water_body_authoring.gd @@ -14,6 +14,12 @@ var surface_size: Vector2 = Vector2(10.0, 10.0): set(value): surface_size = Vector2(maxf(value.x, 0.1), maxf(value.y, 0.1)) _sync_owned_nodes() +## Derive fishing coverage from VisualWater's mesh bounds instead of resizing +## the mesh from Surface Size. Use this for imported or shared water surfaces. +@export var derive_coverage_from_visual_mesh: bool = false: + set(value): + derive_coverage_from_visual_mesh = value + _sync_owned_nodes() ## Material applied to the visible water surface. @export var water_material: Material: set(value): @@ -44,6 +50,11 @@ var fishing_depth: float = 4.0: _sync_owned_nodes() @export_group("Recovery Coverage") +## Keep recovery coverage independent when a water body uses custom volumes. +@export var manage_recovery_coverage: bool = true: + set(value): + manage_recovery_coverage = value + _sync_owned_nodes() @export_range(0.1, 20.0, 0.1, "or_greater", "suffix:m") var recovery_depth: float = 4.8: set(value): @@ -75,9 +86,10 @@ func _sync_owned_nodes() -> void: var visual_water := get_node_or_null(visual_water_path) as MeshInstance3D if visual_water != null: var plane_mesh := visual_water.mesh as PlaneMesh - if plane_mesh != null: + if plane_mesh != null and not derive_coverage_from_visual_mesh: plane_mesh.size = surface_size visual_water.material_override = water_material + var surface_footprint: Rect2 = _resolve_surface_footprint(visual_water) var fishing_shape_node := ( get_node_or_null(fishing_shape_path) as CollisionShape3D @@ -99,34 +111,73 @@ func _sync_owned_nodes() -> void: # The authored root is the one water-surface height. Keep the fishable # volume entirely below that plane so visible water and interaction # can be moved together without a second height to tune. - fishing_shape_node.position.y = -fishing_depth * 0.5 + fishing_shape_node.position = Vector3( + surface_footprint.get_center().x, + -fishing_depth * 0.5, + surface_footprint.get_center().y, + ) fishing_shape.size = Vector3( - surface_size.x, + surface_footprint.size.x, fishing_depth, - surface_size.y + surface_footprint.size.y, ) - var recovery_region := ( - get_node_or_null(recovery_region_path) as Area3D - ) - if recovery_region != null: - recovery_region.position.y = -recovery_depth * 0.5 - var recovery_shape_node := ( - get_node_or_null(recovery_shape_path) as CollisionShape3D - ) - if recovery_shape_node != null: - var recovery_shape := recovery_shape_node.shape as BoxShape3D - if recovery_shape != null: - recovery_shape.size = Vector3( - surface_size.x, - recovery_depth, - surface_size.y + if manage_recovery_coverage: + var recovery_region := ( + get_node_or_null(recovery_region_path) as Area3D + ) + if recovery_region != null: + recovery_region.position = Vector3( + surface_footprint.get_center().x, + -recovery_depth * 0.5, + surface_footprint.get_center().y, ) + var recovery_shape_node := ( + get_node_or_null(recovery_shape_path) as CollisionShape3D + ) + if recovery_shape_node != null: + var recovery_shape := recovery_shape_node.shape as BoxShape3D + if recovery_shape != null: + recovery_shape.size = Vector3( + surface_footprint.size.x, + recovery_depth, + surface_footprint.size.y, + ) if Engine.is_editor_hint(): update_configuration_warnings() +func _resolve_surface_footprint(visual_water: MeshInstance3D) -> Rect2: + var authored_footprint := Rect2(-surface_size * 0.5, surface_size) + if ( + not derive_coverage_from_visual_mesh + or visual_water == null + or visual_water.mesh == null + ): + return authored_footprint + var mesh_bounds: AABB = visual_water.mesh.get_aabb() + if mesh_bounds.size.x <= 0.0 or mesh_bounds.size.z <= 0.0: + return authored_footprint + var visual_to_body: Transform3D = ( + global_transform.affine_inverse() * visual_water.global_transform + ) + var minimum := Vector2(INF, INF) + var maximum := Vector2(-INF, -INF) + for corner_index: int in 8: + var corner := mesh_bounds.position + Vector3( + mesh_bounds.size.x if (corner_index & 1) != 0 else 0.0, + mesh_bounds.size.y if (corner_index & 2) != 0 else 0.0, + mesh_bounds.size.z if (corner_index & 4) != 0 else 0.0, + ) + var body_corner: Vector3 = visual_to_body * corner + minimum.x = minf(minimum.x, body_corner.x) + minimum.y = minf(minimum.y, body_corner.z) + maximum.x = maxf(maximum.x, body_corner.x) + maximum.y = maxf(maximum.y, body_corner.z) + return Rect2(minimum, maximum - minimum) + + func _get_configuration_warnings() -> PackedStringArray: var warnings := PackedStringArray() if not scale.is_equal_approx(Vector3.ONE): @@ -134,28 +185,36 @@ func _get_configuration_warnings() -> PackedStringArray: "Keep root scale at 1; resize water with Surface Size." ) var visual_water := get_node_or_null(visual_water_path) as MeshInstance3D - if visual_water == null or not visual_water.mesh is PlaneMesh: - warnings.append("VisualWater must provide a PlaneMesh.") + if visual_water == null or visual_water.mesh == null: + warnings.append("VisualWater must provide a mesh.") + elif ( + not derive_coverage_from_visual_mesh + and not visual_water.mesh is PlaneMesh + ): + warnings.append( + "VisualWater must provide a PlaneMesh when coverage is authored by size." + ) var fishing_shape := get_node_or_null(fishing_shape_path) as CollisionShape3D if fishing_shape == null or not fishing_shape.shape is BoxShape3D: warnings.append("FishingRegion must provide a BoxShape3D.") - var recovery_region := ( - get_node_or_null(recovery_region_path) as PlayerWaterTrigger - ) - if recovery_region == null: - warnings.append("RecoveryRegion must use PlayerWaterTrigger.") - elif ( - recovery_region.surface_height_mode - != PlayerWaterTrigger.SurfaceHeightMode.PARENT_GLOBAL_Y - ): - warnings.append( - "RecoveryRegion must derive surface height from its parent." + if manage_recovery_coverage: + var recovery_region := ( + get_node_or_null(recovery_region_path) as PlayerWaterTrigger ) - var recovery_shape := ( - get_node_or_null(recovery_shape_path) as CollisionShape3D - ) - if recovery_shape == null or not recovery_shape.shape is BoxShape3D: - warnings.append("RecoveryRegion must provide a BoxShape3D.") + if recovery_region == null: + warnings.append("RecoveryRegion must use PlayerWaterTrigger.") + elif ( + recovery_region.surface_height_mode + != PlayerWaterTrigger.SurfaceHeightMode.PARENT_GLOBAL_Y + ): + warnings.append( + "RecoveryRegion must derive surface height from its parent." + ) + var recovery_shape := ( + get_node_or_null(recovery_shape_path) as CollisionShape3D + ) + if recovery_shape == null or not recovery_shape.shape is BoxShape3D: + warnings.append("RecoveryRegion must provide a BoxShape3D.") var fishing_region := get_node_or_null( fishing_region_path ) as FishableWaterRegion