Add Ground Zero terrain pipeline and playable assets
This commit is contained in:
@@ -21,7 +21,7 @@ if not exist "%BUILD_BAT%" (
|
||||
|
||||
echo Building AgrarianGameEditor with UnrealBuildTool...
|
||||
echo Log: %LOG_FILE%
|
||||
call "%BUILD_BAT%" AgrarianGameEditor Win64 Development -Project="%PROJECT_FILE%" -WaitMutex -architecture=x64 > "%LOG_FILE%" 2>&1
|
||||
call "%BUILD_BAT%" AgrarianGameEditor Win64 Development -Project="%PROJECT_FILE%" -WaitMutex -architecture=x64 -NoUBA > "%LOG_FILE%" 2>&1
|
||||
set "BUILD_EXIT_CODE=%ERRORLEVEL%"
|
||||
|
||||
type "%LOG_FILE%"
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Acquire the best available USGS 3DEP DEM source for Ground Zero.
|
||||
|
||||
This script queries the official USGS TNMAccess API for the selected 1 km tile,
|
||||
stores the product metadata, downloads the chosen 1-meter GeoTIFF source, and
|
||||
updates the tile registry source record.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from prototype_ground_zero_terrain import TARGET_TILE_ID, utm_to_lat_lon
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
REGISTRY_PATH = PROJECT_ROOT / "Data" / "Tiles" / "ground_zero_tiles.json"
|
||||
SOURCE_ROOT = PROJECT_ROOT / "Data" / "Terrain" / "Sources" / TARGET_TILE_ID
|
||||
TNM_PRODUCTS_URL = "https://tnmaccess.nationalmap.gov/api/v1/products"
|
||||
TARGET_DATASET = "Digital Elevation Model (DEM) 1 meter"
|
||||
|
||||
|
||||
def load_tile() -> dict:
|
||||
registry = json.loads(REGISTRY_PATH.read_text())
|
||||
for tile in registry["tiles"]:
|
||||
if tile["tile_id"] == TARGET_TILE_ID:
|
||||
return tile
|
||||
raise RuntimeError(f"Could not find {TARGET_TILE_ID} in {REGISTRY_PATH}")
|
||||
|
||||
|
||||
def tile_bbox_lon_lat(tile: dict) -> tuple[float, float, float, float]:
|
||||
grid = tile["grid"]
|
||||
corners = [
|
||||
(grid["easting_min_m"], grid["northing_min_m"]),
|
||||
(grid["easting_max_m"], grid["northing_min_m"]),
|
||||
(grid["easting_max_m"], grid["northing_max_m"]),
|
||||
(grid["easting_min_m"], grid["northing_max_m"]),
|
||||
]
|
||||
lat_lons = [utm_to_lat_lon(easting, northing, 10, True) for easting, northing in corners]
|
||||
return (
|
||||
min(lon for _, lon in lat_lons),
|
||||
min(lat for lat, _ in lat_lons),
|
||||
max(lon for _, lon in lat_lons),
|
||||
max(lat for lat, _ in lat_lons),
|
||||
)
|
||||
|
||||
|
||||
def query_tnm_products(bbox: tuple[float, float, float, float]) -> dict:
|
||||
params = urllib.parse.urlencode(
|
||||
{
|
||||
"bbox": ",".join(f"{value:.10f}" for value in bbox),
|
||||
"datasets": TARGET_DATASET,
|
||||
"prodFormats": "GeoTIFF",
|
||||
"outputFormat": "JSON",
|
||||
}
|
||||
)
|
||||
url = f"{TNM_PRODUCTS_URL}?{params}"
|
||||
with urllib.request.urlopen(url, timeout=60) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
payload["query_url"] = url
|
||||
return payload
|
||||
|
||||
|
||||
def choose_product(products: dict) -> dict:
|
||||
items = products.get("items", [])
|
||||
if not items:
|
||||
raise RuntimeError("TNMAccess returned no 1-meter DEM products for Ground Zero")
|
||||
|
||||
def product_score(item: dict) -> tuple[int, str]:
|
||||
title = item.get("title", "")
|
||||
size = int(item.get("sizeInBytes") or 0)
|
||||
is_geotiff = 1 if item.get("format") == "GeoTIFF" else 0
|
||||
has_download = 1 if item.get("downloadURL") else 0
|
||||
return (has_download + is_geotiff, f"{size:020d}_{title}")
|
||||
|
||||
return sorted(items, key=product_score, reverse=True)[0]
|
||||
|
||||
|
||||
def coverage_products(products: dict) -> list[dict]:
|
||||
items = products.get("items", [])
|
||||
if not items:
|
||||
raise RuntimeError("TNMAccess returned no 1-meter DEM products for Ground Zero")
|
||||
return [item for item in items if item.get("downloadURL")]
|
||||
|
||||
|
||||
def download_file(url: str, output_path: Path) -> None:
|
||||
if output_path.exists() and output_path.stat().st_size > 0:
|
||||
print(f"Using existing download: {output_path}")
|
||||
return
|
||||
|
||||
temp_path = output_path.with_suffix(output_path.suffix + ".part")
|
||||
with urllib.request.urlopen(url, timeout=120) as response, temp_path.open("wb") as output_file:
|
||||
while True:
|
||||
chunk = response.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
output_file.write(chunk)
|
||||
temp_path.replace(output_path)
|
||||
|
||||
|
||||
def update_registry_source(product: dict, metadata_path: Path, geotiff_path: Path) -> None:
|
||||
registry = json.loads(REGISTRY_PATH.read_text())
|
||||
for tile in registry["tiles"]:
|
||||
if tile["tile_id"] != TARGET_TILE_ID:
|
||||
continue
|
||||
|
||||
for source in tile["sources"]:
|
||||
if source["source_kind"] == "elevation":
|
||||
source["source_name"] = product.get("title", source["source_name"])
|
||||
source["source_uri"] = product.get("downloadURL", source["source_uri"])
|
||||
source["source_version"] = product.get("publicationDate", "downloaded")
|
||||
source["coverage_status"] = "confirmed"
|
||||
source["local_metadata_path"] = str(metadata_path.relative_to(PROJECT_ROOT))
|
||||
source["local_source_path"] = str(geotiff_path.relative_to(PROJECT_ROOT))
|
||||
source["local_source_folder"] = str(geotiff_path.parent.relative_to(PROJECT_ROOT))
|
||||
|
||||
tile["status"] = "source_data_found"
|
||||
tile["notes"] = (
|
||||
"Final MVP 1-meter USGS DEM source acquired. "
|
||||
"Prototype heightmap remains generated separately until DEM extraction/import is run."
|
||||
)
|
||||
break
|
||||
else:
|
||||
raise RuntimeError(f"Could not update missing tile {TARGET_TILE_ID}")
|
||||
|
||||
REGISTRY_PATH.write_text(json.dumps(registry, indent=2) + "\n")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--metadata-only", action="store_true", help="Query and write metadata without downloading the GeoTIFF")
|
||||
args = parser.parse_args()
|
||||
|
||||
tile = load_tile()
|
||||
bbox = tile_bbox_lon_lat(tile)
|
||||
SOURCE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
products = query_tnm_products(bbox)
|
||||
products_to_download = coverage_products(products)
|
||||
product = choose_product(products)
|
||||
|
||||
metadata = {
|
||||
"tile_id": TARGET_TILE_ID,
|
||||
"acquired_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"tnm_query": {
|
||||
"url": products["query_url"],
|
||||
"bbox_lon_lat": bbox,
|
||||
"dataset": TARGET_DATASET,
|
||||
"product_count": products.get("total"),
|
||||
},
|
||||
"selected_product": product,
|
||||
"coverage_products": products_to_download,
|
||||
"all_products": products.get("items", []),
|
||||
}
|
||||
metadata_path = SOURCE_ROOT / f"{TARGET_TILE_ID}_tnm_1m_dem_product.json"
|
||||
metadata_path.write_text(json.dumps(metadata, indent=2) + "\n")
|
||||
|
||||
geotiff_paths = []
|
||||
for coverage_product in products_to_download:
|
||||
download_url = coverage_product.get("downloadURL")
|
||||
filename = Path(urllib.parse.urlparse(download_url).path).name
|
||||
geotiff_path = SOURCE_ROOT / filename
|
||||
geotiff_paths.append(geotiff_path)
|
||||
if not args.metadata_only:
|
||||
download_file(download_url, geotiff_path)
|
||||
|
||||
update_registry_source(product, metadata_path, geotiff_paths[0])
|
||||
|
||||
print(f"Selected: {product.get('title')}")
|
||||
print(f"Published: {product.get('publicationDate')}")
|
||||
print(f"Size: {product.get('sizeInBytes')} bytes")
|
||||
print(f"Metadata: {metadata_path}")
|
||||
for geotiff_path in geotiff_paths:
|
||||
if args.metadata_only:
|
||||
print(f"GeoTIFF download skipped: {geotiff_path}")
|
||||
else:
|
||||
print(f"GeoTIFF: {geotiff_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert the extracted Ground Zero 1m DEM subset into Unreal heightmaps."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import struct
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import rasterio
|
||||
from rasterio.enums import Resampling
|
||||
from rasterio.warp import reproject
|
||||
|
||||
from prototype_ground_zero_terrain import TARGET_TILE_ID
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
EXTRACT_ROOT = PROJECT_ROOT / "Data" / "Terrain" / "Extracted" / TARGET_TILE_ID
|
||||
UNREAL_ROOT = PROJECT_ROOT / "Data" / "Terrain" / "Unreal" / TARGET_TILE_ID
|
||||
SOURCE_TIFF = EXTRACT_ROOT / f"{TARGET_TILE_ID}_1m_dem_subset.tif"
|
||||
UNREAL_SIZE = 1009
|
||||
TILE_SIZE_M = 1000.0
|
||||
UNREAL_Z_SCALE_CM = 100.0
|
||||
|
||||
|
||||
def encode_to_unreal_uint16(data: np.ma.MaskedArray) -> tuple[np.ndarray, float, float]:
|
||||
min_elevation = float(data.min())
|
||||
max_elevation = float(data.max())
|
||||
# Unreal Landscape encodes zero height around 32768. At Z scale 100, each
|
||||
# height unit is 100 / 128 cm and the full range is roughly +/- 256 m.
|
||||
height_values = 32768.0 + (data.filled(0.0) * 100.0 * 128.0 / UNREAL_Z_SCALE_CM)
|
||||
height_uint16 = np.rint(np.clip(height_values, 0.0, 65535.0)).astype("<u2")
|
||||
return height_uint16, min_elevation, max_elevation
|
||||
|
||||
|
||||
def resample_to_unreal_size(source: rasterio.io.DatasetReader) -> np.ma.MaskedArray:
|
||||
source_data = source.read(1, masked=True)
|
||||
destination = np.empty((UNREAL_SIZE, UNREAL_SIZE), dtype=np.float32)
|
||||
|
||||
dst_transform = rasterio.transform.from_bounds(
|
||||
source.bounds.left,
|
||||
source.bounds.bottom,
|
||||
source.bounds.right,
|
||||
source.bounds.top,
|
||||
UNREAL_SIZE,
|
||||
UNREAL_SIZE,
|
||||
)
|
||||
|
||||
reproject(
|
||||
source=source_data.filled(source.nodata),
|
||||
destination=destination,
|
||||
src_transform=source.transform,
|
||||
src_crs=source.crs,
|
||||
src_nodata=source.nodata,
|
||||
dst_transform=dst_transform,
|
||||
dst_crs=source.crs,
|
||||
dst_nodata=source.nodata,
|
||||
resampling=Resampling.bilinear,
|
||||
)
|
||||
|
||||
return np.ma.masked_equal(destination, source.nodata)
|
||||
|
||||
|
||||
def write_r16(height_data: np.ndarray, output_path: Path) -> None:
|
||||
output_path.write_bytes(height_data.tobytes(order="C"))
|
||||
|
||||
|
||||
def write_png16(height_data: np.ndarray, output_path: Path) -> bool:
|
||||
try:
|
||||
from PIL import Image
|
||||
except ModuleNotFoundError:
|
||||
return False
|
||||
|
||||
image = Image.fromarray(height_data, mode="I;16")
|
||||
image.save(output_path)
|
||||
return True
|
||||
|
||||
|
||||
def write_preview_pgm(height_data: np.ndarray, output_path: Path) -> None:
|
||||
preview = (height_data.astype(np.float32) / 65535.0 * 255.0).astype(np.uint8)
|
||||
header = f"P5\n{UNREAL_SIZE} {UNREAL_SIZE}\n255\n".encode("ascii")
|
||||
output_path.write_bytes(header + preview.tobytes(order="C"))
|
||||
|
||||
|
||||
def write_import_metadata(
|
||||
output_path: Path,
|
||||
source: rasterio.io.DatasetReader,
|
||||
min_elevation: float,
|
||||
max_elevation: float,
|
||||
r16_path: Path,
|
||||
png_path: Path,
|
||||
png_written: bool,
|
||||
preview_path: Path,
|
||||
) -> None:
|
||||
vertical_range = max_elevation - min_elevation
|
||||
metadata = {
|
||||
"tile_id": TARGET_TILE_ID,
|
||||
"generated_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"source_dem": str(SOURCE_TIFF.relative_to(PROJECT_ROOT)),
|
||||
"source_crs": str(source.crs),
|
||||
"source_bounds": list(source.bounds),
|
||||
"source_size_pixels": [source.width, source.height],
|
||||
"source_pixel_size_m": [abs(source.transform.a), abs(source.transform.e)],
|
||||
"heightmap": {
|
||||
"format": "r16_little_endian_unsigned",
|
||||
"width": UNREAL_SIZE,
|
||||
"height": UNREAL_SIZE,
|
||||
"r16_path": str(r16_path.relative_to(PROJECT_ROOT)),
|
||||
"png16_path": str(png_path.relative_to(PROJECT_ROOT)) if png_written else "",
|
||||
"preview_pgm_path": str(preview_path.relative_to(PROJECT_ROOT)),
|
||||
"min_elevation_m": min_elevation,
|
||||
"max_elevation_m": max_elevation,
|
||||
"vertical_range_m": vertical_range,
|
||||
"encoding": "unreal_landscape_midpoint_32768_sea_level",
|
||||
},
|
||||
"unreal_landscape_import": {
|
||||
"landscape_resolution": "1009 x 1009",
|
||||
"tile_world_size_m": TILE_SIZE_M,
|
||||
"x_scale_cm": 100.0 * TILE_SIZE_M / (UNREAL_SIZE - 1),
|
||||
"y_scale_cm": 100.0 * TILE_SIZE_M / (UNREAL_SIZE - 1),
|
||||
"z_scale_cm": UNREAL_Z_SCALE_CM,
|
||||
"z_offset_m": 0.0,
|
||||
"notes": [
|
||||
"Use the R16 file for import.",
|
||||
"Set X/Y scale to x_scale_cm/y_scale_cm so the 1009 samples span 1000 real meters.",
|
||||
"Set Z scale to z_scale_cm.",
|
||||
"Height values are encoded so Unreal landscape zero height corresponds to approximately sea level.",
|
||||
"1009 x 1009 is a valid Unreal Landscape import size close to the 1000m source tile."
|
||||
],
|
||||
},
|
||||
}
|
||||
output_path.write_text(json.dumps(metadata, indent=2) + "\n")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
UNREAL_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
r16_path = UNREAL_ROOT / f"{TARGET_TILE_ID}_unreal_1009.r16"
|
||||
png_path = UNREAL_ROOT / f"{TARGET_TILE_ID}_unreal_1009.png"
|
||||
preview_path = UNREAL_ROOT / f"{TARGET_TILE_ID}_unreal_1009_preview.pgm"
|
||||
metadata_path = UNREAL_ROOT / f"{TARGET_TILE_ID}_unreal_heightmap_metadata.json"
|
||||
|
||||
with rasterio.open(SOURCE_TIFF) as source:
|
||||
resampled = resample_to_unreal_size(source)
|
||||
height_data, min_elevation, max_elevation = encode_to_unreal_uint16(resampled)
|
||||
write_r16(height_data, r16_path)
|
||||
png_written = write_png16(height_data, png_path)
|
||||
write_preview_pgm(height_data, preview_path)
|
||||
write_import_metadata(metadata_path, source, min_elevation, max_elevation, r16_path, png_path, png_written, preview_path)
|
||||
|
||||
print(f"R16: {r16_path}")
|
||||
print(f"Preview: {preview_path}")
|
||||
if png_written:
|
||||
print(f"PNG16: {png_path}")
|
||||
else:
|
||||
print("PNG16 skipped: Pillow is not installed")
|
||||
print(f"Metadata: {metadata_path}")
|
||||
print(f"Elevation range: {min_elevation:.3f}m to {max_elevation:.3f}m")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract the 1 km Ground Zero subset from the acquired USGS DEM.
|
||||
|
||||
This script requires either rasterio or GDAL Python bindings. The current
|
||||
Ubuntu-Codex image does not include those packages by default, so this file is
|
||||
checked in as the repeatable extraction step once the geospatial dependency is
|
||||
available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from prototype_ground_zero_terrain import TARGET_TILE_ID
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
REGISTRY_PATH = PROJECT_ROOT / "Data" / "Tiles" / "ground_zero_tiles.json"
|
||||
SOURCE_ROOT = PROJECT_ROOT / "Data" / "Terrain" / "Sources" / TARGET_TILE_ID
|
||||
EXTRACT_ROOT = PROJECT_ROOT / "Data" / "Terrain" / "Extracted" / TARGET_TILE_ID
|
||||
|
||||
|
||||
def load_ground_zero() -> dict:
|
||||
registry = json.loads(REGISTRY_PATH.read_text())
|
||||
for tile in registry["tiles"]:
|
||||
if tile["tile_id"] == TARGET_TILE_ID:
|
||||
return tile
|
||||
raise RuntimeError(f"Could not find {TARGET_TILE_ID}")
|
||||
|
||||
|
||||
def find_source_tiffs(tile: dict) -> list[Path]:
|
||||
for source in tile["sources"]:
|
||||
if source["source_kind"] != "elevation":
|
||||
continue
|
||||
if source.get("local_source_folder"):
|
||||
folder = PROJECT_ROOT / source["local_source_folder"]
|
||||
candidates = sorted(folder.glob("*.tif"))
|
||||
if candidates:
|
||||
return candidates
|
||||
if source.get("local_source_path"):
|
||||
path = PROJECT_ROOT / source["local_source_path"]
|
||||
if path.exists():
|
||||
return [path]
|
||||
candidates = sorted(SOURCE_ROOT.glob("*.tif"))
|
||||
if candidates:
|
||||
return candidates
|
||||
raise RuntimeError("No acquired source GeoTIFF found. Run acquire_ground_zero_dem.py first.")
|
||||
|
||||
|
||||
def extract_with_rasterio(source_tiffs: list[Path], tile: dict) -> None:
|
||||
import rasterio
|
||||
from rasterio.merge import merge
|
||||
from rasterio.windows import from_bounds
|
||||
|
||||
EXTRACT_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
grid = tile["grid"]
|
||||
output_tiff = EXTRACT_ROOT / f"{TARGET_TILE_ID}_1m_dem_subset.tif"
|
||||
output_metadata = EXTRACT_ROOT / f"{TARGET_TILE_ID}_1m_dem_subset_metadata.json"
|
||||
bounds = (
|
||||
grid["easting_min_m"],
|
||||
grid["northing_min_m"],
|
||||
grid["easting_max_m"],
|
||||
grid["northing_max_m"],
|
||||
)
|
||||
|
||||
datasets = [rasterio.open(path) for path in source_tiffs]
|
||||
try:
|
||||
if len(datasets) == 1:
|
||||
dataset = datasets[0]
|
||||
source_bounds = [list(dataset.bounds)]
|
||||
source_crs = str(dataset.crs)
|
||||
window = from_bounds(*bounds, dataset.transform).round_offsets().round_lengths()
|
||||
data = dataset.read(window=window)
|
||||
transform = dataset.window_transform(window)
|
||||
profile = dataset.profile
|
||||
else:
|
||||
source_bounds = [list(dataset.bounds) for dataset in datasets]
|
||||
source_crs = str(datasets[0].crs)
|
||||
data, transform = merge(datasets, bounds=bounds, res=(1.0, 1.0), nodata=-999999)
|
||||
profile = datasets[0].profile
|
||||
|
||||
profile.update(
|
||||
{
|
||||
"height": data.shape[1],
|
||||
"width": data.shape[2],
|
||||
"transform": transform,
|
||||
}
|
||||
)
|
||||
|
||||
with rasterio.open(output_tiff, "w", **profile) as output:
|
||||
output.write(data)
|
||||
|
||||
metadata = {
|
||||
"tile_id": TARGET_TILE_ID,
|
||||
"source_tiffs": [str(path.relative_to(PROJECT_ROOT)) for path in source_tiffs],
|
||||
"output_tiff": str(output_tiff.relative_to(PROJECT_ROOT)),
|
||||
"source_crs": source_crs,
|
||||
"source_bounds": source_bounds,
|
||||
"subset_bounds_utm_m": list(bounds),
|
||||
"subset_width_pixels": int(data.shape[2]),
|
||||
"subset_height_pixels": int(data.shape[1]),
|
||||
"pixel_size_x": abs(transform.a),
|
||||
"pixel_size_y": abs(transform.e),
|
||||
}
|
||||
output_metadata.write_text(json.dumps(metadata, indent=2) + "\n")
|
||||
finally:
|
||||
for dataset in datasets:
|
||||
dataset.close()
|
||||
|
||||
print(f"Subset GeoTIFF: {output_tiff}")
|
||||
print(f"Metadata: {output_metadata}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
tile = load_ground_zero()
|
||||
source_tiffs = find_source_tiffs(tile)
|
||||
try:
|
||||
extract_with_rasterio(source_tiffs, tile)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise SystemExit(
|
||||
"Missing rasterio. Install rasterio or GDAL Python bindings, then rerun this script."
|
||||
) from exc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a first real-elevation heightmap for the Ground Zero terrain tile.
|
||||
|
||||
This prototype intentionally uses only the Python standard library so it can run
|
||||
on Ubuntu-Codex without extra GIS packages. It samples USGS EPQS elevations over
|
||||
the selected 1 km UTM tile, writes CSV samples, writes a little-endian R16
|
||||
heightmap, and records generation metadata.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import math
|
||||
import struct
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
REGISTRY_PATH = PROJECT_ROOT / "Data" / "Tiles" / "ground_zero_tiles.json"
|
||||
OUTPUT_ROOT = PROJECT_ROOT / "Data" / "Terrain" / "Generated"
|
||||
USGS_EPQS_URL = "https://epqs.nationalmap.gov/v1/json"
|
||||
TARGET_TILE_ID = "gz_us_ca_pacifica_utm10n_e544_n4160"
|
||||
|
||||
|
||||
def utm_to_lat_lon(easting: float, northing: float, zone_number: int, northern: bool = True) -> tuple[float, float]:
|
||||
"""Convert UTM WGS84 coordinates to latitude/longitude in degrees."""
|
||||
a = 6378137.0
|
||||
ecc_squared = 0.0066943799901413165
|
||||
k0 = 0.9996
|
||||
|
||||
x = easting - 500000.0
|
||||
y = northing
|
||||
if not northern:
|
||||
y -= 10000000.0
|
||||
|
||||
lon_origin = (zone_number - 1) * 6 - 180 + 3
|
||||
ecc_prime_squared = ecc_squared / (1 - ecc_squared)
|
||||
m = y / k0
|
||||
mu = m / (a * (1 - ecc_squared / 4 - 3 * ecc_squared**2 / 64 - 5 * ecc_squared**3 / 256))
|
||||
|
||||
e1 = (1 - math.sqrt(1 - ecc_squared)) / (1 + math.sqrt(1 - ecc_squared))
|
||||
j1 = 3 * e1 / 2 - 27 * e1**3 / 32
|
||||
j2 = 21 * e1**2 / 16 - 55 * e1**4 / 32
|
||||
j3 = 151 * e1**3 / 96
|
||||
j4 = 1097 * e1**4 / 512
|
||||
fp = mu + j1 * math.sin(2 * mu) + j2 * math.sin(4 * mu) + j3 * math.sin(6 * mu) + j4 * math.sin(8 * mu)
|
||||
|
||||
sin_fp = math.sin(fp)
|
||||
cos_fp = math.cos(fp)
|
||||
tan_fp = math.tan(fp)
|
||||
|
||||
c1 = ecc_prime_squared * cos_fp**2
|
||||
t1 = tan_fp**2
|
||||
n1 = a / math.sqrt(1 - ecc_squared * sin_fp**2)
|
||||
r1 = a * (1 - ecc_squared) / ((1 - ecc_squared * sin_fp**2) ** 1.5)
|
||||
d = x / (n1 * k0)
|
||||
|
||||
lat = fp - (n1 * tan_fp / r1) * (
|
||||
d**2 / 2
|
||||
- (5 + 3 * t1 + 10 * c1 - 4 * c1**2 - 9 * ecc_prime_squared) * d**4 / 24
|
||||
+ (61 + 90 * t1 + 298 * c1 + 45 * t1**2 - 252 * ecc_prime_squared - 3 * c1**2) * d**6 / 720
|
||||
)
|
||||
lon = math.radians(lon_origin) + (
|
||||
d
|
||||
- (1 + 2 * t1 + c1) * d**3 / 6
|
||||
+ (5 - 2 * c1 + 28 * t1 - 3 * c1**2 + 8 * ecc_prime_squared + 24 * t1**2) * d**5 / 120
|
||||
) / cos_fp
|
||||
|
||||
return math.degrees(lat), math.degrees(lon)
|
||||
|
||||
|
||||
def query_elevation(latitude: float, longitude: float, retries: int = 3) -> dict:
|
||||
params = urllib.parse.urlencode({"x": f"{longitude:.8f}", "y": f"{latitude:.8f}", "units": "Meters"})
|
||||
url = f"{USGS_EPQS_URL}?{params}"
|
||||
last_error = None
|
||||
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=20) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
return {
|
||||
"elevation_m": float(payload["value"]),
|
||||
"raster_id": payload.get("rasterId"),
|
||||
"resolution_m": payload.get("resolution"),
|
||||
"query_url": url,
|
||||
}
|
||||
except (urllib.error.URLError, TimeoutError, KeyError, ValueError) as exc:
|
||||
last_error = exc
|
||||
time.sleep(0.5 * (attempt + 1))
|
||||
|
||||
raise RuntimeError(f"USGS elevation query failed for {latitude}, {longitude}: {last_error}")
|
||||
|
||||
|
||||
def load_ground_zero_tile() -> dict:
|
||||
registry = json.loads(REGISTRY_PATH.read_text())
|
||||
for tile in registry["tiles"]:
|
||||
if tile["tile_id"] == TARGET_TILE_ID:
|
||||
return tile
|
||||
raise RuntimeError(f"Could not find {TARGET_TILE_ID} in {REGISTRY_PATH}")
|
||||
|
||||
|
||||
def build_sample_points(tile: dict, grid_size: int) -> list[dict]:
|
||||
grid = tile["grid"]
|
||||
e_min = grid["easting_min_m"]
|
||||
n_min = grid["northing_min_m"]
|
||||
spacing = grid["tile_size_m"] / (grid_size - 1)
|
||||
points = []
|
||||
|
||||
for row in range(grid_size):
|
||||
northing = n_min + row * spacing
|
||||
for col in range(grid_size):
|
||||
easting = e_min + col * spacing
|
||||
latitude, longitude = utm_to_lat_lon(easting, northing, 10, True)
|
||||
points.append(
|
||||
{
|
||||
"row": row,
|
||||
"col": col,
|
||||
"easting_m": easting,
|
||||
"northing_m": northing,
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
}
|
||||
)
|
||||
|
||||
return points
|
||||
|
||||
|
||||
def sample_elevations(points: list[dict], workers: int) -> list[dict]:
|
||||
sampled = []
|
||||
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
future_to_point = {
|
||||
executor.submit(query_elevation, point["latitude"], point["longitude"]): point for point in points
|
||||
}
|
||||
for future in as_completed(future_to_point):
|
||||
point = future_to_point[future]
|
||||
elevation = future.result()
|
||||
point.update(elevation)
|
||||
sampled.append(point)
|
||||
|
||||
return sorted(sampled, key=lambda item: (item["row"], item["col"]))
|
||||
|
||||
|
||||
def write_csv(samples: list[dict], output_path: Path) -> None:
|
||||
fieldnames = [
|
||||
"row",
|
||||
"col",
|
||||
"easting_m",
|
||||
"northing_m",
|
||||
"latitude",
|
||||
"longitude",
|
||||
"elevation_m",
|
||||
"raster_id",
|
||||
"resolution_m",
|
||||
]
|
||||
with output_path.open("w", newline="") as output_file:
|
||||
writer = csv.DictWriter(output_file, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
for sample in samples:
|
||||
writer.writerow({field: sample.get(field, "") for field in fieldnames})
|
||||
|
||||
|
||||
def write_r16(samples: list[dict], output_path: Path) -> tuple[float, float]:
|
||||
elevations = [sample["elevation_m"] for sample in samples]
|
||||
min_elevation = min(elevations)
|
||||
max_elevation = max(elevations)
|
||||
elevation_range = max(max_elevation - min_elevation, 0.001)
|
||||
|
||||
with output_path.open("wb") as output_file:
|
||||
for sample in samples:
|
||||
normalized = (sample["elevation_m"] - min_elevation) / elevation_range
|
||||
output_file.write(struct.pack("<H", round(normalized * 65535)))
|
||||
|
||||
return min_elevation, max_elevation
|
||||
|
||||
|
||||
def write_metadata(tile: dict, samples: list[dict], grid_size: int, min_elevation: float, max_elevation: float, output_path: Path) -> None:
|
||||
raster_ids = sorted({sample["raster_id"] for sample in samples if sample.get("raster_id") is not None})
|
||||
resolutions = sorted({sample["resolution_m"] for sample in samples if sample.get("resolution_m") is not None})
|
||||
metadata = {
|
||||
"tile_id": tile["tile_id"],
|
||||
"generated_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"source": {
|
||||
"name": "USGS Elevation Point Query Service",
|
||||
"url": USGS_EPQS_URL,
|
||||
"units": "Meters",
|
||||
"raster_ids": raster_ids,
|
||||
"reported_resolutions_m": resolutions,
|
||||
},
|
||||
"grid": tile["grid"],
|
||||
"sample_grid": {
|
||||
"width": grid_size,
|
||||
"height": grid_size,
|
||||
"spacing_m": tile["grid"]["tile_size_m"] / (grid_size - 1),
|
||||
"sample_count": len(samples),
|
||||
},
|
||||
"heightmap": {
|
||||
"format": "r16_little_endian_unsigned",
|
||||
"min_elevation_m": min_elevation,
|
||||
"max_elevation_m": max_elevation,
|
||||
"vertical_range_m": max_elevation - min_elevation,
|
||||
},
|
||||
"unreal_import_notes": [
|
||||
"Prototype only; final landscape import should use a higher-resolution DEM raster or lidar-derived terrain.",
|
||||
"R16 values are normalized from min_elevation_m to max_elevation_m.",
|
||||
"Use the metadata min/max values to restore vertical scale during Unreal import.",
|
||||
"Horizontal tile size is 1000 m x 1000 m."
|
||||
],
|
||||
}
|
||||
output_path.write_text(json.dumps(metadata, indent=2) + "\n")
|
||||
|
||||
|
||||
def update_registry(tile_id: str) -> None:
|
||||
registry = json.loads(REGISTRY_PATH.read_text())
|
||||
for tile in registry["tiles"]:
|
||||
if tile["tile_id"] == tile_id:
|
||||
tile["status"] = "generated"
|
||||
tile["generation_version"] = max(tile.get("generation_version", 0), 1)
|
||||
tile["package_version"] = max(tile.get("package_version", 0), 0)
|
||||
tile["notes"] = (
|
||||
"Prototype USGS EPQS elevation heightmap generated. "
|
||||
"Use Data/Terrain/Generated/gz_us_ca_pacifica_utm10n_e544_n4160 for samples, R16 heightmap, and metadata."
|
||||
)
|
||||
break
|
||||
else:
|
||||
raise RuntimeError(f"Could not update missing tile {tile_id}")
|
||||
|
||||
REGISTRY_PATH.write_text(json.dumps(registry, indent=2) + "\n")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--grid-size", type=int, default=33, help="Sample grid width/height. Default: 33")
|
||||
parser.add_argument("--workers", type=int, default=8, help="Concurrent USGS requests. Default: 8")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.grid_size < 2:
|
||||
raise SystemExit("--grid-size must be at least 2")
|
||||
|
||||
tile = load_ground_zero_tile()
|
||||
points = build_sample_points(tile, args.grid_size)
|
||||
output_dir = OUTPUT_ROOT / tile["tile_id"]
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
samples = sample_elevations(points, args.workers)
|
||||
csv_path = output_dir / f"{tile['tile_id']}_elevation_samples_{args.grid_size}.csv"
|
||||
r16_path = output_dir / f"{tile['tile_id']}_heightmap_{args.grid_size}.r16"
|
||||
metadata_path = output_dir / f"{tile['tile_id']}_terrain_metadata.json"
|
||||
|
||||
write_csv(samples, csv_path)
|
||||
min_elevation, max_elevation = write_r16(samples, r16_path)
|
||||
write_metadata(tile, samples, args.grid_size, min_elevation, max_elevation, metadata_path)
|
||||
update_registry(tile["tile_id"])
|
||||
|
||||
print(f"Generated {len(samples)} samples for {tile['tile_id']}")
|
||||
print(f"Elevation range: {min_elevation:.3f}m to {max_elevation:.3f}m")
|
||||
print(f"CSV: {csv_path}")
|
||||
print(f"R16: {r16_path}")
|
||||
print(f"Metadata: {metadata_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,77 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import unreal
|
||||
|
||||
|
||||
MAP_PATH = "/Game/Agrarian/Maps/L_GroundZeroTerrain_Test"
|
||||
PROJECT_ROOT = Path(r"Z:\AgrarianGameBulid")
|
||||
TILE_ID = "gz_us_ca_pacifica_utm10n_e544_n4160"
|
||||
UNREAL_TERRAIN_ROOT = PROJECT_ROOT / "Data" / "Terrain" / "Unreal" / TILE_ID
|
||||
HEIGHTMAP_PATH = UNREAL_TERRAIN_ROOT / f"{TILE_ID}_unreal_1009.r16"
|
||||
METADATA_PATH = UNREAL_TERRAIN_ROOT / f"{TILE_ID}_unreal_heightmap_metadata.json"
|
||||
LANDSCAPE_LABEL = "AGR_GroundZero_Landscape"
|
||||
|
||||
|
||||
def get_actor_label(actor):
|
||||
try:
|
||||
return actor.get_actor_label()
|
||||
except Exception:
|
||||
return actor.get_name()
|
||||
|
||||
|
||||
def remove_existing_landscape():
|
||||
for actor in unreal.EditorLevelLibrary.get_all_level_actors():
|
||||
if get_actor_label(actor) == LANDSCAPE_LABEL:
|
||||
unreal.EditorLevelLibrary.destroy_actor(actor)
|
||||
|
||||
|
||||
def create_or_load_map():
|
||||
unreal.EditorAssetLibrary.make_directory("/Game/Agrarian/Maps")
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(MAP_PATH):
|
||||
if not unreal.EditorLevelLibrary.load_level(MAP_PATH):
|
||||
raise RuntimeError(f"Could not load map: {MAP_PATH}")
|
||||
return
|
||||
|
||||
level_subsystem = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
||||
if hasattr(level_subsystem, "new_level"):
|
||||
if not level_subsystem.new_level(MAP_PATH):
|
||||
raise RuntimeError(f"Could not create map: {MAP_PATH}")
|
||||
return
|
||||
|
||||
if hasattr(unreal.EditorLevelLibrary, "new_level"):
|
||||
if not unreal.EditorLevelLibrary.new_level(MAP_PATH):
|
||||
raise RuntimeError(f"Could not create map: {MAP_PATH}")
|
||||
return
|
||||
|
||||
raise RuntimeError("No supported Unreal Python API found for creating a level")
|
||||
|
||||
|
||||
def main():
|
||||
create_or_load_map()
|
||||
remove_existing_landscape()
|
||||
|
||||
with METADATA_PATH.open("r", encoding="utf-8") as metadata_file:
|
||||
metadata = json.load(metadata_file)
|
||||
|
||||
import_settings = metadata["unreal_landscape_import"]
|
||||
heightmap = metadata["heightmap"]
|
||||
result = unreal.AgrarianEditorAutomationLibrary.import_landscape_heightmap_into_editor_world(
|
||||
str(HEIGHTMAP_PATH),
|
||||
int(heightmap["width"]),
|
||||
int(heightmap["height"]),
|
||||
float(import_settings["x_scale_cm"]),
|
||||
float(import_settings["y_scale_cm"]),
|
||||
float(import_settings["z_scale_cm"]),
|
||||
LANDSCAPE_LABEL,
|
||||
)
|
||||
|
||||
if not str(result).startswith("PASS:"):
|
||||
raise RuntimeError(str(result))
|
||||
|
||||
unreal.log(str(result))
|
||||
unreal.EditorLevelLibrary.save_current_level()
|
||||
unreal.log(f"Ground Zero terrain map saved: {MAP_PATH}")
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,164 @@
|
||||
import unreal
|
||||
|
||||
|
||||
ITEM_FOLDER = "/Game/Agrarian/DataAssets/Items"
|
||||
|
||||
ITEMS = [
|
||||
{
|
||||
"asset": "DA_Item_Wood",
|
||||
"item_id": "wood",
|
||||
"display_name": "Wood",
|
||||
"description": "Usable branches, logs, and rough-cut timber for fires, tools, and early structures.",
|
||||
"item_type": unreal.AgrarianItemType.RESOURCE,
|
||||
"unit_weight": 1.0,
|
||||
"max_stack_size": 99,
|
||||
},
|
||||
{
|
||||
"asset": "DA_Item_Stone",
|
||||
"item_id": "stone",
|
||||
"display_name": "Stone",
|
||||
"description": "Field stone suitable for crude tools, fire rings, and primitive construction.",
|
||||
"item_type": unreal.AgrarianItemType.RESOURCE,
|
||||
"unit_weight": 1.5,
|
||||
"max_stack_size": 99,
|
||||
},
|
||||
{
|
||||
"asset": "DA_Item_Fiber",
|
||||
"item_id": "fiber",
|
||||
"display_name": "Fiber",
|
||||
"description": "Plant fiber that can be twisted, woven, or bound into survival gear and simple structures.",
|
||||
"item_type": unreal.AgrarianItemType.RESOURCE,
|
||||
"unit_weight": 0.1,
|
||||
"max_stack_size": 99,
|
||||
},
|
||||
{
|
||||
"asset": "DA_Item_Food",
|
||||
"item_id": "food",
|
||||
"display_name": "Food",
|
||||
"description": "Foraged edible food that can stave off hunger in the earliest survival loop.",
|
||||
"item_type": unreal.AgrarianItemType.FOOD,
|
||||
"unit_weight": 0.3,
|
||||
"max_stack_size": 50,
|
||||
},
|
||||
{
|
||||
"asset": "DA_Item_Meat",
|
||||
"item_id": "meat",
|
||||
"display_name": "Meat",
|
||||
"description": "Raw harvested meat that should be cooked or preserved before long-term use.",
|
||||
"item_type": unreal.AgrarianItemType.FOOD,
|
||||
"unit_weight": 0.5,
|
||||
"max_stack_size": 50,
|
||||
},
|
||||
{
|
||||
"asset": "DA_Item_Hide",
|
||||
"item_id": "hide",
|
||||
"display_name": "Hide",
|
||||
"description": "Animal hide used for shelter, clothing, containers, and other early craft work.",
|
||||
"item_type": unreal.AgrarianItemType.RESOURCE,
|
||||
"unit_weight": 0.7,
|
||||
"max_stack_size": 50,
|
||||
},
|
||||
{
|
||||
"asset": "DA_Item_PrimitiveFrame",
|
||||
"item_id": "primitive_frame",
|
||||
"display_name": "Primitive Frame",
|
||||
"description": "A lashed support frame used as the backbone for early shelters and simple buildables.",
|
||||
"item_type": unreal.AgrarianItemType.STRUCTURE,
|
||||
"unit_weight": 3.0,
|
||||
"max_stack_size": 20,
|
||||
},
|
||||
{
|
||||
"asset": "DA_Item_PrimitiveWallPanel",
|
||||
"item_id": "primitive_wall_panel",
|
||||
"display_name": "Primitive Wall Panel",
|
||||
"description": "A crude wall panel built from wood and fiber for early shelter construction.",
|
||||
"item_type": unreal.AgrarianItemType.STRUCTURE,
|
||||
"unit_weight": 4.0,
|
||||
"max_stack_size": 20,
|
||||
},
|
||||
{
|
||||
"asset": "DA_Item_PrimitiveRoofPanel",
|
||||
"item_id": "primitive_roof_panel",
|
||||
"display_name": "Primitive Roof Panel",
|
||||
"description": "A simple roof panel made from lashed branches, fiber, and cover material.",
|
||||
"item_type": unreal.AgrarianItemType.STRUCTURE,
|
||||
"unit_weight": 4.0,
|
||||
"max_stack_size": 20,
|
||||
},
|
||||
{
|
||||
"asset": "DA_Item_Campfire",
|
||||
"item_id": "campfire",
|
||||
"display_name": "Campfire",
|
||||
"description": "A placeable fire ring for warmth, light, and early cooking.",
|
||||
"item_type": unreal.AgrarianItemType.STRUCTURE,
|
||||
"unit_weight": 12.0,
|
||||
"max_stack_size": 5,
|
||||
},
|
||||
{
|
||||
"asset": "DA_Item_PrimitiveShelter",
|
||||
"item_id": "primitive_shelter",
|
||||
"display_name": "Primitive Shelter",
|
||||
"description": "A compact early shelter that provides basic weather protection.",
|
||||
"item_type": unreal.AgrarianItemType.STRUCTURE,
|
||||
"unit_weight": 25.0,
|
||||
"max_stack_size": 1,
|
||||
},
|
||||
{
|
||||
"asset": "DA_Item_BasicTool",
|
||||
"item_id": "basic_tool",
|
||||
"display_name": "Basic Tool",
|
||||
"description": "A crude stone-and-wood tool for the first gather and build loops.",
|
||||
"item_type": unreal.AgrarianItemType.TOOL,
|
||||
"unit_weight": 1.2,
|
||||
"max_stack_size": 10,
|
||||
},
|
||||
{
|
||||
"asset": "DA_Item_Bandage",
|
||||
"item_id": "bandage",
|
||||
"display_name": "Bandage",
|
||||
"description": "A simple treatment item made from hide and fiber.",
|
||||
"item_type": unreal.AgrarianItemType.MEDICINE,
|
||||
"unit_weight": 0.1,
|
||||
"max_stack_size": 20,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def create_or_load_item_asset(asset_name):
|
||||
path = f"{ITEM_FOLDER}/{asset_name}"
|
||||
existing = unreal.EditorAssetLibrary.load_asset(path)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
factory = unreal.DataAssetFactory()
|
||||
factory.set_editor_property("data_asset_class", unreal.AgrarianItemDefinitionAsset)
|
||||
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
|
||||
asset = asset_tools.create_asset(asset_name, ITEM_FOLDER, unreal.AgrarianItemDefinitionAsset, factory)
|
||||
if not asset:
|
||||
raise RuntimeError(f"Could not create {path}")
|
||||
return asset
|
||||
|
||||
|
||||
def apply_definition(asset, item):
|
||||
definition = unreal.AgrarianItemDefinition()
|
||||
definition.set_editor_property("item_id", item["item_id"])
|
||||
definition.set_editor_property("display_name", item["display_name"])
|
||||
definition.set_editor_property("description", item["description"])
|
||||
definition.set_editor_property("item_type", item["item_type"])
|
||||
definition.set_editor_property("unit_weight", item["unit_weight"])
|
||||
definition.set_editor_property("max_stack_size", item["max_stack_size"])
|
||||
asset.set_editor_property("definition", definition)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
|
||||
|
||||
def main():
|
||||
unreal.EditorAssetLibrary.make_directory(ITEM_FOLDER)
|
||||
for item in ITEMS:
|
||||
asset = create_or_load_item_asset(item["asset"])
|
||||
apply_definition(asset, item)
|
||||
unreal.log(f"Configured item definition: {item['item_id']} -> {ITEM_FOLDER}/{item['asset']}")
|
||||
|
||||
unreal.log("Agrarian item definition setup complete.")
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,166 @@
|
||||
import unreal
|
||||
|
||||
|
||||
BLUEPRINT_ROOT = "/Game/Agrarian/Blueprints"
|
||||
RESOURCE_FOLDER = f"{BLUEPRINT_ROOT}/Resources"
|
||||
STRUCTURE_FOLDER = f"{BLUEPRINT_ROOT}/Structures"
|
||||
WILDLIFE_FOLDER = f"{BLUEPRINT_ROOT}/Wildlife"
|
||||
|
||||
WOOD_ITEM_PATH = "/Game/Agrarian/DataAssets/Items/DA_Item_Wood"
|
||||
FIBER_ITEM_PATH = "/Game/Agrarian/DataAssets/Items/DA_Item_Fiber"
|
||||
|
||||
MESH_CUBE_PATH = "/Game/LevelPrototyping/Meshes/SM_Cube"
|
||||
MESH_CYLINDER_PATH = "/Game/LevelPrototyping/Meshes/SM_Cylinder"
|
||||
|
||||
BLUEPRINTS = [
|
||||
{
|
||||
"asset": "BP_WoodResourceNode",
|
||||
"folder": RESOURCE_FOLDER,
|
||||
"parent": unreal.AgrarianResourceNode,
|
||||
"defaults": {
|
||||
"yield_item_definition": WOOD_ITEM_PATH,
|
||||
"remaining_harvests": 16,
|
||||
"quantity_per_harvest": 2,
|
||||
},
|
||||
"mesh": MESH_CUBE_PATH,
|
||||
"scale": unreal.Vector(1.0, 1.0, 1.5),
|
||||
},
|
||||
{
|
||||
"asset": "BP_FiberResourceNode",
|
||||
"folder": RESOURCE_FOLDER,
|
||||
"parent": unreal.AgrarianResourceNode,
|
||||
"defaults": {
|
||||
"yield_item_definition": FIBER_ITEM_PATH,
|
||||
"remaining_harvests": 10,
|
||||
"quantity_per_harvest": 3,
|
||||
},
|
||||
"mesh": MESH_CYLINDER_PATH,
|
||||
"scale": unreal.Vector(0.8, 0.8, 1.0),
|
||||
},
|
||||
{
|
||||
"asset": "BP_Campfire",
|
||||
"folder": STRUCTURE_FOLDER,
|
||||
"parent": unreal.AgrarianCampfire,
|
||||
"defaults": {
|
||||
"fuel_seconds": 180.0,
|
||||
"warmth_radius": 650.0,
|
||||
"warmth_per_second": 0.03,
|
||||
},
|
||||
"mesh": MESH_CYLINDER_PATH,
|
||||
"scale": unreal.Vector(1.3, 1.3, 0.25),
|
||||
},
|
||||
{
|
||||
"asset": "BP_PrimitiveShelter",
|
||||
"folder": STRUCTURE_FOLDER,
|
||||
"parent": unreal.AgrarianShelterActor,
|
||||
"defaults": {
|
||||
"weather_protection": 0.7,
|
||||
},
|
||||
"mesh": MESH_CUBE_PATH,
|
||||
"scale": unreal.Vector(3.0, 2.0, 1.4),
|
||||
},
|
||||
{
|
||||
"asset": "BP_RabbitWildlife",
|
||||
"folder": WILDLIFE_FOLDER,
|
||||
"parent": unreal.AgrarianWildlifeBase,
|
||||
"defaults": {
|
||||
"wildlife_id": "rabbit",
|
||||
"display_name": "Rabbit",
|
||||
"max_health": 12.0,
|
||||
"health": 12.0,
|
||||
"wander_radius": 900.0,
|
||||
"wander_speed": 160.0,
|
||||
"flee_speed": 520.0,
|
||||
"aggro_radius": 0.0,
|
||||
"flee_radius": 750.0,
|
||||
"decision_interval_seconds": 1.5,
|
||||
"harvest_yields": [
|
||||
{
|
||||
"item_id": "meat",
|
||||
"display_name": "Meat",
|
||||
"quantity": 1,
|
||||
"unit_weight": 0.5,
|
||||
},
|
||||
{
|
||||
"item_id": "hide",
|
||||
"display_name": "Hide",
|
||||
"quantity": 2,
|
||||
"unit_weight": 0.7,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def load_required_asset(path):
|
||||
asset = unreal.EditorAssetLibrary.load_asset(path)
|
||||
if not asset:
|
||||
raise RuntimeError(f"Required asset not found: {path}")
|
||||
return asset
|
||||
|
||||
|
||||
def create_or_load_blueprint(asset_name, folder, parent_class):
|
||||
path = f"{folder}/{asset_name}"
|
||||
existing = unreal.EditorAssetLibrary.load_asset(path)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
factory = unreal.BlueprintFactory()
|
||||
factory.set_editor_property("parent_class", parent_class)
|
||||
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
|
||||
blueprint = asset_tools.create_asset(asset_name, folder, None, factory)
|
||||
if not blueprint:
|
||||
raise RuntimeError(f"Could not create {path}")
|
||||
return blueprint
|
||||
|
||||
|
||||
def make_stack(data):
|
||||
stack = unreal.AgrarianItemStack()
|
||||
stack.set_editor_property("item_id", data["item_id"])
|
||||
stack.set_editor_property("display_name", data["display_name"])
|
||||
stack.set_editor_property("quantity", data["quantity"])
|
||||
stack.set_editor_property("unit_weight", data["unit_weight"])
|
||||
return stack
|
||||
|
||||
|
||||
def apply_defaults(blueprint, config):
|
||||
blueprint_path = f"{config['folder']}/{config['asset']}"
|
||||
unreal.BlueprintEditorLibrary.compile_blueprint(blueprint)
|
||||
generated_class = unreal.EditorAssetLibrary.load_blueprint_class(blueprint_path)
|
||||
if not generated_class:
|
||||
raise RuntimeError(f"Could not load generated class for {blueprint_path}")
|
||||
|
||||
cdo = unreal.get_default_object(generated_class)
|
||||
|
||||
for property_name, value in config.get("defaults", {}).items():
|
||||
if property_name == "yield_item_definition":
|
||||
value = load_required_asset(value)
|
||||
elif property_name == "harvest_yields":
|
||||
value = [make_stack(stack_data) for stack_data in value]
|
||||
|
||||
cdo.set_editor_property(property_name, value)
|
||||
|
||||
mesh_path = config.get("mesh")
|
||||
if mesh_path:
|
||||
mesh_component = cdo.get_editor_property("mesh")
|
||||
mesh_component.set_editor_property("static_mesh", load_required_asset(mesh_path))
|
||||
mesh_component.set_editor_property("relative_scale3d", config.get("scale", unreal.Vector(1.0, 1.0, 1.0)))
|
||||
|
||||
unreal.BlueprintEditorLibrary.compile_blueprint(blueprint)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(blueprint)
|
||||
|
||||
|
||||
def main():
|
||||
for folder in (RESOURCE_FOLDER, STRUCTURE_FOLDER, WILDLIFE_FOLDER):
|
||||
unreal.EditorAssetLibrary.make_directory(folder)
|
||||
|
||||
for config in BLUEPRINTS:
|
||||
blueprint = create_or_load_blueprint(config["asset"], config["folder"], config["parent"])
|
||||
apply_defaults(blueprint, config)
|
||||
unreal.log(f"Configured Blueprint: {config['folder']}/{config['asset']}")
|
||||
|
||||
unreal.log("Agrarian playable Blueprint setup complete.")
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,156 @@
|
||||
import unreal
|
||||
|
||||
|
||||
RECIPE_FOLDER = "/Game/Agrarian/DataAssets/Recipes"
|
||||
|
||||
ITEM_DISPLAY = {
|
||||
"wood": ("Wood", 1.0),
|
||||
"stone": ("Stone", 1.5),
|
||||
"fiber": ("Fiber", 0.1),
|
||||
"hide": ("Hide", 0.7),
|
||||
"primitive_frame": ("Primitive Frame", 3.0),
|
||||
"primitive_wall_panel": ("Primitive Wall Panel", 4.0),
|
||||
"primitive_roof_panel": ("Primitive Roof Panel", 4.0),
|
||||
"campfire": ("Campfire", 12.0),
|
||||
"primitive_shelter": ("Primitive Shelter", 25.0),
|
||||
"basic_tool": ("Basic Tool", 1.2),
|
||||
"bandage": ("Bandage", 0.1),
|
||||
}
|
||||
|
||||
RECIPES = [
|
||||
{
|
||||
"asset": "DA_Recipe_Campfire",
|
||||
"recipe_id": "campfire",
|
||||
"display_name": "Campfire",
|
||||
"ingredients": [("wood", 5), ("stone", 8), ("fiber", 2)],
|
||||
"result": ("campfire", 1),
|
||||
"craft_seconds": 8.0,
|
||||
"requires_campfire": False,
|
||||
},
|
||||
{
|
||||
"asset": "DA_Recipe_PrimitiveShelter",
|
||||
"recipe_id": "primitive_shelter",
|
||||
"display_name": "Primitive Shelter",
|
||||
"ingredients": [
|
||||
("primitive_frame", 2),
|
||||
("primitive_wall_panel", 4),
|
||||
("primitive_roof_panel", 2),
|
||||
("hide", 2),
|
||||
("fiber", 6),
|
||||
],
|
||||
"result": ("primitive_shelter", 1),
|
||||
"craft_seconds": 20.0,
|
||||
"requires_campfire": False,
|
||||
},
|
||||
{
|
||||
"asset": "DA_Recipe_PrimitiveFrame",
|
||||
"recipe_id": "primitive_frame",
|
||||
"display_name": "Primitive Frame",
|
||||
"ingredients": [("wood", 4), ("fiber", 2)],
|
||||
"result": ("primitive_frame", 1),
|
||||
"craft_seconds": 8.0,
|
||||
"requires_campfire": False,
|
||||
},
|
||||
{
|
||||
"asset": "DA_Recipe_PrimitiveWallPanel",
|
||||
"recipe_id": "primitive_wall_panel",
|
||||
"display_name": "Primitive Wall Panel",
|
||||
"ingredients": [("wood", 3), ("fiber", 2)],
|
||||
"result": ("primitive_wall_panel", 1),
|
||||
"craft_seconds": 6.0,
|
||||
"requires_campfire": False,
|
||||
},
|
||||
{
|
||||
"asset": "DA_Recipe_PrimitiveRoofPanel",
|
||||
"recipe_id": "primitive_roof_panel",
|
||||
"display_name": "Primitive Roof Panel",
|
||||
"ingredients": [("wood", 3), ("fiber", 3)],
|
||||
"result": ("primitive_roof_panel", 1),
|
||||
"craft_seconds": 7.0,
|
||||
"requires_campfire": False,
|
||||
},
|
||||
{
|
||||
"asset": "DA_Recipe_BasicTool",
|
||||
"recipe_id": "basic_tool",
|
||||
"display_name": "Basic Tool",
|
||||
"ingredients": [("wood", 1), ("stone", 2), ("fiber", 1)],
|
||||
"result": ("basic_tool", 1),
|
||||
"craft_seconds": 6.0,
|
||||
"requires_campfire": False,
|
||||
},
|
||||
{
|
||||
"asset": "DA_Recipe_Bandage",
|
||||
"recipe_id": "bandage",
|
||||
"display_name": "Bandage",
|
||||
"ingredients": [("fiber", 3), ("hide", 1)],
|
||||
"result": ("bandage", 1),
|
||||
"craft_seconds": 4.0,
|
||||
"requires_campfire": False,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def make_stack(item_id, quantity):
|
||||
display_name, unit_weight = ITEM_DISPLAY[item_id]
|
||||
stack = unreal.AgrarianItemStack()
|
||||
stack.set_editor_property("item_id", item_id)
|
||||
stack.set_editor_property("display_name", display_name)
|
||||
stack.set_editor_property("quantity", quantity)
|
||||
stack.set_editor_property("unit_weight", unit_weight)
|
||||
return stack
|
||||
|
||||
|
||||
def set_requires_campfire(recipe, value):
|
||||
for property_name in ("requires_campfire", "b_requires_campfire"):
|
||||
try:
|
||||
recipe.set_editor_property(property_name, value)
|
||||
return
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if value:
|
||||
raise RuntimeError("Could not set bRequiresCampfire on recipe asset")
|
||||
|
||||
|
||||
def create_or_load_recipe_asset(asset_name):
|
||||
path = f"{RECIPE_FOLDER}/{asset_name}"
|
||||
existing = unreal.EditorAssetLibrary.load_asset(path)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
factory = unreal.DataAssetFactory()
|
||||
factory.set_editor_property("data_asset_class", unreal.AgrarianRecipeDataAsset)
|
||||
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
|
||||
asset = asset_tools.create_asset(asset_name, RECIPE_FOLDER, unreal.AgrarianRecipeDataAsset, factory)
|
||||
if not asset:
|
||||
raise RuntimeError(f"Could not create {path}")
|
||||
return asset
|
||||
|
||||
|
||||
def apply_recipe(asset, recipe_data):
|
||||
recipe = unreal.AgrarianRecipe()
|
||||
recipe.set_editor_property("recipe_id", recipe_data["recipe_id"])
|
||||
recipe.set_editor_property("display_name", recipe_data["display_name"])
|
||||
recipe.set_editor_property(
|
||||
"ingredients",
|
||||
[make_stack(item_id, quantity) for item_id, quantity in recipe_data["ingredients"]],
|
||||
)
|
||||
result_id, result_quantity = recipe_data["result"]
|
||||
recipe.set_editor_property("result", make_stack(result_id, result_quantity))
|
||||
recipe.set_editor_property("craft_seconds", recipe_data["craft_seconds"])
|
||||
set_requires_campfire(recipe, recipe_data["requires_campfire"])
|
||||
asset.set_editor_property("recipe", recipe)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
|
||||
|
||||
def main():
|
||||
unreal.EditorAssetLibrary.make_directory(RECIPE_FOLDER)
|
||||
for recipe_data in RECIPES:
|
||||
asset = create_or_load_recipe_asset(recipe_data["asset"])
|
||||
apply_recipe(asset, recipe_data)
|
||||
unreal.log(f"Configured recipe: {recipe_data['recipe_id']} -> {RECIPE_FOLDER}/{recipe_data['asset']}")
|
||||
|
||||
unreal.log("Agrarian recipe setup complete.")
|
||||
|
||||
|
||||
main()
|
||||
@@ -10,6 +10,12 @@ PLACEMENTS = [
|
||||
"location": unreal.Vector(650.0, -150.0, 120.0),
|
||||
"rotation": unreal.Rotator(0.0, 15.0, 0.0),
|
||||
},
|
||||
{
|
||||
"label": "AGR_FiberResourceNode_01",
|
||||
"class_path": "/Game/Agrarian/Blueprints/Resources/BP_FiberResourceNode",
|
||||
"location": unreal.Vector(560.0, 140.0, 90.0),
|
||||
"rotation": unreal.Rotator(0.0, -10.0, 0.0),
|
||||
},
|
||||
{
|
||||
"label": "AGR_Campfire_01",
|
||||
"class_path": "/Game/Agrarian/Blueprints/Structures/BP_Campfire",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import unreal
|
||||
|
||||
|
||||
MAP_PATH = "/Game/Agrarian/Maps/L_GroundZeroTerrain_Test"
|
||||
PROJECT_ROOT = Path(r"Z:\AgrarianGameBulid")
|
||||
TILE_ID = "gz_us_ca_pacifica_utm10n_e544_n4160"
|
||||
METADATA_PATH = PROJECT_ROOT / "Data" / "Terrain" / "Unreal" / TILE_ID / f"{TILE_ID}_unreal_heightmap_metadata.json"
|
||||
LANDSCAPE_LABEL = "AGR_GroundZero_Landscape"
|
||||
|
||||
|
||||
def nearly_equal(left, right, tolerance=0.01):
|
||||
return abs(float(left) - float(right)) <= tolerance
|
||||
|
||||
|
||||
def get_actor_label(actor):
|
||||
try:
|
||||
return actor.get_actor_label()
|
||||
except Exception:
|
||||
return actor.get_name()
|
||||
|
||||
|
||||
def main():
|
||||
if not unreal.EditorLevelLibrary.load_level(MAP_PATH):
|
||||
raise RuntimeError(f"Could not load map: {MAP_PATH}")
|
||||
|
||||
with METADATA_PATH.open("r", encoding="utf-8") as metadata_file:
|
||||
metadata = json.load(metadata_file)
|
||||
|
||||
expected = metadata["unreal_landscape_import"]
|
||||
actors = unreal.EditorLevelLibrary.get_all_level_actors()
|
||||
landscapes = [actor for actor in actors if get_actor_label(actor) == LANDSCAPE_LABEL]
|
||||
if len(landscapes) != 1:
|
||||
raise RuntimeError(f"Expected exactly one {LANDSCAPE_LABEL}, found {len(landscapes)}")
|
||||
|
||||
landscape = landscapes[0]
|
||||
scale = landscape.get_actor_scale3d()
|
||||
failures = []
|
||||
if not nearly_equal(scale.x, expected["x_scale_cm"]):
|
||||
failures.append(f"X scale expected {expected['x_scale_cm']}, got {scale.x}")
|
||||
if not nearly_equal(scale.y, expected["y_scale_cm"]):
|
||||
failures.append(f"Y scale expected {expected['y_scale_cm']}, got {scale.y}")
|
||||
if not nearly_equal(scale.z, expected["z_scale_cm"]):
|
||||
failures.append(f"Z scale expected {expected['z_scale_cm']}, got {scale.z}")
|
||||
|
||||
bounds_origin, bounds_extent = landscape.get_actor_bounds(False)
|
||||
expected_extent = float(expected["tile_world_size_m"]) * 100.0 * 0.5
|
||||
if not nearly_equal(bounds_extent.x, expected_extent, tolerance=150.0):
|
||||
failures.append(f"X extent expected about {expected_extent}, got {bounds_extent.x}")
|
||||
if not nearly_equal(bounds_extent.y, expected_extent, tolerance=150.0):
|
||||
failures.append(f"Y extent expected about {expected_extent}, got {bounds_extent.y}")
|
||||
if abs(bounds_origin.x) > 150.0 or abs(bounds_origin.y) > 150.0:
|
||||
failures.append(f"Bounds origin expected near XY zero, got {bounds_origin}")
|
||||
|
||||
if failures:
|
||||
raise RuntimeError("Ground Zero terrain verification failed: " + "; ".join(failures))
|
||||
|
||||
unreal.log(
|
||||
"Ground Zero terrain verification complete: "
|
||||
f"scale={scale}, bounds_origin={bounds_origin}, bounds_extent={bounds_extent}"
|
||||
)
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,60 @@
|
||||
import unreal
|
||||
|
||||
|
||||
ITEM_FOLDER = "/Game/Agrarian/DataAssets/Items"
|
||||
|
||||
EXPECTED_ITEMS = {
|
||||
"DA_Item_Wood": ("wood", unreal.AgrarianItemType.RESOURCE),
|
||||
"DA_Item_Stone": ("stone", unreal.AgrarianItemType.RESOURCE),
|
||||
"DA_Item_Fiber": ("fiber", unreal.AgrarianItemType.RESOURCE),
|
||||
"DA_Item_Food": ("food", unreal.AgrarianItemType.FOOD),
|
||||
"DA_Item_Meat": ("meat", unreal.AgrarianItemType.FOOD),
|
||||
"DA_Item_Hide": ("hide", unreal.AgrarianItemType.RESOURCE),
|
||||
"DA_Item_PrimitiveFrame": ("primitive_frame", unreal.AgrarianItemType.STRUCTURE),
|
||||
"DA_Item_PrimitiveWallPanel": ("primitive_wall_panel", unreal.AgrarianItemType.STRUCTURE),
|
||||
"DA_Item_PrimitiveRoofPanel": ("primitive_roof_panel", unreal.AgrarianItemType.STRUCTURE),
|
||||
"DA_Item_Campfire": ("campfire", unreal.AgrarianItemType.STRUCTURE),
|
||||
"DA_Item_PrimitiveShelter": ("primitive_shelter", unreal.AgrarianItemType.STRUCTURE),
|
||||
"DA_Item_BasicTool": ("basic_tool", unreal.AgrarianItemType.TOOL),
|
||||
"DA_Item_Bandage": ("bandage", unreal.AgrarianItemType.MEDICINE),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
missing = []
|
||||
|
||||
for asset_name, (expected_item_id, expected_type) in EXPECTED_ITEMS.items():
|
||||
path = f"{ITEM_FOLDER}/{asset_name}"
|
||||
asset = unreal.EditorAssetLibrary.load_asset(path)
|
||||
if not asset:
|
||||
missing.append(f"{path} missing")
|
||||
continue
|
||||
|
||||
definition = asset.get_editor_property("definition")
|
||||
item_id = str(definition.get_editor_property("item_id"))
|
||||
display_name = str(definition.get_editor_property("display_name"))
|
||||
description = str(definition.get_editor_property("description"))
|
||||
item_type = definition.get_editor_property("item_type")
|
||||
unit_weight = definition.get_editor_property("unit_weight")
|
||||
max_stack_size = definition.get_editor_property("max_stack_size")
|
||||
|
||||
if item_id != expected_item_id:
|
||||
missing.append(f"{path} item_id expected {expected_item_id}, got {item_id}")
|
||||
if item_type != expected_type:
|
||||
missing.append(f"{path} type expected {expected_type}, got {item_type}")
|
||||
if not display_name:
|
||||
missing.append(f"{path} display_name empty")
|
||||
if not description:
|
||||
missing.append(f"{path} description empty")
|
||||
if unit_weight <= 0.0:
|
||||
missing.append(f"{path} unit_weight must be positive")
|
||||
if max_stack_size < 1:
|
||||
missing.append(f"{path} max_stack_size must be positive")
|
||||
|
||||
if missing:
|
||||
raise RuntimeError("Item definition verification failed: " + "; ".join(missing))
|
||||
|
||||
unreal.log("Agrarian item definition verification complete.")
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,95 @@
|
||||
import unreal
|
||||
|
||||
|
||||
EXPECTED = {
|
||||
"/Game/Agrarian/Blueprints/Resources/BP_WoodResourceNode": {
|
||||
"properties": {
|
||||
"remaining_harvests": 16,
|
||||
"quantity_per_harvest": 2,
|
||||
},
|
||||
"yield_item_id": "wood",
|
||||
},
|
||||
"/Game/Agrarian/Blueprints/Resources/BP_FiberResourceNode": {
|
||||
"properties": {
|
||||
"remaining_harvests": 10,
|
||||
"quantity_per_harvest": 3,
|
||||
},
|
||||
"yield_item_id": "fiber",
|
||||
},
|
||||
"/Game/Agrarian/Blueprints/Structures/BP_Campfire": {
|
||||
"properties": {
|
||||
"fuel_seconds": 180.0,
|
||||
"warmth_radius": 650.0,
|
||||
"warmth_per_second": 0.03,
|
||||
},
|
||||
},
|
||||
"/Game/Agrarian/Blueprints/Structures/BP_PrimitiveShelter": {
|
||||
"properties": {
|
||||
"weather_protection": 0.7,
|
||||
},
|
||||
},
|
||||
"/Game/Agrarian/Blueprints/Wildlife/BP_RabbitWildlife": {
|
||||
"properties": {
|
||||
"wildlife_id": "rabbit",
|
||||
"max_health": 12.0,
|
||||
"health": 12.0,
|
||||
"wander_radius": 900.0,
|
||||
"wander_speed": 160.0,
|
||||
"flee_speed": 520.0,
|
||||
"aggro_radius": 0.0,
|
||||
"flee_radius": 750.0,
|
||||
"decision_interval_seconds": 1.5,
|
||||
},
|
||||
"harvest_yield_ids": ["meat", "hide"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def nearly_equal(left, right):
|
||||
if isinstance(left, float) or isinstance(right, float):
|
||||
return abs(float(left) - float(right)) < 0.001
|
||||
return left == right
|
||||
|
||||
|
||||
def main():
|
||||
failures = []
|
||||
|
||||
for path, expected in EXPECTED.items():
|
||||
blueprint = unreal.EditorAssetLibrary.load_asset(path)
|
||||
if not blueprint:
|
||||
failures.append(f"{path} missing")
|
||||
continue
|
||||
|
||||
generated_class = unreal.EditorAssetLibrary.load_blueprint_class(path)
|
||||
if not generated_class:
|
||||
failures.append(f"{path} parent class mismatch")
|
||||
continue
|
||||
|
||||
cdo = unreal.get_default_object(generated_class)
|
||||
for property_name, expected_value in expected.get("properties", {}).items():
|
||||
actual_value = cdo.get_editor_property(property_name)
|
||||
if str(actual_value) != expected_value and not nearly_equal(actual_value, expected_value):
|
||||
failures.append(f"{path} {property_name} expected {expected_value}, got {actual_value}")
|
||||
|
||||
expected_yield_item_id = expected.get("yield_item_id")
|
||||
if expected_yield_item_id:
|
||||
item_asset = cdo.get_editor_property("yield_item_definition")
|
||||
definition = item_asset.get_editor_property("definition") if item_asset else None
|
||||
item_id = str(definition.get_editor_property("item_id")) if definition else ""
|
||||
if item_id != expected_yield_item_id:
|
||||
failures.append(f"{path} yield_item_definition expected {expected_yield_item_id}, got {item_id}")
|
||||
|
||||
expected_harvest_ids = expected.get("harvest_yield_ids")
|
||||
if expected_harvest_ids:
|
||||
harvest_yields = cdo.get_editor_property("harvest_yields")
|
||||
actual_ids = [str(stack.get_editor_property("item_id")) for stack in harvest_yields]
|
||||
if actual_ids != expected_harvest_ids:
|
||||
failures.append(f"{path} harvest_yields expected {expected_harvest_ids}, got {actual_ids}")
|
||||
|
||||
if failures:
|
||||
raise RuntimeError("Playable Blueprint verification failed: " + "; ".join(failures))
|
||||
|
||||
unreal.log("Agrarian playable Blueprint verification complete.")
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,84 @@
|
||||
import unreal
|
||||
|
||||
|
||||
MAP_PATH = "/Game/ThirdPerson/Lvl_ThirdPerson"
|
||||
CHARACTER_CLASS_PATH = "/Game/ThirdPerson/Blueprints/BP_ThirdPersonCharacter"
|
||||
SHELTER_RECIPE_PATH = "/Game/Agrarian/DataAssets/Recipes/DA_Recipe_PrimitiveShelter"
|
||||
FRAME_RECIPE_PATH = "/Game/Agrarian/DataAssets/Recipes/DA_Recipe_PrimitiveFrame"
|
||||
WALL_PANEL_RECIPE_PATH = "/Game/Agrarian/DataAssets/Recipes/DA_Recipe_PrimitiveWallPanel"
|
||||
ROOF_PANEL_RECIPE_PATH = "/Game/Agrarian/DataAssets/Recipes/DA_Recipe_PrimitiveRoofPanel"
|
||||
SHELTER_CLASS_PATH = "/Game/Agrarian/Blueprints/Structures/BP_PrimitiveShelter"
|
||||
WOOD_NODE_LABEL = "AGR_WoodResourceNode_01"
|
||||
FIBER_NODE_LABEL = "AGR_FiberResourceNode_01"
|
||||
RABBIT_LABEL = "AGR_RabbitWildlife_01"
|
||||
|
||||
|
||||
def get_actor_label(actor):
|
||||
try:
|
||||
return actor.get_actor_label()
|
||||
except Exception:
|
||||
return actor.get_name()
|
||||
|
||||
|
||||
def load_blueprint_class(path):
|
||||
generated_class = unreal.EditorAssetLibrary.load_blueprint_class(path)
|
||||
if not generated_class:
|
||||
raise RuntimeError(f"Could not load Blueprint class: {path}")
|
||||
return generated_class
|
||||
|
||||
|
||||
def find_actor_by_label(label):
|
||||
for actor in unreal.EditorLevelLibrary.get_all_level_actors():
|
||||
if get_actor_label(actor) == label:
|
||||
return actor
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
if not unreal.EditorLevelLibrary.load_level(MAP_PATH):
|
||||
raise RuntimeError(f"Could not load map: {MAP_PATH}")
|
||||
|
||||
character_class = load_blueprint_class(CHARACTER_CLASS_PATH)
|
||||
shelter_class = load_blueprint_class(SHELTER_CLASS_PATH)
|
||||
shelter_recipe = unreal.EditorAssetLibrary.load_asset(SHELTER_RECIPE_PATH)
|
||||
if not shelter_recipe:
|
||||
raise RuntimeError(f"Could not load shelter recipe: {SHELTER_RECIPE_PATH}")
|
||||
frame_recipe = unreal.EditorAssetLibrary.load_asset(FRAME_RECIPE_PATH)
|
||||
if not frame_recipe:
|
||||
raise RuntimeError(f"Could not load frame recipe: {FRAME_RECIPE_PATH}")
|
||||
wall_panel_recipe = unreal.EditorAssetLibrary.load_asset(WALL_PANEL_RECIPE_PATH)
|
||||
if not wall_panel_recipe:
|
||||
raise RuntimeError(f"Could not load wall panel recipe: {WALL_PANEL_RECIPE_PATH}")
|
||||
roof_panel_recipe = unreal.EditorAssetLibrary.load_asset(ROOF_PANEL_RECIPE_PATH)
|
||||
if not roof_panel_recipe:
|
||||
raise RuntimeError(f"Could not load roof panel recipe: {ROOF_PANEL_RECIPE_PATH}")
|
||||
|
||||
wood_node = find_actor_by_label(WOOD_NODE_LABEL)
|
||||
if not wood_node:
|
||||
raise RuntimeError(f"Could not find placed wood node: {WOOD_NODE_LABEL}")
|
||||
fiber_node = find_actor_by_label(FIBER_NODE_LABEL)
|
||||
if not fiber_node:
|
||||
raise RuntimeError(f"Could not find placed fiber node: {FIBER_NODE_LABEL}")
|
||||
rabbit = find_actor_by_label(RABBIT_LABEL)
|
||||
if not rabbit:
|
||||
raise RuntimeError(f"Could not find placed rabbit wildlife: {RABBIT_LABEL}")
|
||||
|
||||
result = unreal.AgrarianEditorAutomationLibrary.run_natural_shelter_loop_smoke_test(
|
||||
character_class,
|
||||
wood_node,
|
||||
fiber_node,
|
||||
rabbit,
|
||||
frame_recipe,
|
||||
wall_panel_recipe,
|
||||
roof_panel_recipe,
|
||||
shelter_recipe,
|
||||
shelter_class,
|
||||
)
|
||||
unreal.log(result)
|
||||
if not str(result).startswith("PASS:"):
|
||||
raise RuntimeError(result)
|
||||
|
||||
unreal.log("Agrarian playable loop smoke verification complete.")
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,108 @@
|
||||
import unreal
|
||||
|
||||
|
||||
RECIPE_FOLDER = "/Game/Agrarian/DataAssets/Recipes"
|
||||
|
||||
EXPECTED_RECIPES = {
|
||||
"DA_Recipe_Campfire": {
|
||||
"recipe_id": "campfire",
|
||||
"ingredients": {"wood": 5, "stone": 8, "fiber": 2},
|
||||
"result": ("campfire", 1),
|
||||
},
|
||||
"DA_Recipe_PrimitiveShelter": {
|
||||
"recipe_id": "primitive_shelter",
|
||||
"ingredients": {
|
||||
"primitive_frame": 2,
|
||||
"primitive_wall_panel": 4,
|
||||
"primitive_roof_panel": 2,
|
||||
"hide": 2,
|
||||
"fiber": 6,
|
||||
},
|
||||
"result": ("primitive_shelter", 1),
|
||||
},
|
||||
"DA_Recipe_PrimitiveFrame": {
|
||||
"recipe_id": "primitive_frame",
|
||||
"ingredients": {"wood": 4, "fiber": 2},
|
||||
"result": ("primitive_frame", 1),
|
||||
},
|
||||
"DA_Recipe_PrimitiveWallPanel": {
|
||||
"recipe_id": "primitive_wall_panel",
|
||||
"ingredients": {"wood": 3, "fiber": 2},
|
||||
"result": ("primitive_wall_panel", 1),
|
||||
},
|
||||
"DA_Recipe_PrimitiveRoofPanel": {
|
||||
"recipe_id": "primitive_roof_panel",
|
||||
"ingredients": {"wood": 3, "fiber": 3},
|
||||
"result": ("primitive_roof_panel", 1),
|
||||
},
|
||||
"DA_Recipe_BasicTool": {
|
||||
"recipe_id": "basic_tool",
|
||||
"ingredients": {"wood": 1, "stone": 2, "fiber": 1},
|
||||
"result": ("basic_tool", 1),
|
||||
},
|
||||
"DA_Recipe_Bandage": {
|
||||
"recipe_id": "bandage",
|
||||
"ingredients": {"fiber": 3, "hide": 1},
|
||||
"result": ("bandage", 1),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def stack_item_id(stack):
|
||||
return str(stack.get_editor_property("item_id"))
|
||||
|
||||
|
||||
def main():
|
||||
missing = []
|
||||
|
||||
for asset_name, expected in EXPECTED_RECIPES.items():
|
||||
path = f"{RECIPE_FOLDER}/{asset_name}"
|
||||
asset = unreal.EditorAssetLibrary.load_asset(path)
|
||||
if not asset:
|
||||
missing.append(f"{path} missing")
|
||||
continue
|
||||
|
||||
recipe = asset.get_editor_property("recipe")
|
||||
recipe_id = str(recipe.get_editor_property("recipe_id"))
|
||||
display_name = str(recipe.get_editor_property("display_name"))
|
||||
craft_seconds = recipe.get_editor_property("craft_seconds")
|
||||
result = recipe.get_editor_property("result")
|
||||
ingredients = list(recipe.get_editor_property("ingredients"))
|
||||
|
||||
if recipe_id != expected["recipe_id"]:
|
||||
missing.append(f"{path} recipe_id expected {expected['recipe_id']}, got {recipe_id}")
|
||||
if not display_name:
|
||||
missing.append(f"{path} display_name empty")
|
||||
if craft_seconds <= 0.0:
|
||||
missing.append(f"{path} craft_seconds must be positive")
|
||||
|
||||
expected_result_id, expected_result_quantity = expected["result"]
|
||||
if stack_item_id(result) != expected_result_id:
|
||||
missing.append(f"{path} result id expected {expected_result_id}, got {stack_item_id(result)}")
|
||||
if result.get_editor_property("quantity") != expected_result_quantity:
|
||||
missing.append(f"{path} result quantity expected {expected_result_quantity}")
|
||||
if result.get_editor_property("unit_weight") <= 0.0:
|
||||
missing.append(f"{path} result unit weight must be positive")
|
||||
|
||||
actual_ingredients = {stack_item_id(stack): stack for stack in ingredients}
|
||||
for expected_item_id, expected_quantity in expected["ingredients"].items():
|
||||
stack = actual_ingredients.get(expected_item_id)
|
||||
if not stack:
|
||||
missing.append(f"{path} missing ingredient {expected_item_id}")
|
||||
continue
|
||||
if stack.get_editor_property("quantity") != expected_quantity:
|
||||
missing.append(f"{path} ingredient {expected_item_id} expected quantity {expected_quantity}")
|
||||
if stack.get_editor_property("unit_weight") <= 0.0:
|
||||
missing.append(f"{path} ingredient {expected_item_id} unit weight must be positive")
|
||||
|
||||
unexpected = set(actual_ingredients.keys()) - set(expected["ingredients"].keys())
|
||||
if unexpected:
|
||||
missing.append(f"{path} has unexpected ingredients: {sorted(unexpected)}")
|
||||
|
||||
if missing:
|
||||
raise RuntimeError("Recipe definition verification failed: " + "; ".join(missing))
|
||||
|
||||
unreal.log("Agrarian recipe definition verification complete.")
|
||||
|
||||
|
||||
main()
|
||||
@@ -8,10 +8,18 @@ EXPECTED_PLACEMENTS = {
|
||||
"class_path": "/Game/Agrarian/Blueprints/Resources/BP_WoodResourceNode",
|
||||
"location": unreal.Vector(650.0, -150.0, 120.0),
|
||||
"properties": {
|
||||
"remaining_harvests": 6,
|
||||
"remaining_harvests": 16,
|
||||
"quantity_per_harvest": 2,
|
||||
},
|
||||
},
|
||||
"AGR_FiberResourceNode_01": {
|
||||
"class_path": "/Game/Agrarian/Blueprints/Resources/BP_FiberResourceNode",
|
||||
"location": unreal.Vector(560.0, 140.0, 90.0),
|
||||
"properties": {
|
||||
"remaining_harvests": 10,
|
||||
"quantity_per_harvest": 3,
|
||||
},
|
||||
},
|
||||
"AGR_Campfire_01": {
|
||||
"class_path": "/Game/Agrarian/Blueprints/Structures/BP_Campfire",
|
||||
"location": unreal.Vector(900.0, 120.0, 60.0),
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import unreal
|
||||
|
||||
|
||||
MAP_PATH = "/Game/ThirdPerson/Lvl_ThirdPerson"
|
||||
CHARACTER_CLASS_PATH = "/Game/ThirdPerson/Blueprints/BP_ThirdPersonCharacter"
|
||||
RABBIT_LABEL = "AGR_RabbitWildlife_01"
|
||||
|
||||
|
||||
def get_actor_label(actor):
|
||||
try:
|
||||
return actor.get_actor_label()
|
||||
except Exception:
|
||||
return actor.get_name()
|
||||
|
||||
|
||||
def load_blueprint_class(path):
|
||||
generated_class = unreal.EditorAssetLibrary.load_blueprint_class(path)
|
||||
if not generated_class:
|
||||
raise RuntimeError(f"Could not load Blueprint class: {path}")
|
||||
return generated_class
|
||||
|
||||
|
||||
def find_actor_by_label(label):
|
||||
for actor in unreal.EditorLevelLibrary.get_all_level_actors():
|
||||
if get_actor_label(actor) == label:
|
||||
return actor
|
||||
return None
|
||||
|
||||
|
||||
def enum_name(value):
|
||||
return str(value).split(".")[-1].lower()
|
||||
|
||||
|
||||
def get_bool_property(actor, *property_names):
|
||||
for property_name in property_names:
|
||||
try:
|
||||
return bool(actor.get_editor_property(property_name))
|
||||
except Exception:
|
||||
continue
|
||||
raise RuntimeError(f"Could not read any bool property from {property_names}")
|
||||
|
||||
|
||||
def main():
|
||||
if not unreal.EditorLevelLibrary.load_level(MAP_PATH):
|
||||
raise RuntimeError(f"Could not load map: {MAP_PATH}")
|
||||
|
||||
character_class = load_blueprint_class(CHARACTER_CLASS_PATH)
|
||||
rabbit = find_actor_by_label(RABBIT_LABEL)
|
||||
if not rabbit:
|
||||
raise RuntimeError(f"Could not find placed rabbit wildlife: {RABBIT_LABEL}")
|
||||
|
||||
character = unreal.AgrarianEditorAutomationLibrary.spawn_actor_in_editor_world(
|
||||
character_class,
|
||||
unreal.Vector(500.0, 320.0, 180.0),
|
||||
unreal.Rotator(0.0, 0.0, 0.0),
|
||||
"AGR_AutomationWildlifeCharacter",
|
||||
)
|
||||
if not character:
|
||||
raise RuntimeError("Could not spawn automation character")
|
||||
|
||||
inventory = character.get_component_by_class(unreal.AgrarianInventoryComponent)
|
||||
if not inventory:
|
||||
raise RuntimeError("Automation character is missing AgrarianInventoryComponent")
|
||||
|
||||
starting_health = float(rabbit.get_editor_property("health"))
|
||||
max_health = float(rabbit.get_editor_property("max_health"))
|
||||
if starting_health <= 0.0 or max_health <= 0.0:
|
||||
raise RuntimeError(f"Rabbit starts invalid health={starting_health}, max_health={max_health}")
|
||||
|
||||
rabbit.apply_wildlife_damage(1.0, character)
|
||||
damaged_health = float(rabbit.get_editor_property("health"))
|
||||
damaged_state = enum_name(rabbit.get_editor_property("wildlife_state"))
|
||||
if not damaged_health < starting_health:
|
||||
raise RuntimeError(f"Rabbit non-lethal damage did not reduce health: {starting_health} -> {damaged_health}")
|
||||
if "fleeing" not in damaged_state:
|
||||
raise RuntimeError(f"Rabbit expected fleeing after non-lethal damage, got {damaged_state}")
|
||||
|
||||
rabbit.apply_wildlife_damage(max_health + 10.0, character)
|
||||
dead_health = float(rabbit.get_editor_property("health"))
|
||||
dead_state = enum_name(rabbit.get_editor_property("wildlife_state"))
|
||||
if dead_health != 0.0:
|
||||
raise RuntimeError(f"Rabbit lethal damage expected health 0, got {dead_health}")
|
||||
if "dead" not in dead_state:
|
||||
raise RuntimeError(f"Rabbit expected dead after lethal damage, got {dead_state}")
|
||||
if not rabbit.can_interact(character):
|
||||
raise RuntimeError("Rabbit should be harvestable after death")
|
||||
|
||||
meat_before = inventory.get_item_count("meat")
|
||||
hide_before = inventory.get_item_count("hide")
|
||||
rabbit.interact(character)
|
||||
meat_after = inventory.get_item_count("meat")
|
||||
hide_after = inventory.get_item_count("hide")
|
||||
if meat_after <= meat_before:
|
||||
raise RuntimeError(f"Rabbit harvest did not add meat: {meat_before} -> {meat_after}")
|
||||
if hide_after <= hide_before:
|
||||
raise RuntimeError(f"Rabbit harvest did not add hide: {hide_before} -> {hide_after}")
|
||||
if not get_bool_property(rabbit, "b_harvested", "harvested"):
|
||||
raise RuntimeError("Rabbit harvest did not set bHarvested")
|
||||
if rabbit.can_interact(character):
|
||||
raise RuntimeError("Rabbit should not be harvestable twice")
|
||||
|
||||
unreal.EditorLevelLibrary.destroy_actor(character)
|
||||
unreal.log(
|
||||
"PASS: wildlife damage/death/harvest verified "
|
||||
f"health {starting_health}->{damaged_health}->0, "
|
||||
f"meat {meat_before}->{meat_after}, hide {hide_before}->{hide_after}"
|
||||
)
|
||||
|
||||
|
||||
main()
|
||||
Reference in New Issue
Block a user