Improve world authoring workflow

This commit is contained in:
Alexander Sellite 2026-07-27 03:30:03 -04:00
parent 67d17074b2
commit 0d26f30172
18 changed files with 682 additions and 138 deletions

View file

@ -118,7 +118,7 @@ var _new_cast_press_armed: bool = true
var _bobber_water_position: Vector3
var _fight_start_position: Vector3
var _cooldown_status: String = ""
var _fishable_query_shape: SphereShape3D = SphereShape3D.new()
var _fishable_query_shape: CylinderShape3D = CylinderShape3D.new()
var _fish_selector: FishSelectorType = FishSelectorType.new()
var _selected_water_region: FishableWaterRegionType
var _selection_context: FishingContextType
@ -966,7 +966,7 @@ func _calculate_cast_target(distance: float) -> Vector3:
target
)
if water_region != null:
target.y = water_region.surface_height
target.y = water_region.get_surface_height()
_presentation.set_water_surface_height(target.y)
else:
_presentation.reset_water_surface_height()
@ -982,11 +982,12 @@ func get_fishable_water_region(
target: Vector3,
) -> FishableWaterRegionType:
_fishable_query_shape.radius = fishable_query_radius
_fishable_query_shape.height = preview_ray_length
var query := PhysicsShapeQueryParameters3D.new()
query.shape = _fishable_query_shape
query.transform = Transform3D(
Basis.IDENTITY,
Vector3(target.x, _presentation.get_water_surface_height(), target.z)
Vector3(target.x, target.y, target.z)
)
query.collision_mask = fishable_surface_mask
query.collide_with_areas = true

View file

@ -0,0 +1,88 @@
# World authoring
The active test world uses the human-authored starter island through
`world/regions/starter_island_region.tscn`.
## Active composition
`world/test_world.tscn` owns the gameplay-wide environment, sun, world bounds,
and below-world failsafe. `StarterIslandRegion` owns the placed island content:
```text
StarterIslandRegion
├── Terrain
│ ├── Visual
│ └── Collision
├── WaterBodies
│ ├── Pond
│ │ ├── VisualWater
│ │ ├── FishingRegion
│ │ └── RecoveryRegion
│ └── Ocean
│ ├── VisualWater
│ ├── FishingRegions
│ └── RecoveryRegions
├── PlayerSpawn
├── SafeRespawns
└── Interactables
├── FishingShopWorld
└── PelicanCoolerPerch
```
Move the meaningful feature root rather than one of its implementation
children. Child transforms are local offsets owned by that feature.
## Water bodies
Select `WaterBodies/Pond` to move or resize the pond. Its transform is the
authoritative surface position. The `surface_size`, fishing padding, recovery
padding, and recovery depth properties update the visible water and gameplay
coverage together. Fishing and recovery derive the surface height from the
owned surface transform.
Select `WaterBodies/Ocean` to move the surrounding water as one feature. Its
visual, fishing lobes, and recovery lobes use local transforms beneath the
ocean root. The existing multi-lobe footprint remains intentionally explicit;
changing that footprint still requires editing its owned lobe children.
## Placed features
- `PlayerSpawn` is the authoritative initial player transform. Its rotation
controls initial facing.
- Each marker under `SafeRespawns` is an authoritative recovery destination.
Recovery preserves the player's current facing; marker rotation is
intentionally not applied.
- `Interactables/FishingShopWorld` owns the shop visual, collision,
interaction area, prompt, and entrance marker.
- `Interactables/PelicanCoolerPerch` owns the complete Pelican landmark visual.
Pelican selling remains a Cooler action and has no world interaction area.
- `Terrain` owns both the imported visual and generated terrain collision.
- `BelowWorldFailsafe/Coverage` exposes the below-world recovery coverage as a
saved collision shape in `world/test_world.tscn`.
The active `Environment`, `Sun`, and external starter-island grass material
remain directly editable through the Inspector. Their tuning is not recreated
by runtime scripts.
## Reusable world props
Use a single placed `WorldProp` root for trees, rocks, buildings, benches,
signs, vegetation, and similar landmarks:
```text
WorldProp
├── Visual
└── Collision
└── CollisionShape3D
```
Add optional interaction areas, labels, and markers beneath the same root.
Instance imported GLB visuals beneath `Visual`; keep gameplay collision
Godot-owned beneath `Collision`. Place and duplicate the complete prop scene,
not its visual child.
Author reusable assets at scale `(1, 1, 1)`. Uniform root scaling is acceptable
for provisional props when visual and collision scale together. Avoid
non-uniform root scaling because it can deform collision and imported geometry
unpredictably. Make mutable per-instance shapes, meshes, and materials local to
the scene so editing one prop does not change unrelated instances.

