Validate Ground Zero safe spawn area

This commit is contained in:
2026-05-16 10:44:08 -07:00
parent 56f8ef9bae
commit e50745dbdd
5 changed files with 251 additions and 5 deletions
+1 -1
View File
@@ -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.
Binary file not shown.
+9
View File
@@ -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,
+91 -2
View File
@@ -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.")
+148
View File
@@ -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()