From e50745dbddd0a1bfa3a4490d2307f0e2382dd005 Mon Sep 17 00:00:00 2001 From: nathan Date: Sat, 16 May 2026 10:44:08 -0700 Subject: [PATCH] Validate Ground Zero safe spawn area --- AGRARIAN_DEVELOPMENT_ROADMAP.md | 2 +- .../Maps/L_GroundZeroTerrain_Test.umap | 4 +- Docs/TechnicalDesignDocument.md | 9 ++ Scripts/setup_ground_zero_demo_map.py | 93 ++++++++++- Scripts/verify_ground_zero_safe_spawn.py | 148 ++++++++++++++++++ 5 files changed, 251 insertions(+), 5 deletions(-) create mode 100644 Scripts/verify_ground_zero_safe_spawn.py diff --git a/AGRARIAN_DEVELOPMENT_ROADMAP.md b/AGRARIAN_DEVELOPMENT_ROADMAP.md index 79840b8..862a6b5 100644 --- a/AGRARIAN_DEVELOPMENT_ROADMAP.md +++ b/AGRARIAN_DEVELOPMENT_ROADMAP.md @@ -461,7 +461,7 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe - [x] Replace `LevelPrototyping` cube/cylinder mesh dependencies in Agrarian setup scripts and prototype Blueprints with Agrarian-native placeholder environment meshes. Added repeatable Agrarian-native placeholder mesh assets under `/Game/Agrarian/Environment/PlaceholderMeshes`, updated playable Blueprint and Ground Zero setup scripts to use those native paths, regenerated affected Blueprints/map content, and added verification that Blueprint, foliage, and variation meshes no longer point at template mesh paths. - [x] Add weather exposure zones if needed. Added native `AAgrarianWeatherExposureZone` volumes with exposure multipliers and temperature offsets, wired survival to apply the strongest overlapping zone after shelter protection, exposed current zone effects on the dev HUD, placed three Ground Zero zones for ridge, coastal-wind, and drainage cooling cases, and added verification for zone placement plus docs. - [x] Add landmark or ruin placeholder. Added a repeatable five-piece Ground Zero ruin cluster using Agrarian-native placeholder stone meshes for a visible MVP point of interest, regenerated the map, documented the pass, and added verification for actor count, native mesh paths, stone material assignment, distinct placement, and roadmap/doc coverage. -- [ ] Add spawn area with validation that the player spawns above sea level, above terrain by a safe offset, away from water, away from steep slopes, away from dense resource clusters, and with a known safe fallback coordinate. +- [x] Add spawn area with validation that the player spawns above sea level, above terrain by a safe offset, away from water, away from steep slopes, away from dense resource clusters, and with a known safe fallback coordinate. Added safe spawn candidate/fallback selection to the Ground Zero setup script, validates elevation, slope, above-terrain Z offset, freshwater spacing, and resource-cluster spacing before map save, places `AGR_DemoPlayerStart` at the known safe fallback coordinate, and added verification so future terrain/resource/water changes cannot silently invalidate the spawn area. - [ ] Add performance profiling markers. - [ ] Add navigation support for wildlife. - [ ] Add map boundaries or soft limits. diff --git a/Content/Agrarian/Maps/L_GroundZeroTerrain_Test.umap b/Content/Agrarian/Maps/L_GroundZeroTerrain_Test.umap index b98a42f..cb90407 100644 --- a/Content/Agrarian/Maps/L_GroundZeroTerrain_Test.umap +++ b/Content/Agrarian/Maps/L_GroundZeroTerrain_Test.umap @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6bdee6b560a9e11558de90cff77e337a959756f04ec8b4f372e21f357ec9273c -size 7483909 +oid sha256:7225e51e1f563ce4117c01144ad3f6cf974e05f9f849237d4eae6a8f7d4cf477 +size 7483919 diff --git a/Docs/TechnicalDesignDocument.md b/Docs/TechnicalDesignDocument.md index 945803d..c678255 100644 --- a/Docs/TechnicalDesignDocument.md +++ b/Docs/TechnicalDesignDocument.md @@ -164,6 +164,15 @@ and cold damage after shelter protection. This lets future generated tiles add biome, slope, elevation, hydrology, and coastal modifiers without changing the core survival calculation. +The safe Ground Zero spawn is selected by `Scripts/setup_ground_zero_demo_map.py` +from declared candidate coordinates and a known safe fallback coordinate. The +setup validates the selected player start against terrain elevation, terrain +slope, a minimum above-terrain Z offset, freshwater spacing, and resource-cluster +spacing before saving the map. `Scripts/verify_ground_zero_safe_spawn.py` +rechecks the placed `AGR_DemoPlayerStart` against the same constraints so future +map, resource, water, or terrain changes cannot silently move the player below +sea level, into steep terrain, into water, or into a dense resource cluster. + First-pass sky and lighting use `AAgrarianSkyLightingController`. The controller owns movable sun, skylight, and exponential-height-fog components and reads the replicated `AAgrarianGameState` time, active tile sunrise/sunset, weather state, diff --git a/Scripts/setup_ground_zero_demo_map.py b/Scripts/setup_ground_zero_demo_map.py index 4a068fc..f22584e 100644 --- a/Scripts/setup_ground_zero_demo_map.py +++ b/Scripts/setup_ground_zero_demo_map.py @@ -93,6 +93,25 @@ RESOURCE_MATERIAL_BY_LABEL_PREFIX = { "AGR_GZ_FreshWaterSource": "fresh_water", } +SAFE_SPAWN_CONFIG = { + "player_start_label": "AGR_DemoPlayerStart", + "safe_z_offset": 220.0, + "min_elevation_m": 2.0, + "max_slope_degrees": 8.0, + "slope_sample_distance_cm": 500.0, + "min_water_distance_cm": 10000.0, + "min_resource_distance_cm": 5000.0, + "fallback_location_xy": unreal.Vector(-22000.0, -3500.0, 0.0), + "candidate_locations_xy": [ + unreal.Vector(-22000.0, -3500.0, 0.0), + unreal.Vector(-25000.0, -4000.0, 0.0), + unreal.Vector(-20500.0, -10500.0, 0.0), + unreal.Vector(-28000.0, -9000.0, 0.0), + unreal.Vector(-30000.0, -12000.0, 0.0), + unreal.Vector(-35000.0, 0.0, 0.0), + ], +} + DEMO_ACTORS = [ { @@ -597,6 +616,18 @@ def terrain_elevation_m(height_values, x_cm, y_cm): return terrain_z_cm(height_values, x_cm, y_cm) / 100.0 +def terrain_slope_degrees(height_values, x_cm, y_cm, sample_distance_cm): + dz_dx_m = ( + terrain_elevation_m(height_values, x_cm + sample_distance_cm, y_cm) + - terrain_elevation_m(height_values, x_cm - sample_distance_cm, y_cm) + ) / ((sample_distance_cm * 2.0) / 100.0) + dz_dy_m = ( + terrain_elevation_m(height_values, x_cm, y_cm + sample_distance_cm) + - terrain_elevation_m(height_values, x_cm, y_cm - sample_distance_cm) + ) / ((sample_distance_cm * 2.0) / 100.0) + return math.degrees(math.atan(math.sqrt((dz_dx_m * dz_dx_m) + (dz_dy_m * dz_dy_m)))) + + def remove_existing_demo_actors(labels): for actor in unreal.EditorLevelLibrary.get_all_level_actors(): if get_actor_label(actor) in labels: @@ -607,6 +638,60 @@ def distance_2d(a, b): return math.hypot(a.x - b.x, a.y - b.y) +def validate_safe_spawn_location(height_values, location_xy, resource_points, water_points): + failures = [] + elevation_m = terrain_elevation_m(height_values, location_xy.x, location_xy.y) + slope_degrees = terrain_slope_degrees( + height_values, + location_xy.x, + location_xy.y, + SAFE_SPAWN_CONFIG["slope_sample_distance_cm"], + ) + min_resource_distance_cm = min(distance_2d(location_xy, point) for point in resource_points) + min_water_distance_cm = min(distance_2d(location_xy, point) for point in water_points) + + if elevation_m < SAFE_SPAWN_CONFIG["min_elevation_m"]: + failures.append(f"elevation {elevation_m:.2f}m below minimum") + if slope_degrees > SAFE_SPAWN_CONFIG["max_slope_degrees"]: + failures.append(f"slope {slope_degrees:.2f}deg above maximum") + if min_resource_distance_cm < SAFE_SPAWN_CONFIG["min_resource_distance_cm"]: + failures.append(f"resource distance {min_resource_distance_cm:.0f}cm below minimum") + if min_water_distance_cm < SAFE_SPAWN_CONFIG["min_water_distance_cm"]: + failures.append(f"water distance {min_water_distance_cm:.0f}cm below minimum") + + return failures + + +def select_safe_spawn_location(height_values): + resource_points = [spec["location_xy"] for spec in BIOME_RESOURCE_ACTORS] + resource_points.extend( + spec["location_xy"] + for spec in DEMO_ACTORS + if spec["label"] in {"AGR_DemoWoodResource_01", "AGR_DemoFiberResource_01"} + ) + water_points = [spec["location_xy"] for spec in WATER_SOURCE_ACTORS] + + candidates = list(SAFE_SPAWN_CONFIG["candidate_locations_xy"]) + fallback = SAFE_SPAWN_CONFIG["fallback_location_xy"] + if all(distance_2d(fallback, candidate) > 1.0 for candidate in candidates): + candidates.append(fallback) + + rejected = [] + for candidate in candidates: + failures = validate_safe_spawn_location(height_values, candidate, resource_points, water_points) + if not failures: + unreal.log( + "Selected safe Ground Zero spawn at " + f"({candidate.x:.0f}, {candidate.y:.0f}) " + f"elevation {terrain_elevation_m(height_values, candidate.x, candidate.y):.2f}m " + f"slope {terrain_slope_degrees(height_values, candidate.x, candidate.y, SAFE_SPAWN_CONFIG['slope_sample_distance_cm']):.2f}deg" + ) + return candidate + rejected.append(f"({candidate.x:.0f}, {candidate.y:.0f}): {', '.join(failures)}") + + raise RuntimeError("No safe Ground Zero spawn candidate found. Rejected: " + "; ".join(rejected)) + + def make_foliage_transform(height_values, x, y, yaw, z_offset, scale_xy, scale_z): return unreal.Transform( location=unreal.Vector(x, y, terrain_z_cm(height_values, x, y) + z_offset), @@ -720,8 +805,11 @@ def spawn_foliage_actor(height_values, materials): return foliage_actor -def spawn_demo_actor(spec, height_values, materials): +def spawn_demo_actor(spec, height_values, materials, safe_spawn_location_xy=None): location_xy = spec["location_xy"] + if spec["label"] == SAFE_SPAWN_CONFIG["player_start_label"] and safe_spawn_location_xy is not None: + location_xy = safe_spawn_location_xy + z = spec.get("fixed_z") if z is None: z = terrain_z_cm(height_values, location_xy.x, location_xy.y) + spec.get("z_offset", 0.0) @@ -811,6 +899,7 @@ def main(): materials = ensure_environment_materials() apply_landscape_material(materials["terrain"]) height_values = load_heightmap() + safe_spawn_location_xy = select_safe_spawn_location(height_values) spawn_foliage_actor(height_values, materials) for spec in BIOME_RESOURCE_ACTORS: spawn_demo_actor(spec, height_values, materials) @@ -823,7 +912,7 @@ def main(): for spec in RUIN_PLACEHOLDER_ACTORS: spawn_environment_variation_actor(spec, height_values, materials) for spec in DEMO_ACTORS: - spawn_demo_actor(spec, height_values, materials) + spawn_demo_actor(spec, height_values, materials, safe_spawn_location_xy) unreal.EditorLevelLibrary.save_current_level() unreal.log("Ground Zero demo map setup complete.") diff --git a/Scripts/verify_ground_zero_safe_spawn.py b/Scripts/verify_ground_zero_safe_spawn.py new file mode 100644 index 0000000..d3af69d --- /dev/null +++ b/Scripts/verify_ground_zero_safe_spawn.py @@ -0,0 +1,148 @@ +import math +import struct +from pathlib import Path + +import unreal + + +MAP_PATH = "/Game/Agrarian/Maps/L_GroundZeroTerrain_Test" +PROJECT_ROOT = Path(r"Y:\AgrarianGameBulid") +TILE_ID = "gz_us_ca_pacifica_utm10n_e544_n4160" +HEIGHTMAP_PATH = PROJECT_ROOT / "Data" / "Terrain" / "Unreal" / TILE_ID / f"{TILE_ID}_unreal_1009.r16" +LANDSCAPE_SIZE = 1009 +XY_SCALE_CM = 100000.0 / (LANDSCAPE_SIZE - 1) +Z_SCALE_CM = 100.0 +LANDSCAPE_MIN_XY = -50000.0 + +PLAYER_START_LABEL = "AGR_DemoPlayerStart" +SAFE_Z_OFFSET_CM = 220.0 +MIN_ELEVATION_M = 2.0 +MAX_SLOPE_DEGREES = 8.0 +SLOPE_SAMPLE_DISTANCE_CM = 500.0 +MIN_WATER_DISTANCE_CM = 10000.0 +MIN_RESOURCE_DISTANCE_CM = 5000.0 +KNOWN_SAFE_FALLBACK_XY = (-22000.0, -3500.0) +RESOURCE_PREFIXES = ( + "AGR_GZ_Wood", + "AGR_GZ_Fiber", + "AGR_GZ_Stone", + "AGR_DemoWoodResource", + "AGR_DemoFiberResource", +) +WATER_LABELS = { + "AGR_GZ_FreshWaterSource_01", + "AGR_GZ_EnvVar_Water_Surface_01", +} + + +def get_actor_label(actor): + try: + return actor.get_actor_label() + except Exception: + return actor.get_name() + + +def load_heightmap(): + raw = HEIGHTMAP_PATH.read_bytes() + expected_bytes = LANDSCAPE_SIZE * LANDSCAPE_SIZE * 2 + if len(raw) != expected_bytes: + raise RuntimeError(f"Unexpected heightmap size: {len(raw)} != {expected_bytes}") + return struct.unpack(f"<{LANDSCAPE_SIZE * LANDSCAPE_SIZE}H", raw) + + +def terrain_elevation_m(height_values, x_cm, y_cm): + sample_x = max(0, min(LANDSCAPE_SIZE - 1, round((x_cm - LANDSCAPE_MIN_XY) / XY_SCALE_CM))) + sample_y = max(0, min(LANDSCAPE_SIZE - 1, round((y_cm - LANDSCAPE_MIN_XY) / XY_SCALE_CM))) + height_value = height_values[int(sample_y) * LANDSCAPE_SIZE + int(sample_x)] + return (float(height_value) - 32768.0) * Z_SCALE_CM / (100.0 * 128.0) + + +def terrain_slope_degrees(height_values, x_cm, y_cm): + dz_dx_m = ( + terrain_elevation_m(height_values, x_cm + SLOPE_SAMPLE_DISTANCE_CM, y_cm) + - terrain_elevation_m(height_values, x_cm - SLOPE_SAMPLE_DISTANCE_CM, y_cm) + ) / ((SLOPE_SAMPLE_DISTANCE_CM * 2.0) / 100.0) + dz_dy_m = ( + terrain_elevation_m(height_values, x_cm, y_cm + SLOPE_SAMPLE_DISTANCE_CM) + - terrain_elevation_m(height_values, x_cm, y_cm - SLOPE_SAMPLE_DISTANCE_CM) + ) / ((SLOPE_SAMPLE_DISTANCE_CM * 2.0) / 100.0) + return math.degrees(math.atan(math.sqrt((dz_dx_m * dz_dx_m) + (dz_dy_m * dz_dy_m)))) + + +def distance_2d(a, b): + return math.hypot(a.x - b.x, a.y - b.y) + + +def main(): + if not unreal.EditorLevelLibrary.load_level(MAP_PATH): + raise RuntimeError(f"Could not load map: {MAP_PATH}") + + failures = [] + actors = unreal.EditorLevelLibrary.get_all_level_actors() + starts = [actor for actor in actors if get_actor_label(actor) == PLAYER_START_LABEL] + if len(starts) != 1: + raise RuntimeError(f"Expected exactly one {PLAYER_START_LABEL}, found {len(starts)}") + + player_start = starts[0] + location = player_start.get_actor_location() + height_values = load_heightmap() + terrain_elevation = terrain_elevation_m(height_values, location.x, location.y) + terrain_z_cm = terrain_elevation * 100.0 + slope_degrees = terrain_slope_degrees(height_values, location.x, location.y) + actual_z_offset_cm = location.z - terrain_z_cm + + resource_points = [ + actor.get_actor_location() + for actor in actors + if get_actor_label(actor).startswith(RESOURCE_PREFIXES) + ] + water_points = [ + actor.get_actor_location() + for actor in actors + if get_actor_label(actor) in WATER_LABELS + ] + + if terrain_elevation < MIN_ELEVATION_M: + failures.append(f"spawn elevation {terrain_elevation:.2f}m below {MIN_ELEVATION_M:.2f}m") + if actual_z_offset_cm < SAFE_Z_OFFSET_CM - 1.0: + failures.append(f"spawn z offset {actual_z_offset_cm:.1f}cm below {SAFE_Z_OFFSET_CM:.1f}cm") + if slope_degrees > MAX_SLOPE_DEGREES: + failures.append(f"spawn slope {slope_degrees:.2f}deg exceeds {MAX_SLOPE_DEGREES:.2f}deg") + if not resource_points: + failures.append("no resource actors found for spacing validation") + elif min(distance_2d(location, point) for point in resource_points) < MIN_RESOURCE_DISTANCE_CM: + failures.append("spawn is too close to a resource cluster") + if not water_points: + failures.append("no water actors found for spacing validation") + elif min(distance_2d(location, point) for point in water_points) < MIN_WATER_DISTANCE_CM: + failures.append("spawn is too close to water") + + fallback_point = unreal.Vector(KNOWN_SAFE_FALLBACK_XY[0], KNOWN_SAFE_FALLBACK_XY[1], 0.0) + if distance_2d(location, fallback_point) > 1.0: + failures.append( + "spawn is not using the known safe fallback coordinate " + f"{KNOWN_SAFE_FALLBACK_XY}, got ({location.x:.0f}, {location.y:.0f})" + ) + + roadmap = unreal.Paths.convert_relative_path_to_full(unreal.Paths.project_dir()) + "AGRARIAN_DEVELOPMENT_ROADMAP.md" + technical_design = unreal.Paths.convert_relative_path_to_full(unreal.Paths.project_dir()) + "Docs/TechnicalDesignDocument.md" + for path, snippet in [ + (roadmap, "[x] Add spawn area with validation"), + (technical_design, "safe Ground Zero spawn"), + ]: + with open(path, "r", encoding="utf-8") as handle: + text = handle.read() + if snippet not in text: + failures.append(f"{path} missing `{snippet}`") + + if failures: + raise RuntimeError("Ground Zero safe spawn verification failed: " + "; ".join(failures)) + + unreal.log( + "Ground Zero safe spawn verification complete: " + f"location ({location.x:.0f}, {location.y:.0f}, {location.z:.0f}), " + f"terrain {terrain_elevation:.2f}m, slope {slope_degrees:.2f}deg, z offset {actual_z_offset_cm:.0f}cm." + ) + + +main()