View file

@ -0,0 +1,24 @@
@tool
class_name BelowWorldFailsafe
extends PlayerWaterTrigger
@export_group("Failsafe Authoring")
## Saved coverage volume that catches players below the playable world.
@export_node_path("CollisionShape3D")
var coverage_shape_path: NodePath = ^"Coverage"
func _ready() -> void:
super()
if Engine.is_editor_hint():
update_configuration_warnings()
func _get_configuration_warnings() -> PackedStringArray:
var warnings := PackedStringArray()
var coverage := get_node_or_null(coverage_shape_path) as CollisionShape3D
if coverage == null:
warnings.append("Add the saved Coverage collision shape.")
elif coverage.shape == null:
warnings.append("Assign a shape to the failsafe Coverage node.")
return warnings

View file

@ -0,0 +1 @@
uid://b71576njgcgyw

View file

@ -1,9 +1,38 @@
@tool
class_name FishableWaterRegion
extends Area3D
const FishPoolType = preload("res://fish/fish_pool.gd")
enum SurfaceHeightMode {
EXPLICIT,
PARENT_GLOBAL_Y,
}
@export var location_tags: Array[StringName] = []
@export var fish_pool: FishPoolType
@export var selection_priority: int = 0
@export_group("Surface")
@export var surface_height_mode: SurfaceHeightMode = SurfaceHeightMode.EXPLICIT:
set(value):
surface_height_mode = value
if Engine.is_editor_hint():
notify_property_list_changed()
## Legacy explicit height used when Surface Height Mode is Explicit.
@export var surface_height: float = 0.3
func get_surface_height() -> float:
if surface_height_mode == SurfaceHeightMode.PARENT_GLOBAL_Y:
var surface_owner := get_parent() as Node3D
if surface_owner != null:
return surface_owner.global_position.y
return surface_height
func _validate_property(property: Dictionary) -> void:
if (
property.name == "surface_height"
and surface_height_mode == SurfaceHeightMode.PARENT_GLOBAL_Y
):
property.usage = int(property.usage) & ~PROPERTY_USAGE_EDITOR

View file

