From 506f6dd0c8d6b9a42abe93622cf6d381cd6c18bd Mon Sep 17 00:00:00 2001 From: Olivier Date: Tue, 6 Oct 2026 19:52:24 +0200 Subject: [PATCH] feat(sites): typed dive site definition with placements and routes Co-Authored-By: Claude Haiku 4.5 --- game/domain/sites/dive_site_definition.gd | 96 ++++++++++++++++++++ game/domain/sites/site_placement.gd | 35 ++++++++ game/domain/sites/site_route.gd | 21 +++++ game/domain/sites/terrain_height_field.gd | 3 +- tests/unit/run_site_domain_tests.gd | 102 +++++++++++++++++++++- 5 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 game/domain/sites/dive_site_definition.gd create mode 100644 game/domain/sites/site_placement.gd create mode 100644 game/domain/sites/site_route.gd diff --git a/game/domain/sites/dive_site_definition.gd b/game/domain/sites/dive_site_definition.gd new file mode 100644 index 0000000..3142eda --- /dev/null +++ b/game/domain/sites/dive_site_definition.gd @@ -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)) diff --git a/game/domain/sites/site_placement.gd b/game/domain/sites/site_placement.gd new file mode 100644 index 0000000..1c05afc --- /dev/null +++ b/game/domain/sites/site_placement.gd @@ -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 "" diff --git a/game/domain/sites/site_route.gd b/game/domain/sites/site_route.gd new file mode 100644 index 0000000..a2959e9 --- /dev/null +++ b/game/domain/sites/site_route.gd @@ -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 "" diff --git a/game/domain/sites/terrain_height_field.gd b/game/domain/sites/terrain_height_field.gd index 7866b17..fcd7ed6 100644 --- a/game/domain/sites/terrain_height_field.gd +++ b/game/domain/sites/terrain_height_field.gd @@ -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] diff --git a/tests/unit/run_site_domain_tests.gd b/tests/unit/run_site_domain_tests.gd index 225d3e3..6715204 100644 --- a/tests/unit/run_site_domain_tests.gd +++ b/tests/unit/run_site_domain_tests.gd @@ -3,8 +3,11 @@ extends SceneTree const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") 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") +const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") -const TEST_COUNT := 20 +const TEST_COUNT := 38 var _failures: Array[String] = [] @@ -12,6 +15,7 @@ var _failures: Array[String] = [] func _init() -> void: _test_terrain_data_validation() _test_height_field_sampling() + _test_site_definition_validation() if _failures.is_empty(): 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") +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: if not condition: _failures.append(label)