352 lines
12 KiB
GDScript
352 lines
12 KiB
GDScript
@tool
|
|
class_name WaterBodyAuthoring
|
|
extends Node3D
|
|
|
|
const FishPoolType = preload("res://fish/fish_pool.gd")
|
|
const GENERATED_POLYGON_SHAPE_META := &"water_body_generated_polygon_shape"
|
|
|
|
@export_group("Surface")
|
|
## Visible water footprint in meters.
|
|
@export_custom(
|
|
PROPERTY_HINT_RANGE,
|
|
"0.1,200.0,0.1,or_greater,suffix:m",
|
|
)
|
|
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()
|
|
## Optional exact local X/Z coverage. Three or more points replace the box
|
|
## collision footprint with triangulated, vertically extruded volumes. The
|
|
## visible PlaneMesh remains controlled by surface_size.
|
|
@export var surface_polygon := PackedVector2Array():
|
|
set(value):
|
|
surface_polygon = value
|
|
_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):
|
|
water_material = value
|
|
_sync_owned_nodes()
|
|
## Render the owned plane. Disable this when another shared water surface
|
|
## already provides visible water and this body only supplies gameplay data.
|
|
@export var visual_surface_enabled := true:
|
|
set(value):
|
|
visual_surface_enabled = value
|
|
_sync_owned_nodes()
|
|
|
|
@export_group("Fishing Coverage")
|
|
@export_range(0.1, 20.0, 0.1, "or_greater", "suffix:m")
|
|
var fishing_depth: float = 4.0:
|
|
set(value):
|
|
fishing_depth = maxf(value, 0.1)
|
|
_sync_owned_nodes()
|
|
@export var fish_pool: FishPoolType:
|
|
set(value):
|
|
fish_pool = value
|
|
_sync_owned_nodes()
|
|
@export var water_type: WaterType.Type = WaterType.Type.FRESH_WATER:
|
|
set(value):
|
|
water_type = value
|
|
_sync_owned_nodes()
|
|
@export var location_tags: Array[StringName] = []:
|
|
set(value):
|
|
location_tags = value
|
|
_sync_owned_nodes()
|
|
@export var selection_priority: int = 0:
|
|
set(value):
|
|
selection_priority = value
|
|
_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 var recovery_entry_height_reference: PlayerWaterTrigger.EntryHeightReference = (
|
|
PlayerWaterTrigger.EntryHeightReference.BODY_CENTER
|
|
):
|
|
set(value):
|
|
recovery_entry_height_reference = value
|
|
_sync_owned_nodes()
|
|
@export_range(0.0, 2.0, 0.05)
|
|
var recovery_entry_depth_threshold: float = 0.35:
|
|
set(value):
|
|
recovery_entry_depth_threshold = clampf(value, 0.0, 2.0)
|
|
_sync_owned_nodes()
|
|
@export_range(0.1, 20.0, 0.1, "or_greater", "suffix:m")
|
|
var recovery_depth: float = 4.8:
|
|
set(value):
|
|
recovery_depth = maxf(value, 0.1)
|
|
_sync_owned_nodes()
|
|
|
|
@export_group("Owned Nodes")
|
|
@export_node_path("MeshInstance3D")
|
|
var visual_water_path: NodePath = ^"VisualWater"
|
|
@export_node_path("CollisionShape3D")
|
|
var fishing_shape_path: NodePath = ^"FishingRegion/Shape"
|
|
@export_node_path("Area3D")
|
|
var fishing_region_path: NodePath = ^"FishingRegion"
|
|
@export_node_path("Area3D")
|
|
var recovery_region_path: NodePath = ^"RecoveryRegion"
|
|
@export_node_path("CollisionShape3D")
|
|
var recovery_shape_path: NodePath = ^"RecoveryRegion/Shape"
|
|
|
|
|
|
func _ready() -> void:
|
|
_sync_owned_nodes()
|
|
if Engine.is_editor_hint():
|
|
update_configuration_warnings()
|
|
|
|
|
|
func _sync_owned_nodes() -> void:
|
|
if not is_inside_tree():
|
|
return
|
|
var visual_water := get_node_or_null(visual_water_path) as MeshInstance3D
|
|
if visual_water != null:
|
|
visual_water.visible = visual_surface_enabled
|
|
var plane_mesh := visual_water.mesh as PlaneMesh
|
|
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 uses_polygon := surface_polygon.size() >= 3
|
|
|
|
var fishing_shape_node := (
|
|
get_node_or_null(fishing_shape_path) as CollisionShape3D
|
|
)
|
|
var fishing_region := (
|
|
get_node_or_null(fishing_region_path) as FishableWaterRegion
|
|
)
|
|
if fishing_region != null:
|
|
fishing_region.fish_pool = fish_pool
|
|
fishing_region.water_type = water_type
|
|
fishing_region.location_tags = location_tags.duplicate()
|
|
fishing_region.selection_priority = selection_priority
|
|
fishing_region.surface_height_mode = (
|
|
FishableWaterRegion.SurfaceHeightMode.PARENT_GLOBAL_Y
|
|
)
|
|
if fishing_shape_node != null and fishing_region != null:
|
|
if uses_polygon and _sync_polygon_collision_shapes(
|
|
fishing_region,
|
|
fishing_shape_node,
|
|
fishing_depth,
|
|
):
|
|
pass
|
|
else:
|
|
_clear_generated_polygon_shapes(fishing_region)
|
|
var fishing_shape := fishing_shape_node.shape as BoxShape3D
|
|
if fishing_shape == null:
|
|
fishing_shape = BoxShape3D.new()
|
|
fishing_shape_node.shape = fishing_shape
|
|
# 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 = Vector3(
|
|
surface_footprint.get_center().x,
|
|
-fishing_depth * 0.5,
|
|
surface_footprint.get_center().y,
|
|
)
|
|
fishing_shape.size = Vector3(
|
|
surface_footprint.size.x,
|
|
fishing_depth,
|
|
surface_footprint.size.y,
|
|
)
|
|
|
|
if manage_recovery_coverage:
|
|
var recovery_region := (
|
|
get_node_or_null(recovery_region_path) as PlayerWaterTrigger
|
|
)
|
|
var recovery_shape_node := (
|
|
get_node_or_null(recovery_shape_path) as CollisionShape3D
|
|
)
|
|
if recovery_region != null and recovery_shape_node != null:
|
|
recovery_region.entry_height_reference = (
|
|
recovery_entry_height_reference
|
|
)
|
|
recovery_region.entry_depth_threshold = (
|
|
recovery_entry_depth_threshold
|
|
)
|
|
if uses_polygon and _sync_polygon_collision_shapes(
|
|
recovery_region,
|
|
recovery_shape_node,
|
|
recovery_depth,
|
|
):
|
|
recovery_region.position = Vector3.ZERO
|
|
else:
|
|
_clear_generated_polygon_shapes(recovery_region)
|
|
recovery_region.position = Vector3(
|
|
surface_footprint.get_center().x,
|
|
-recovery_depth * 0.5,
|
|
surface_footprint.get_center().y,
|
|
)
|
|
recovery_shape_node.position = Vector3.ZERO
|
|
var recovery_shape := recovery_shape_node.shape as BoxShape3D
|
|
if recovery_shape == null:
|
|
recovery_shape = BoxShape3D.new()
|
|
recovery_shape_node.shape = recovery_shape
|
|
recovery_shape.size = Vector3(
|
|
surface_footprint.size.x,
|
|
recovery_depth,
|
|
surface_footprint.size.y,
|
|
)
|
|
|
|
if Engine.is_editor_hint():
|
|
update_configuration_warnings()
|
|
|
|
|
|
func _sync_polygon_collision_shapes(
|
|
region: CollisionObject3D,
|
|
primary_shape_node: CollisionShape3D,
|
|
depth: float,
|
|
) -> bool:
|
|
var triangle_indices := Geometry2D.triangulate_polygon(surface_polygon)
|
|
if triangle_indices.size() < 3:
|
|
return false
|
|
_clear_generated_polygon_shapes(region)
|
|
for offset: int in range(0, triangle_indices.size(), 3):
|
|
var shape_node := primary_shape_node
|
|
if offset > 0:
|
|
shape_node = CollisionShape3D.new()
|
|
shape_node.name = "GeneratedPolygonShape%d" % floori(
|
|
float(offset) / 3.0
|
|
)
|
|
shape_node.set_meta(GENERATED_POLYGON_SHAPE_META, true)
|
|
region.add_child(shape_node)
|
|
shape_node.position = Vector3.ZERO
|
|
shape_node.disabled = false
|
|
shape_node.shape = _extruded_triangle_shape(
|
|
triangle_indices,
|
|
offset,
|
|
depth,
|
|
)
|
|
return true
|
|
|
|
|
|
func _extruded_triangle_shape(
|
|
triangle_indices: PackedInt32Array,
|
|
offset: int,
|
|
depth: float,
|
|
) -> ConvexPolygonShape3D:
|
|
var points := PackedVector3Array()
|
|
for point_offset: int in 3:
|
|
var point := surface_polygon[triangle_indices[offset + point_offset]]
|
|
points.append(Vector3(point.x, 0.0, point.y))
|
|
points.append(Vector3(point.x, -depth, point.y))
|
|
var shape := ConvexPolygonShape3D.new()
|
|
shape.points = points
|
|
return shape
|
|
|
|
|
|
func _clear_generated_polygon_shapes(region: CollisionObject3D) -> void:
|
|
for child: Node in region.get_children():
|
|
if not child.has_meta(GENERATED_POLYGON_SHAPE_META):
|
|
continue
|
|
region.remove_child(child)
|
|
child.free()
|
|
|
|
|
|
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):
|
|
warnings.append(
|
|
"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 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:
|
|
warnings.append("FishingRegion must provide a collision shape.")
|
|
elif surface_polygon.size() >= 3:
|
|
if not fishing_shape.shape is ConvexPolygonShape3D:
|
|
warnings.append(
|
|
"FishingRegion must provide polygon coverage for Surface Polygon."
|
|
)
|
|
elif not fishing_shape.shape is BoxShape3D:
|
|
warnings.append("FishingRegion must provide a BoxShape3D.")
|
|
if manage_recovery_coverage:
|
|
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."
|
|
)
|
|
var recovery_shape := (
|
|
get_node_or_null(recovery_shape_path) as CollisionShape3D
|
|
)
|
|
if recovery_shape == null:
|
|
warnings.append("RecoveryRegion must provide a collision shape.")
|
|
elif surface_polygon.size() >= 3:
|
|
if not recovery_shape.shape is ConvexPolygonShape3D:
|
|
warnings.append(
|
|
"RecoveryRegion must provide polygon coverage for Surface Polygon."
|
|
)
|
|
elif 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
|
|
if fishing_region == null:
|
|
warnings.append("FishingRegion must use FishableWaterRegion.")
|
|
elif (
|
|
fishing_region.surface_height_mode
|
|
!= FishableWaterRegion.SurfaceHeightMode.PARENT_GLOBAL_Y
|
|
):
|
|
warnings.append(
|
|
"FishingRegion must derive surface height from its parent."
|
|
)
|
|
if water_material == null:
|
|
warnings.append("Assign a water material.")
|
|
if fish_pool == null:
|
|
warnings.append("Assign a fish pool.")
|
|
return warnings
|