@ -1,64 +1,78 @@
[gd_scene load_steps=10 format=3]
[gd_scene load_steps=11 format=3]
[ext_resource type="Script" path="res://world/fishing_shop_interaction.gd" id="1_shop"]
[ext_resource type="Script" path="res://world/world_prop.gd" id="2_prop"]
[sub_resource type="BoxMesh" id="CounterMesh"]
resource_local_to_scene = true
size = Vector3(4.2, 1.25, 1.8)
[sub_resource type="BoxMesh" id="RoofMesh"]
resource_local_to_scene = true
size = Vector3(5.2, 0.35, 3.2)
[sub_resource type="BoxMesh" id="PostMesh"]
resource_local_to_scene = true
size = Vector3(0.28, 3.2, 0.28)
[sub_resource type="BoxMesh" id="BackMesh"]
resource_local_to_scene = true
size = Vector3(5, 2.8, 0.25)
[sub_resource type="StandardMaterial3D" id="PrimaryMaterial"]
resource_local_to_scene = true
albedo_color = Color(0.12, 0.68, 0.62, 1)
[sub_resource type="StandardMaterial3D" id="AccentMaterial"]
resource_local_to_scene = true
albedo_color = Color(1, 0.68, 0.18, 1)
[sub_resource type="StandardMaterial3D" id="BackMaterial"]
resource_local_to_scene = true
albedo_color = Color(0.13, 0.34, 0.38, 1)
[sub_resource type="SphereShape3D" id="InteractionShape"]
resource_local_to_scene = true
radius = 3.0
[sub_resource type="BoxShape3D" id="CounterShape"]
resource_local_to_scene = true
size = Vector3(4.2, 1.25, 1.8)
[node name="FishingShopWorld" type="Node3D"]
script = ExtResource("2_prop")
collision_root_path = NodePath("Collision")
interaction_area_path = NodePath("InteractionArea")
entrance_marker_path = NodePath("EntranceMarker")
[node name="Visuals" type="Node3D" parent="."]
[node name="Visual" type="Node3D" parent="."]
[node name="Counter" type="MeshInstance3D" parent="Visuals"]
[node name="Counter" type="MeshInstance3D" parent="Visual"]
position = Vector3(0, 0.625, 0)
mesh = SubResource("CounterMesh")
material_override = SubResource("PrimaryMaterial")
[node name="Back" type="MeshInstance3D" parent="Visuals"]
[node name="Back" type="MeshInstance3D" parent="Visual"]
position = Vector3(0, 1.4, 1.25)
mesh = SubResource("BackMesh")
material_override = SubResource("BackMaterial")
[node name="Roof" type="MeshInstance3D" parent="Visuals"]
[node name="Roof" type="MeshInstance3D" parent="Visual"]
position = Vector3(0, 3.15, 0.35)
mesh = SubResource("RoofMesh")
material_override = SubResource("AccentMaterial")
[node name="WestPost" type="MeshInstance3D" parent="Visuals"]
[node name="WestPost" type="MeshInstance3D" parent="Visual"]
position = Vector3(-2.15, 1.6, -0.8)
mesh = SubResource("PostMesh")
material_override = SubResource("PrimaryMaterial")
[node name="EastPost" type="MeshInstance3D" parent="Visuals"]
[node name="EastPost" type="MeshInstance3D" parent="Visual"]
position = Vector3(2.15, 1.6, -0.8)
mesh = SubResource("PostMesh")
material_override = SubResource("PrimaryMaterial")
[node name="ShopName" type="Label3D" parent="Visuals"]
[node name="ShopName" type="Label3D" parent="Visual"]
position = Vector3(0, 3.7, 0)
text = "fishing shop"
font_size = 42
@ -66,18 +80,21 @@ outline_size = 9
billboard = 1
no_depth_test = true
[node name="StaticCollision" type="StaticBody3D" parent="."]
[node name="Collision" type="StaticBody3D" parent="."]
[node name="CounterShape" type="CollisionShape3D" parent="StaticCollision"]
[node name="CounterShape" type="CollisionShape3D" parent="Collision"]
position = Vector3(0, 0.625, 0)
shape = SubResource("CounterShape")
[node name="FishingShopInteraction" type="Area3D" parent="."]
[node name="InteractionArea" type="Area3D" parent="."]
unique_name_in_owner = true
position = Vector3(0, 1, -0.3)
collision_layer = 0
collision_mask = 2
script = ExtResource("1_shop")
[node name="CollisionShape3D" type="CollisionShape3D" parent="FishingShopInteraction"]
[node name="InteractionShape" type="CollisionShape3D" parent="InteractionArea"]
shape = SubResource("InteractionShape")
[node name="EntranceMarker" type="Marker3D" parent="."]
position = Vector3(0, 0, -2)

View file

