diff --git a/content/sites/vehicle_test.tres b/content/sites/vehicle_test.tres new file mode 100644 index 0000000..2d2df84 --- /dev/null +++ b/content/sites/vehicle_test.tres @@ -0,0 +1,28 @@ +[gd_resource type="Resource" script_class="DiveSiteDefinition" format=3] + +[ext_resource type="Script" path="res://game/domain/sites/site_placement.gd" id="1_j2ii4"] +[ext_resource type="Script" path="res://game/domain/sites/site_route.gd" id="2_tj3be"] +[ext_resource type="Script" path="res://game/domain/sites/dive_site_definition.gd" id="3_usfet"] +[ext_resource type="Resource" path="res://content/sites/vehicle_test_terrain.res" id="4_awe7g"] + +[sub_resource type="Resource" id="Resource_5juth"] +script = ExtResource("1_j2ii4") +placement_id = &"calibration_target" +kind = 1 +position_m = Vector3(0, -60.44367, -58) +footprint_m = Vector3(8, 5, 7) + +[sub_resource type="Resource" id="Resource_tboiu"] +script = ExtResource("1_j2ii4") +placement_id = &"navigation_beacon" +kind = 5 +position_m = Vector3(0, -15, -22) + +[resource] +script = ExtResource("3_usfet") +site_id = &"vehicle_test" +display_name = "Site de calibration du véhicule" +terrain = ExtResource("4_awe7g") +playable_bounds_m = Rect2(-90, -176, 180, 204) +spawn_position_m = Vector3(0, -12, 12) +placements = Array[ExtResource("1_j2ii4")]([SubResource("Resource_5juth"), SubResource("Resource_tboiu")]) diff --git a/content/sites/vehicle_test_terrain.res b/content/sites/vehicle_test_terrain.res index 496f48d..02a7762 100644 Binary files a/content/sites/vehicle_test_terrain.res and b/content/sites/vehicle_test_terrain.res differ diff --git a/game/world/sites/dive_site.gd b/game/world/sites/dive_site.gd new file mode 100644 index 0000000..841d86d --- /dev/null +++ b/game/world/sites/dive_site.gd @@ -0,0 +1,113 @@ +class_name DiveSite +extends Node3D + +const SonarMaterial = preload("res://game/domain/sonar/sonar_material.gd") +const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") +const SitePlacement = preload("res://game/domain/sites/site_placement.gd") +const BathymetricTerrainActor = preload( + "res://game/world/sites/bathymetric_terrain_actor.gd" +) + +const METAL_REFLECTIVITY := 1.0 +const BEACON_COLOR := Color(0.2, 0.78, 0.9) + +@onready var _terrain: BathymetricTerrainActor = %Terrain +@onready var _placements_root: Node3D = %Placements + +var _definition: DiveSiteDefinition +var _placeholder_count := 0 + + +func configure(definition: DiveSiteDefinition) -> String: + if definition == null: + return "dive site definition is missing" + var error := definition.validation_error() + if not error.is_empty(): + return error + var terrain_error := _terrain.configure(definition.height_field()) + if not terrain_error.is_empty(): + return terrain_error + _definition = definition + for child in _placements_root.get_children(): + _placements_root.remove_child(child) + child.free() + _placeholder_count = 0 + for placement: SitePlacement in definition.placements: + _place(placement) + return "" + + +func definition() -> DiveSiteDefinition: + return _definition + + +func terrain() -> BathymetricTerrainActor: + return _terrain + + +func spawn_transform() -> Transform3D: + return _definition.spawn_transform() if _definition != null else Transform3D.IDENTITY + + +func placeholder_count() -> int: + return _placeholder_count + + +func _place(placement: SitePlacement) -> void: + if placement.is_sonar_metal(): + _placements_root.add_child(_metal_placeholder(placement)) + _placeholder_count += 1 + elif placement.kind == SitePlacement.Kind.VESSEL_ANCHOR: + _placements_root.add_child(_beacon(placement)) + _placeholder_count += 1 + + +func _metal_placeholder(placement: SitePlacement) -> StaticBody3D: + var body := StaticBody3D.new() + body.name = String(placement.placement_id) + body.position = placement.position_m + body.rotation.y = placement.yaw_radians + body.set_meta("sonar_material", SonarMaterial.Value.METAL) + body.set_meta("sonar_reflectivity", METAL_REFLECTIVITY) + var mesh := BoxMesh.new() + mesh.size = placement.footprint_m + var mesh_instance := MeshInstance3D.new() + mesh_instance.name = "Mesh" + mesh_instance.mesh = mesh + var material := StandardMaterial3D.new() + material.albedo_color = Color(0.24, 0.18, 0.08) + material.metallic = 0.88 + material.roughness = 0.27 + mesh_instance.material_override = material + body.add_child(mesh_instance) + var shape := BoxShape3D.new() + shape.size = placement.footprint_m + var collision := CollisionShape3D.new() + collision.name = "Collision" + collision.shape = shape + body.add_child(collision) + return body + + +func _beacon(placement: SitePlacement) -> MeshInstance3D: + var mesh := SphereMesh.new() + mesh.radius = 0.55 + mesh.height = 1.1 + var beacon := MeshInstance3D.new() + beacon.name = String(placement.placement_id) + beacon.mesh = mesh + beacon.position = placement.position_m + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.albedo_color = BEACON_COLOR + material.emission_enabled = true + material.emission = BEACON_COLOR + material.emission_energy_multiplier = 5.0 + beacon.material_override = material + var light := OmniLight3D.new() + light.name = "BeaconLight" + light.omni_range = 12.0 + light.light_color = BEACON_COLOR + light.light_energy = 4.0 + beacon.add_child(light) + return beacon diff --git a/game/world/sites/dive_site.tscn b/game/world/sites/dive_site.tscn new file mode 100644 index 0000000..c23fe17 --- /dev/null +++ b/game/world/sites/dive_site.tscn @@ -0,0 +1,47 @@ +[gd_scene load_steps=4 format=3] + +[ext_resource type="Script" path="res://game/world/sites/dive_site.gd" id="1_site"] +[ext_resource type="Script" path="res://game/world/sites/bathymetric_terrain_actor.gd" id="2_terrain"] + +[sub_resource type="Environment" id="EnvironmentUnderwater"] +background_mode = 1 +background_color = Color(0.005, 0.045, 0.065, 1) +ambient_light_source = 3 +ambient_light_color = Color(0.12, 0.32, 0.37, 1) +ambient_light_energy = 0.42 +glow_enabled = true +glow_levels/1 = 1.0 +glow_levels/2 = 1.0 +glow_levels/3 = 0.6 +glow_levels/4 = 0.2 +glow_intensity = 1.0 +glow_blend_mode = 0 +glow_hdr_threshold = 1.0 +fog_enabled = true +fog_light_color = Color(0.07, 0.25, 0.3, 1) +fog_light_energy = 0.48 +fog_density = 0.014 +fog_height = -4.0 +fog_height_density = 0.025 +volumetric_fog_enabled = true +volumetric_fog_density = 0.025 +volumetric_fog_length = 72.0 + +[node name="DiveSite" type="Node3D"] +script = ExtResource("1_site") + +[node name="WorldEnvironment" type="WorldEnvironment" parent="."] +environment = SubResource("EnvironmentUnderwater") + +[node name="AmbientDirection" type="DirectionalLight3D" parent="."] +rotation_degrees = Vector3(-52, -24, 0) +light_color = Color(0.39, 0.65, 0.7, 1) +light_energy = 0.38 +shadow_enabled = true + +[node name="Terrain" type="StaticBody3D" parent="."] +unique_name_in_owner = true +script = ExtResource("2_terrain") + +[node name="Placements" type="Node3D" parent="."] +unique_name_in_owner = true diff --git a/game/world/sonar/reference_bathymetry_acquisition.gd b/game/world/sonar/reference_bathymetry_acquisition.gd index 13267ab..fb3d5f3 100644 --- a/game/world/sonar/reference_bathymetry_acquisition.gd +++ b/game/world/sonar/reference_bathymetry_acquisition.gd @@ -68,10 +68,16 @@ func capture(request: SonarCaptureRequest) -> SonarCaptureResult: var material := SonarMaterial.Value.ROCK var reflectivity := 0.72 if collider != null: - material = int(collider.get_meta("sonar_material", material)) - reflectivity = float( - collider.get_meta("sonar_reflectivity", reflectivity) - ) + if collider.has_method("sonar_material_at"): + material = int(collider.call("sonar_material_at", world_position)) + reflectivity = float( + collider.call("sonar_reflectivity_at", world_position) + ) + else: + material = int(collider.get_meta("sonar_material", material)) + reflectivity = float( + collider.get_meta("sonar_reflectivity", reflectivity) + ) var incidence := clampf(absf(world_normal.dot(Vector3.UP)), 0.18, 1.0) var attenuation := lerpf(1.0, 0.55, distance_ratio) var intensity := clampf(incidence * reflectivity * attenuation, 0.0, 1.0) diff --git a/tests/integration/run_site_terrain_integration.gd b/tests/integration/run_site_terrain_integration.gd index 399c1cc..ab87e89 100644 --- a/tests/integration/run_site_terrain_integration.gd +++ b/tests/integration/run_site_terrain_integration.gd @@ -6,8 +6,16 @@ const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd") const BathymetricTerrainActor = preload( "res://game/world/sites/bathymetric_terrain_actor.gd" ) +const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") +const SitePlacement = preload("res://game/domain/sites/site_placement.gd") +const DiveSite = preload("res://game/world/sites/dive_site.gd") +const SonarCaptureRequest = preload("res://game/application/sonar/sonar_capture_request.gd") +const ReferenceBathymetryAcquisition = preload( + "res://game/world/sonar/reference_bathymetry_acquisition.gd" +) +const DIVE_SITE_SCENE = preload("res://game/world/sites/dive_site.tscn") -const TEST_COUNT := 12 +const TEST_COUNT := 23 var _failures: Array[String] = [] @@ -18,6 +26,7 @@ func _init() -> void: func _run() -> void: await _test_actor_matches_field() + await _test_dive_site_scene() var exit_code := 0 if _failures.is_empty(): @@ -104,3 +113,39 @@ func _collision_height(space: PhysicsDirectSpaceState3D, x: float, z: float) -> func _expect(condition: bool, label: String) -> void: if not condition: _failures.append(label) + + +func _test_dive_site_scene() -> void: + var definition := load("res://content/sites/vehicle_test.tres") as DiveSiteDefinition + _expect(definition != null, "calibration site definition loads") + if definition == null: + return + _expect(definition.validation_error().is_empty(), "calibration site definition is valid") + var started_usec := Time.get_ticks_usec() + var site := DIVE_SITE_SCENE.instantiate() as DiveSite + root.add_child(site) + _expect(site.configure(definition).is_empty(), "dive site scene accepts the definition") + var configure_ms := float(Time.get_ticks_usec() - started_usec) / 1000.0 + _expect(configure_ms < 3000.0, "site configures under the 3 s load budget (%.0f ms)" % configure_ms) + await physics_frame + await physics_frame + _expect(site.terrain().tile_count() > 0, "site builds its terrain tiles") + _expect(site.placeholder_count() == 2, "calibration target and beacon are placed") + var target := site.get_node_or_null("%Placements/calibration_target") as StaticBody3D + _expect(target != null and int(target.get_meta("sonar_material")) == SonarMaterial.Value.METAL, "wreck placeholder is a metal sonar body") + _expect(site.spawn_transform().origin.is_equal_approx(Vector3(0.0, -12.0, 12.0)), "spawn comes from the definition") + + var sensor := Transform3D(Basis(Vector3.UP, 0.0), Vector3(0.0, -12.0, 10.0)) + sensor.basis = Basis(sensor.basis.x, sensor.basis.y, -sensor.basis.z) + var acquisition := ReferenceBathymetryAcquisition.new(site.get_world_3d()) + var request := SonarCaptureRequest.new( + &"scan", &"session", sensor, 5.0, 95.0, deg_to_rad(76.0), deg_to_rad(64.0), 49, 31 + ) + var result = acquisition.capture(request) + _expect(result.accepted, "reference acquisition scans the baked terrain") + var materials: Dictionary = {} + for sample in result.samples: + materials[sample.material] = true + _expect(materials.has(SonarMaterial.Value.SAND) and materials.has(SonarMaterial.Value.ROCK), "acquisition reports per-cell sand and rock") + _expect(materials.has(SonarMaterial.Value.METAL), "acquisition still reports the metal placeholder") + site.queue_free() diff --git a/tools/bake_site_terrain.gd b/tools/bake_site_terrain.gd index 6a69b3f..2b1bae3 100644 --- a/tools/bake_site_terrain.gd +++ b/tools/bake_site_terrain.gd @@ -31,17 +31,32 @@ func _init() -> void: DirAccess.make_dir_recursive_absolute(OUTPUT_DIRECTORY) DirAccess.make_dir_recursive_absolute(PREVIEW_DIRECTORY) var output_path := "%s/%s_terrain.res" % [OUTPUT_DIRECTORY, name] - var save_error := ResourceSaver.save(data, output_path) + var save_error := ResourceSaver.save(data, output_path, ResourceSaver.FLAG_CHANGE_PATH) if save_error != OK: push_error("cannot save %s (%d)" % [output_path, save_error]) quit(1) return + # ResourceSaver restores the previous path after saving, so the data must + # take over the baked path explicitly for the definition to reference it. + data.take_over_path(output_path) var preview_path := "%s/%s_terrain_preview.png" % [PREVIEW_DIRECTORY, name] var preview_error := SiteTerrainBaker.write_preview_png(data, preview_path) if preview_error != OK: push_error("cannot write %s (%d)" % [preview_path, preview_error]) quit(1) return + var definition = recipe_script.definition(data) + var definition_error: String = definition.validation_error() + if not definition_error.is_empty(): + push_error("recipe %s definition is invalid: %s" % [name, definition_error]) + quit(1) + return + var definition_path := "%s/%s.tres" % [OUTPUT_DIRECTORY, name] + var definition_save_error := ResourceSaver.save(definition, definition_path) + if definition_save_error != OK: + push_error("cannot save %s (%d)" % [definition_path, definition_save_error]) + quit(1) + return var rock_cells := 0 for material: int in data.materials: if material == SiteTerrainBaker.SonarMaterial.Value.ROCK: diff --git a/tools/sites/vehicle_test_terrain_recipe.gd b/tools/sites/vehicle_test_terrain_recipe.gd index b5baf29..b207867 100644 --- a/tools/sites/vehicle_test_terrain_recipe.gd +++ b/tools/sites/vehicle_test_terrain_recipe.gd @@ -2,6 +2,10 @@ class_name VehicleTestTerrainRecipe extends RefCounted const SiteTerrainBaker = preload("res://tools/site_terrain_baker.gd") +const SiteTerrainData = preload("res://game/domain/sites/site_terrain_data.gd") +const TerrainHeightField = preload("res://game/domain/sites/terrain_height_field.gd") +const DiveSiteDefinition = preload("res://game/domain/sites/dive_site_definition.gd") +const SitePlacement = preload("res://game/domain/sites/site_placement.gd") static func recipe() -> SiteTerrainBaker.Recipe: @@ -23,3 +27,25 @@ static func recipe() -> SiteTerrainBaker.Recipe: SiteTerrainBaker.Ripples.new(Vector2(0.0, 1.0), TAU / 0.048, 0.9, Vector2.ZERO, 0.0), ] return result + + +static func definition(terrain: SiteTerrainData) -> DiveSiteDefinition: + var field := TerrainHeightField.from_data(terrain) + var result := DiveSiteDefinition.new() + result.site_id = &"vehicle_test" + result.display_name = "Site de calibration du véhicule" + result.terrain = terrain + result.playable_bounds_m = Rect2(-90.0, -176.0, 180.0, 204.0) + result.spawn_position_m = Vector3(0.0, -12.0, 12.0) + result.spawn_yaw_radians = 0.0 + var target := SitePlacement.new() + target.placement_id = &"calibration_target" + target.kind = SitePlacement.Kind.WRECK_DECOY + target.position_m = Vector3(0.0, field.elevation_at(0.0, -58.0) + 2.5, -58.0) + target.footprint_m = Vector3(8.0, 5.0, 7.0) + var anchor := SitePlacement.new() + anchor.placement_id = &"navigation_beacon" + anchor.kind = SitePlacement.Kind.VESSEL_ANCHOR + anchor.position_m = Vector3(0.0, -15.0, -22.0) + result.placements = [target, anchor] + return result