feat(sites): typed dive site definition with placements and routes

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
OlivierandClaude Haiku 4.5 committed 2026-10-06 19:52:24 +02:00
1 parent 1c1f0b87da
commit 506f6dd0c8
5 files changed
+255 -2

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
class_name DiveSiteDefinition
extends Resource
const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd")
const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd")
const SitePlacement = preload("res://game/domain/sites/site_placement.gd")
const SiteRoute = preload("res://game/domain/sites/site_route.gd")
@export var site_id: StringName
@export var display_name: String = ""
@export var terrain: SiteTerrainData
## Metres, world XZ: position = (x_min, z_min), size = (width along +X, depth along +Z).
@export var playable_bounds_m: Rect2 = Rect2()
@export var spawn_position_m: Vector3 = Vector3.ZERO
@export var spawn_yaw_radians: float = 0.0
@export var placements: Array[SitePlacement] = []
@export var routes: Array[SiteRoute] = []
func height_field() -> TerrainHeightField:
return TerrainHeightField.from_data(terrain)
func spawn_transform() -> Transform3D:
return Transform3D(Basis(Vector3.UP, spawn_yaw_radians), spawn_position_m)
func placement(placement_id: StringName) -> SitePlacement:
for candidate: SitePlacement in placements:
if candidate != null and candidate.placement_id == placement_id:
return candidate
return null
func placements_of_kind(kind: SitePlacement.Kind) -> Array[SitePlacement]:
var result: Array[SitePlacement] = []
for candidate: SitePlacement in placements:
if candidate != null and candidate.kind == kind:
result.append(candidate)
return result
func route(route_id: StringName) -> SiteRoute:
for candidate: SiteRoute in routes:
if candidate != null and candidate.route_id == route_id:
return candidate
return null
func validation_error() -> String:
if site_id.is_empty():
return "site id is empty"
if terrain == null:
return "site terrain is missing"
var terrain_error := terrain.validation_error()
if not terrain_error.is_empty():
return terrain_error
var field := TerrainHeightField.from_data(terrain)
if playable_bounds_m.size.x <= 0.0 or playable_bounds_m.size.y <= 0.0:
return "playable bounds must have a positive size"
if not field.bounds_m().encloses(playable_bounds_m):
return "playable bounds must lie inside the terrain"
if not _inside_bounds(spawn_position_m):
return "spawn position must lie inside the playable bounds"
if not is_finite(spawn_yaw_radians):
return "spawn yaw must be finite"
var seen_ids: Dictionary = {}
for candidate: SitePlacement in placements:
if candidate == null:
return "placement entry is null"
var placement_error := candidate.validation_error()
if not placement_error.is_empty():
return placement_error
if seen_ids.has(candidate.placement_id):
return "placement id %s is duplicated" % candidate.placement_id
seen_ids[candidate.placement_id] = true
if not _inside_bounds(candidate.position_m):
return "placement %s lies outside the playable bounds" % candidate.placement_id
var seen_routes: Dictionary = {}
for candidate_route: SiteRoute in routes:
if candidate_route == null:
return "route entry is null"
var route_error := candidate_route.validation_error()
if not route_error.is_empty():
return route_error
if seen_routes.has(candidate_route.route_id):
return "route id %s is duplicated" % candidate_route.route_id
seen_routes[candidate_route.route_id] = true
for waypoint: Vector3 in candidate_route.waypoints_m:
if not _inside_bounds(waypoint):
return "route %s leaves the playable bounds" % candidate_route.route_id
return ""
func _inside_bounds(position: Vector3) -> bool:
return playable_bounds_m.has_point(Vector2(position.x, position.z))
+35
View File
@@ -0,0 +1,35 @@
class_name SitePlacement
extends Resource
enum Kind {
WRECK_TARGET,
WRECK_DECOY,
SECONDARY_ITEM,
DEBRIS,
LANDMARK,
VESSEL_ANCHOR,
}
@export var placement_id: StringName
@export var kind: Kind = Kind.LANDMARK
@export var position_m: Vector3 = Vector3.ZERO
@export var yaw_radians: float = 0.0
@export var footprint_m: Vector3 = Vector3.ZERO
func is_sonar_metal() -> bool:
return kind in [Kind.WRECK_TARGET, Kind.WRECK_DECOY, Kind.SECONDARY_ITEM, Kind.DEBRIS]
func validation_error() -> String:
if placement_id.is_empty():
return "placement id is empty"
if not (is_finite(position_m.x) and is_finite(position_m.y) and is_finite(position_m.z)):
return "placement position must be finite"
if not is_finite(yaw_radians):
return "placement yaw must be finite"
if footprint_m.x < 0.0 or footprint_m.y < 0.0 or footprint_m.z < 0.0:
return "placement footprint must not be negative"
if is_sonar_metal() and footprint_m.length_squared() == 0.0:
return "sonar bodies need a footprint"
return ""
+21
View File
@@ -0,0 +1,21 @@
class_name SiteRoute
extends Resource
@export var route_id: StringName
@export var waypoints_m := PackedVector3Array()
@export var maximum_depth_m: float = 0.0
func validation_error() -> String:
if route_id.is_empty():
return "route id is empty"
if waypoints_m.size() < 2:
return "route needs at least two waypoints"
if not is_finite(maximum_depth_m) or maximum_depth_m <= 0.0:
return "route maximum depth must be positive"
for waypoint: Vector3 in waypoints_m:
if not (is_finite(waypoint.x) and is_finite(waypoint.y) and is_finite(waypoint.z)):
return "route waypoints must be finite"
if -waypoint.y > maximum_depth_m:
return "route waypoint exceeds the route maximum depth"
return ""
+2 -1
View File
@@ -12,7 +12,8 @@ var _maximum_elevation_m := 0.0
static func from_data(data: SiteTerrainData) -> TerrainHeightField:
if data == null or not data.validation_error().is_empty():
return null
var field := TerrainHeightField.new()
var script_class = load("res://game/domain/sites/terrain_height_field.gd")
var field: TerrainHeightField = script_class.new()
field._data = data
field._minimum_elevation_m = data.elevations_m[0]
field._maximum_elevation_m = data.elevations_m[0]