@ -1,39 +1,46 @@
[gd_scene load_steps=5 format=3]
[gd_scene load_steps=6 format=3]
[ext_resource type="Script" path="res://world/world_prop.gd" id="1_prop"]
[sub_resource type="BoxMesh" id="PerchMesh"]
resource_local_to_scene = true
size = Vector3(5, 0.7, 2.2)
[sub_resource type="CylinderMesh" id="PostMesh"]
resource_local_to_scene = true
top_radius = 0.18
bottom_radius = 0.24
height = 2.5
[sub_resource type="StandardMaterial3D" id="WoodMaterial"]
resource_local_to_scene = true
albedo_color = Color(0.46, 0.28, 0.13, 1)
[sub_resource type="StandardMaterial3D" id="SignMaterial"]
resource_local_to_scene = true
albedo_color = Color(0.95, 0.82, 0.42, 1)
[node name="PelicanCoolerPerch" type="Node3D"]
script = ExtResource("1_prop")
[node name="Visuals" type="Node3D" parent="."]
[node name="Visual" type="Node3D" parent="."]
[node name="Perch" type="MeshInstance3D" parent="Visuals"]
[node name="Perch" type="MeshInstance3D" parent="Visual"]
position = Vector3(0, 1.65, 0)
mesh = SubResource("PerchMesh")
material_override = SubResource("WoodMaterial")
[node name="WestPost" type="MeshInstance3D" parent="Visuals"]
[node name="WestPost" type="MeshInstance3D" parent="Visual"]
position = Vector3(-1.8, 0.7, 0)
mesh = SubResource("PostMesh")
material_override = SubResource("WoodMaterial")
[node name="EastPost" type="MeshInstance3D" parent="Visuals"]
[node name="EastPost" type="MeshInstance3D" parent="Visual"]
position = Vector3(1.8, 0.7, 0)
mesh = SubResource("PostMesh")
material_override = SubResource("WoodMaterial")
[node name="Sign" type="Label3D" parent="Visuals"]
[node name="Sign" type="Label3D" parent="Visual"]
position = Vector3(0, 2.55, 0)
text = "sell to pelicans\nfrom the cooler"
font_size = 30
@ -41,7 +48,7 @@ outline_size = 8
billboard = 1
no_depth_test = true
[node name="ConvenienceLabel" type="Label3D" parent="Visuals"]
[node name="ConvenienceLabel" type="Label3D" parent="Visual"]
position = Vector3(0, 1.65, -1.15)
text = "available anywhere • 0.25x"
font_size = 20

View file

@ -1,9 +1,24 @@
@tool
class_name PlayerWaterTrigger
extends Area3D
signal recovery_requested(player: Player, surface_height: float)
enum SurfaceHeightMode {
EXPLICIT,
PARENT_GLOBAL_Y,
}
@export_group("Surface")
@export var surface_height_mode: SurfaceHeightMode = SurfaceHeightMode.EXPLICIT:
set(value):
surface_height_mode = value
if Engine.is_editor_hint():
notify_property_list_changed()
## Legacy explicit height used when Surface Height Mode is Explicit.
@export var surface_height: float = 0.2
@export_group("Recovery Trigger")
## Recovery begins when the player's body center reaches this depth.
@export_range(0.0, 2.0, 0.05) var entry_depth_threshold: float = 0.35
var _tracked_players: Array[Player] = []
@ -11,11 +26,15 @@ var _triggered_players: Dictionary[StringName, bool] = {}
func _ready() -> void:
if Engine.is_editor_hint():
set_physics_process(false)
return
body_entered.connect(_on_body_entered)
body_exited.connect(_on_body_exited)
func _physics_process(_delta: float) -> void:
var active_surface_height: float = get_surface_height()
for player: Player in _tracked_players.duplicate():
if not is_instance_valid(player):
_tracked_players.erase(player)
@ -25,10 +44,26 @@ func _physics_process(_delta: float) -> void:
continue
if (
player.get_body_center_position().y
<= surface_height - entry_depth_threshold
<= active_surface_height - entry_depth_threshold
):
_triggered_players[player_key] = true
recovery_requested.emit(player, surface_height)
recovery_requested.emit(player, active_surface_height)
func get_surface_height() -> float:
if surface_height_mode == SurfaceHeightMode.PARENT_GLOBAL_Y:
var surface_owner := get_parent() as Node3D
if surface_owner != null:
return surface_owner.global_position.y
return surface_height
func _validate_property(property: Dictionary) -> void:
if (
property.name == "surface_height"
and surface_height_mode == SurfaceHeightMode.PARENT_GLOBAL_Y
):
property.usage = int(property.usage) & ~PROPERTY_USAGE_EDITOR
func _on_body_entered(body: Node3D) -> void:

