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:
1 parent
1c1f0b87da
commit
506f6dd0c8
5 files changed
+255
-2
No files matched your search
@@ -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))
|
||||||
@@ -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 ""
|
||||||
@@ -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 ""
|
||||||
@@ -12,7 +12,8 @@ var _maximum_elevation_m := 0.0
|
|||||||
static func from_data(data: SiteTerrainData) -> TerrainHeightField:
|
static func from_data(data: SiteTerrainData) -> TerrainHeightField:
|
||||||
if data == null or not data.validation_error().is_empty():
|
if data == null or not data.validation_error().is_empty():
|
||||||
return null
|
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._data = data
|
||||||
field._minimum_elevation_m = data.elevations_m[0]
|
field._minimum_elevation_m = data.elevations_m[0]
|
||||||
field._maximum_elevation_m = data.elevations_m[0]
|
field._maximum_elevation_m = data.elevations_m[0]
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ extends SceneTree
|
|||||||
const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd")
|
const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd")
|
||||||
const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd")
|
const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd")
|
||||||
const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.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")
|
||||||
|
const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd")
|
||||||
|
|
||||||
const TEST_COUNT := 20
|
const TEST_COUNT := 38
|
||||||
|
|
||||||
var _failures: Array[String] = []
|
var _failures: Array[String] = []
|
||||||
|
|
||||||
@@ -12,6 +15,7 @@ var _failures: Array[String] = []
|
|||||||
func _init() -> void:
|
func _init() -> void:
|
||||||
_test_terrain_data_validation()
|
_test_terrain_data_validation()
|
||||||
_test_height_field_sampling()
|
_test_height_field_sampling()
|
||||||
|
_test_site_definition_validation()
|
||||||
|
|
||||||
if _failures.is_empty():
|
if _failures.is_empty():
|
||||||
print("Site domain unit tests: %d passed, 0 failed." % TEST_COUNT)
|
print("Site domain unit tests: %d passed, 0 failed." % TEST_COUNT)
|
||||||
@@ -95,6 +99,102 @@ func _test_height_field_sampling() -> void:
|
|||||||
_expect(is_equal_approx(field.reflectivity_for(SonarMaterial.Value.SAND), 0.45), "sand reflectivity comes from the data")
|
_expect(is_equal_approx(field.reflectivity_for(SonarMaterial.Value.SAND), 0.45), "sand reflectivity comes from the data")
|
||||||
|
|
||||||
|
|
||||||
|
func _make_placement(id: String, kind: int, position: Vector3) -> SitePlacement:
|
||||||
|
var placement := SitePlacement.new()
|
||||||
|
placement.placement_id = StringName(id)
|
||||||
|
placement.kind = kind
|
||||||
|
placement.position_m = position
|
||||||
|
placement.footprint_m = Vector3(4.0, 2.0, 3.0)
|
||||||
|
return placement
|
||||||
|
|
||||||
|
|
||||||
|
func _make_route(id: String, waypoints: PackedVector3Array, maximum_depth: float) -> SiteRoute:
|
||||||
|
var route := SiteRoute.new()
|
||||||
|
route.route_id = StringName(id)
|
||||||
|
route.waypoints_m = waypoints
|
||||||
|
route.maximum_depth_m = maximum_depth
|
||||||
|
return route
|
||||||
|
|
||||||
|
|
||||||
|
func _make_definition() -> DiveSiteDefinition:
|
||||||
|
var definition := DiveSiteDefinition.new()
|
||||||
|
definition.site_id = &"test_site"
|
||||||
|
definition.display_name = "Site de test"
|
||||||
|
definition.terrain = _make_terrain(3, 4)
|
||||||
|
definition.playable_bounds_m = Rect2(-10.0, 100.0, 4.0, 6.0)
|
||||||
|
definition.spawn_position_m = Vector3(-8.0, -10.0, 102.0)
|
||||||
|
definition.spawn_yaw_radians = PI * 0.5
|
||||||
|
var placements_list: Array[SitePlacement] = [
|
||||||
|
_make_placement("target", SitePlacement.Kind.WRECK_TARGET, Vector3(-8.0, -23.0, 103.0)),
|
||||||
|
_make_placement("anchor", SitePlacement.Kind.VESSEL_ANCHOR, Vector3(-8.0, -20.0, 100.5)),
|
||||||
|
]
|
||||||
|
definition.placements = placements_list
|
||||||
|
var routes_list: Array[SiteRoute] = [
|
||||||
|
_make_route(
|
||||||
|
"safe",
|
||||||
|
PackedVector3Array([Vector3(-8.0, -12.0, 101.0), Vector3(-8.0, -14.0, 103.0)]),
|
||||||
|
20.0
|
||||||
|
),
|
||||||
|
]
|
||||||
|
definition.routes = routes_list
|
||||||
|
return definition
|
||||||
|
|
||||||
|
|
||||||
|
func _test_site_definition_validation() -> void:
|
||||||
|
var definition := _make_definition()
|
||||||
|
_expect(definition.validation_error().is_empty(), "complete definition is valid")
|
||||||
|
_expect(definition.height_field() != null, "definition exposes its height field")
|
||||||
|
_expect(definition.placement(&"target").kind == SitePlacement.Kind.WRECK_TARGET, "placement lookup by id")
|
||||||
|
_expect(definition.placement(&"missing") == null, "unknown placement is null")
|
||||||
|
_expect(definition.placements_of_kind(SitePlacement.Kind.VESSEL_ANCHOR).size() == 1, "placements filter by kind")
|
||||||
|
_expect(definition.route(&"safe").waypoints_m.size() == 2, "route lookup by id")
|
||||||
|
_expect(definition.spawn_transform().origin.is_equal_approx(Vector3(-8.0, -10.0, 102.0)), "spawn transform origin")
|
||||||
|
_expect(
|
||||||
|
(definition.spawn_transform().basis * Vector3.FORWARD).is_equal_approx(Vector3.LEFT),
|
||||||
|
"spawn yaw rotates the forward axis about Y"
|
||||||
|
)
|
||||||
|
_expect(definition.placement(&"target").is_sonar_metal(), "wrecks read as metal on sonar")
|
||||||
|
_expect(not definition.placement(&"anchor").is_sonar_metal(), "anchor is not a sonar body")
|
||||||
|
|
||||||
|
var no_id := _make_definition()
|
||||||
|
no_id.site_id = &""
|
||||||
|
_expect(not no_id.validation_error().is_empty(), "site id is required")
|
||||||
|
|
||||||
|
var no_terrain := _make_definition()
|
||||||
|
no_terrain.terrain = null
|
||||||
|
_expect(not no_terrain.validation_error().is_empty(), "terrain is required")
|
||||||
|
|
||||||
|
var bounds_outside := _make_definition()
|
||||||
|
bounds_outside.playable_bounds_m = Rect2(-20.0, 100.0, 4.0, 6.0)
|
||||||
|
_expect(not bounds_outside.validation_error().is_empty(), "playable bounds must lie inside the terrain")
|
||||||
|
|
||||||
|
var spawn_outside := _make_definition()
|
||||||
|
spawn_outside.spawn_position_m = Vector3(50.0, -10.0, 102.0)
|
||||||
|
_expect(not spawn_outside.validation_error().is_empty(), "spawn must lie inside the bounds")
|
||||||
|
|
||||||
|
var duplicate_ids := _make_definition()
|
||||||
|
duplicate_ids.placements.append(
|
||||||
|
_make_placement("target", SitePlacement.Kind.WRECK_DECOY, Vector3(-7.0, -23.0, 104.0))
|
||||||
|
)
|
||||||
|
_expect(not duplicate_ids.validation_error().is_empty(), "placement ids are unique")
|
||||||
|
|
||||||
|
var placement_outside := _make_definition()
|
||||||
|
placement_outside.placements.append(
|
||||||
|
_make_placement("far", SitePlacement.Kind.DEBRIS, Vector3(80.0, -23.0, 104.0))
|
||||||
|
)
|
||||||
|
_expect(not placement_outside.validation_error().is_empty(), "placements must lie inside the bounds")
|
||||||
|
|
||||||
|
var empty_route := _make_definition()
|
||||||
|
empty_route.routes.append(_make_route("empty", PackedVector3Array(), 20.0))
|
||||||
|
_expect(not empty_route.validation_error().is_empty(), "routes need at least two waypoints")
|
||||||
|
|
||||||
|
var deep_route := _make_definition()
|
||||||
|
deep_route.routes.append(
|
||||||
|
_make_route("deep", PackedVector3Array([Vector3(-8.0, -12.0, 101.0), Vector3(-8.0, -30.0, 103.0)]), 20.0)
|
||||||
|
)
|
||||||
|
_expect(not deep_route.validation_error().is_empty(), "route waypoints respect the route maximum depth")
|
||||||
|
|
||||||
|
|
||||||
func _expect(condition: bool, label: String) -> void:
|
func _expect(condition: bool, label: String) -> void:
|
||||||
if not condition:
|
if not condition:
|
||||||
_failures.append(label)
|
_failures.append(label)
|
||||||
Reference in new issue
Block a user