View file

@ -1,21 +1,28 @@
@tool
class_name StarterIslandRegion
extends GrayboxRegion
extends WorldRegion
const FishingShopInteractionType = preload(
"res://world/fishing_shop_interaction.gd"
)
@export var visual_mesh_path: NodePath = (
^"Visuals/StarterIslandModel/starter_island"
@export_group("Owned Nodes")
@export_node_path("MeshInstance3D")
var visual_mesh_path: NodePath = (
^"Terrain/Visual/starter_island"
)
@export var terrain_collision_shape_path: NodePath = (
^"StaticCollision/Terrain/Shape"
@export_node_path("CollisionShape3D")
var terrain_collision_shape_path: NodePath = (
^"Terrain/Collision/Shape"
)
@export var player_spawn_path: NodePath = ^"PlayerSpawn"
@export var fishing_shop_path: NodePath = (
^"Interactables/FishingShopWorld/FishingShopInteraction"
@export_node_path("Marker3D")
var player_spawn_path: NodePath = ^"PlayerSpawn"
@export_node_path("Area3D")
var fishing_shop_path: NodePath = (
^"Interactables/FishingShopWorld/InteractionArea"
)
@export var pelican_landmark_path: NodePath = (
@export_node_path("Node3D")
var pelican_landmark_path: NodePath = (
^"Interactables/PelicanCoolerPerch"
)
@ -27,6 +34,8 @@ const FishingShopInteractionType = preload(
func _ready() -> void:
_apply_grass_material()
_build_terrain_collision()
if Engine.is_editor_hint():
update_configuration_warnings()
func get_player_spawn_transform() -> Transform3D:
@ -91,3 +100,25 @@ func _build_terrain_collision() -> void:
push_error("Starter island terrain collision could not be created.")
return
collision_shape.shape = terrain_shape
func _get_configuration_warnings() -> PackedStringArray:
var warnings := PackedStringArray()
var visual_mesh := get_node_or_null(visual_mesh_path) as MeshInstance3D
if visual_mesh == null or visual_mesh.mesh == null:
warnings.append("Terrain/Visual must provide the island mesh.")
elif grass_surface_index >= visual_mesh.mesh.get_surface_count():
warnings.append("Grass surface index is outside the terrain mesh.")
elif visual_mesh.mesh.surface_get_name(grass_surface_index) != "grass":
warnings.append("Grass surface index must identify the grass surface.")
if grass_material == null:
warnings.append("Assign the starter-island grass material.")
if get_node_or_null(terrain_collision_shape_path) == null:
warnings.append("Terrain/Collision must provide a collision shape.")
if get_node_or_null(player_spawn_path) == null:
warnings.append("PlayerSpawn marker is missing.")
if get_node_or_null(fishing_shop_path) == null:
warnings.append("Fishing Shop placement is missing.")
if get_node_or_null(pelican_landmark_path) == null:
warnings.append("Pelican placement is missing.")
return warnings

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,60 @@
@tool
class_name WorldRegion
extends Node3D
@export var region_id: StringName
@export var fishable_water_root: NodePath = ^"FishingWater"
@export var water_recovery_root: NodePath = ^"WaterRecovery"
@export var safe_respawns_root: NodePath = ^"SafeRespawns"
func get_fishable_water_regions() -> Array[FishableWaterRegion]:
var regions: Array[FishableWaterRegion] = []
var root: Node = get_node_or_null(fishable_water_root)
if root == null:
return regions
_collect_fishable_water_regions(root, regions)
return regions
func get_water_recovery_triggers() -> Array[PlayerWaterTrigger]:
var triggers: Array[PlayerWaterTrigger] = []
var root: Node = get_node_or_null(water_recovery_root)
if root == null:
return triggers
_collect_water_recovery_triggers(root, triggers)
return triggers
func get_safe_respawn_points() -> Array[SafeRespawnPoint]:
var points: Array[SafeRespawnPoint] = []
var root: Node = get_node_or_null(safe_respawns_root)
if root == null:
return points
for child: Node in root.get_children():
var point: SafeRespawnPoint = child as SafeRespawnPoint
if point != null:
points.append(point)
return points
func _collect_fishable_water_regions(
root: Node,
regions: Array[FishableWaterRegion],
) -> void:
for child: Node in root.get_children():
var water := child as FishableWaterRegion
if water != null:
regions.append(water)
_collect_fishable_water_regions(child, regions)
func _collect_water_recovery_triggers(
root: Node,
triggers: Array[PlayerWaterTrigger],
) -> void:
for child: Node in root.get_children():
var trigger := child as PlayerWaterTrigger
if trigger != null:
triggers.append(trigger)
_collect_water_recovery_triggers(child, triggers)

View file

@ -0,0 +1 @@
uid://bb1j0uhvrbf5n

View file

@ -19,7 +19,7 @@ const FishingShopInteractionType = preload(
func get_player_water_triggers() -> Array[PlayerWaterTrigger]:
var triggers: Array[PlayerWaterTrigger] = []
for region: GrayboxRegion in _get_regions():
for region: WorldRegion in _get_regions():
triggers.append_array(region.get_water_recovery_triggers())
triggers.append(_below_world_failsafe)
return triggers
@ -27,7 +27,7 @@ func get_player_water_triggers() -> Array[PlayerWaterTrigger]:
func get_safe_respawn_points() -> Array[SafeRespawnPoint]:
var points: Array[SafeRespawnPoint] = []
for region: GrayboxRegion in _get_regions():
for region: WorldRegion in _get_regions():
points.append_array(region.get_safe_respawn_points())
return points
@ -42,7 +42,7 @@ func get_player_spawn_transform() -> Transform3D:
func get_fishable_water_regions() -> Array[FishableWaterRegion]:
var waters: Array[FishableWaterRegion] = []
for region: GrayboxRegion in _get_regions():
for region: WorldRegion in _get_regions():
waters.append_array(region.get_fishable_water_regions())
return waters
@ -51,10 +51,10 @@ func get_pelican_convenience_landmark() -> Node3D:
return _starter_island.get_pelican_landmark()
func _get_regions() -> Array[GrayboxRegion]:
var regions: Array[GrayboxRegion] = []
func _get_regions() -> Array[WorldRegion]:
var regions: Array[WorldRegion] = []
for child: Node in _regions_root.get_children():
var region: GrayboxRegion = child as GrayboxRegion
var region: WorldRegion = child as WorldRegion
if region != null:
regions.append(region)
return regions

View file

@ -2,7 +2,7 @@
[ext_resource type="Script" path="res://world/test_world.gd" id="1_world"]
[ext_resource type="PackedScene" path="res://world/regions/starter_island_region.tscn" id="2_island"]
[ext_resource type="Script" path="res://world/player_water_trigger.gd" id="3_water_trigger"]
[ext_resource type="Script" path="res://world/below_world_failsafe.gd" id="3_water_trigger"]
[sub_resource type="Environment" id="Environment"]
background_mode = 1
@ -60,7 +60,7 @@ collision_mask = 2
script = ExtResource("3_water_trigger")
surface_height = 0.3
[node name="FailsafeShape" type="CollisionShape3D" parent="Safety/BelowWorldFailsafe"]
[node name="Coverage" type="CollisionShape3D" parent="Safety/BelowWorldFailsafe"]
shape = SubResource("FailsafeShape")
[node name="WorldBounds" type="Node3D" parent="."]

View file

@ -0,0 +1,158 @@
@tool
class_name WaterBodyAuthoring
extends Node3D
@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()
## Material applied to the visible water surface.
@export var water_material: Material:
set(value):
water_material = value
_sync_owned_nodes()
@export_group("Fishing Coverage")
## Extra fishable coverage beyond each edge of the visible surface.
@export_custom(
PROPERTY_HINT_RANGE,
"0.0,20.0,0.1,or_greater,suffix:m",
)
var fishing_padding: Vector2 = Vector2.ZERO:
set(value):
fishing_padding = Vector2(maxf(value.x, 0.0), maxf(value.y, 0.0))
_sync_owned_nodes()
@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_group("Recovery Coverage")
## Extra recovery coverage beyond each edge of the visible surface.
@export_custom(
PROPERTY_HINT_RANGE,
"0.0,20.0,0.1,or_greater,suffix:m",
)
var recovery_padding: Vector2 = Vector2.ZERO:
set(value):
recovery_padding = Vector2(maxf(value.x, 0.0), maxf(value.y, 0.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 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:
var plane_mesh := visual_water.mesh as PlaneMesh
if plane_mesh != null:
plane_mesh.size = surface_size
visual_water.material_override = water_material
var fishing_shape_node := (
get_node_or_null(fishing_shape_path) as CollisionShape3D
)
if fishing_shape_node != null:
var fishing_shape := fishing_shape_node.shape as BoxShape3D
if fishing_shape != null:
fishing_shape.size = Vector3(
surface_size.x + fishing_padding.x * 2.0,
fishing_depth,
surface_size.y + fishing_padding.y * 2.0
)
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_padding.x * 2.0,
recovery_depth,
surface_size.y + recovery_padding.y * 2.0
)
if Engine.is_editor_hint():
update_configuration_warnings()
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 not visual_water.mesh is PlaneMesh:
warnings.append("VisualWater must provide a PlaneMesh.")
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."
)
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 := (
fishing_shape.get_parent() as FishableWaterRegion
if fishing_shape != null
else null
)
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.")
return warnings

View file

@ -0,0 +1 @@
uid://dtcgnxl1kmdu1

62
world/world_prop.gd Normal file
View file

@ -0,0 +1,62 @@
@tool
class_name WorldProp
extends Node3D
@export_group("Owned Nodes")
@export_node_path("Node3D")
var visual_root_path: NodePath = ^"Visual"
@export_node_path("Node3D")
var collision_root_path: NodePath
@export_node_path("Area3D")
var interaction_area_path: NodePath
@export_node_path("Marker3D")
var entrance_marker_path: NodePath
@export_group("Scaling")
## Enable only when this prop explicitly supports non-uniform root scaling.
@export var allow_non_uniform_scale: bool = false
func _ready() -> void:
if not Engine.is_editor_hint():
return
set_notify_transform(true)
update_configuration_warnings()
func _notification(what: int) -> void:
if Engine.is_editor_hint() and what == NOTIFICATION_TRANSFORM_CHANGED:
update_configuration_warnings()
func _get_configuration_warnings() -> PackedStringArray:
var warnings := PackedStringArray()
if get_node_or_null(visual_root_path) == null:
warnings.append("Assign a valid Visual root.")
if (
not collision_root_path.is_empty()
and get_node_or_null(collision_root_path) == null
):
warnings.append("Assign a valid Collision root.")
if (
not interaction_area_path.is_empty()
and get_node_or_null(interaction_area_path) == null
):
warnings.append("Assign a valid InteractionArea.")
if (
not entrance_marker_path.is_empty()
and get_node_or_null(entrance_marker_path) == null
):
warnings.append("Assign a valid EntranceMarker.")
if not allow_non_uniform_scale and not _has_uniform_scale():
warnings.append(
"Use uniform root scale so visuals and collision stay aligned."
)
return warnings
func _has_uniform_scale() -> bool:
return (
is_equal_approx(scale.x, scale.y)
and is_equal_approx(scale.y, scale.z)
)

1
world/world_prop.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://c1p4583452rpa