"""The world manifest: RawContent/World/World.json, the one place that says how big L_World is, what a heightmap value means in metres, and where the height comes from. Pure Python (no numpy, no engine), shared by generate_heightmap.py (writes the PNGs) and create_world.py (imports them), so both agree without either knowing about the other. The height contract. The landscape is `vertices_per_side` vertices a side at `quad_cm` a quad. The 16-bit heightmap spans `elevation_m.min` (value 0) to `elevation_m.max` (value 65535), and the level places the landscape so that world Z 0 is elevation 0 m: sea level, when `sea_level_m` is 0. From that the engine's Z scale and the actor's Z offset follow; nothing else in the project needs to know them. Sources. `{"kind": "noise", "seed": N}` builds a continent with heightmap_noise.py. `{"kind": "file", "path": ..., "elevation_m": {"min": ..., "max": ...}}` takes a real heightmap (16-bit greyscale PNG or raw 16-bit little-endian .r16), whose 0..65535 spans its own elevation range, and resamples it onto the world. Either way the paint layers are derived from the finished height, so a real heightmap needs no weightmaps of its own. """ import json import os HERE = os.path.dirname(os.path.abspath(__file__)) PROJECT_ROOT = os.path.normpath(os.path.join(HERE, "..", "..")) WORLD_DIR = os.path.join(PROJECT_ROOT, "RawContent", "World") MANIFEST_PATH = os.path.join(WORLD_DIR, "World.json") HEIGHTMAP_DIR = os.path.join(WORLD_DIR, "Heightmaps") HEIGHTMAP_FILE = "L_World_Height.png" # Paint layer name (as the Elite_RockyMeadows landscape material calls it) -> weightmap file. The names mislead: # in this pack Base_Layer is the rock, Layer_02 the grass (the meadow) and Layer_03 the high rock. LAYER_FILES = { "Base_Layer": "L_World_Base_Layer.png", "Layer_02": "L_World_Layer_02.png", "Layer_03": "L_World_Layer_03.png", } # Derivative maps the erosion pass leaves behind, 8-bit, for painting and for a material that wants them later: # how much water passed (log scaled), how much bedrock was scraped, how much sediment was laid down, and the # curvature (128 flat, brighter convex, darker concave). DERIVED_FILES = { "flow": "L_World_Flow.png", "wear": "L_World_Wear.png", "deposit": "L_World_Deposit.png", "curvature": "L_World_Curvature.png", } # The engine maps heightmap value v to local height (v - 32768) / 128 * ZScale cm, so ZScale 100 spans 512 m. ENGINE_SPAN_M_AT_SCALE_100 = 512.0 class WorldManifest: def __init__(self, data, path=MANIFEST_PATH): self.path = path self.level = data.get("level", "/Game/Maps/L_World") self.vertices_per_side = int(data["vertices_per_side"]) self.quad_cm = float(data["quad_cm"]) self.elevation_min_m = float(data["elevation_m"]["min"]) self.elevation_max_m = float(data["elevation_m"]["max"]) self.sea_level_m = float(data.get("sea_level_m", 0.0)) self.spawn_pad_m = float(data.get("spawn_pad_m", 150.0)) self.streaming_grid_components = int(data.get("streaming_grid_components", 1)) self.source = dict(data.get("source", {"kind": "noise", "seed": 7})) self.erosion = dict(data.get("erosion", {})) # keys and defaults in heightmap_erosion.DEFAULTS self.layers = dict(data.get("layers", {})) if self.vertices_per_side < 2 or self.elevation_max_m <= self.elevation_min_m or self.quad_cm <= 0: raise ValueError(f"{path}: vertices_per_side, quad_cm and elevation_m must be positive and ordered") # Derived geometry. @property def quads_per_side(self): return self.vertices_per_side - 1 @property def side_m(self): return self.quads_per_side * self.quad_cm / 100.0 @property def area_km2(self): return (self.side_m / 1000.0) ** 2 @property def elevation_span_m(self): return self.elevation_max_m - self.elevation_min_m @property def elevation_mid_m(self): return (self.elevation_max_m + self.elevation_min_m) / 2.0 @property def z_scale(self): """The landscape actor's Z scale so that the 16-bit range spans exactly the manifest's elevation range.""" return self.elevation_span_m / ENGINE_SPAN_M_AT_SCALE_100 * 100.0 @property def landscape_z_cm(self): """The landscape actor's world Z: value 32768 sits at elevation_mid, so elevation 0 m lands on world Z 0.""" return self.elevation_mid_m * 100.0 @property def sea_level_z_cm(self): return self.sea_level_m * 100.0 # The height encoding, in plain floats so the numpy side can vectorise the same formula. def metres_to_value(self, metres): return (metres - self.elevation_min_m) / self.elevation_span_m * 65535.0 def value_to_metres(self, value): return self.elevation_min_m + value / 65535.0 * self.elevation_span_m # Files. @property def heightmap_path(self): return os.path.join(HEIGHTMAP_DIR, HEIGHTMAP_FILE) def weightmap_path(self, layer_name): return os.path.join(HEIGHTMAP_DIR, LAYER_FILES[layer_name]) def derived_path(self, map_name): return os.path.join(HEIGHTMAP_DIR, DERIVED_FILES[map_name]) def resolve(self, relative): """A manifest path is relative to the project root unless absolute.""" return relative if os.path.isabs(relative) else os.path.normpath(os.path.join(PROJECT_ROOT, relative)) def describe(self): return (f"{self.vertices_per_side} vertices a side at {self.quad_cm:g} cm: {self.side_m / 1000:.2f} km, " f"{self.area_km2:.0f} km2; elevation {self.elevation_min_m:g}..{self.elevation_max_m:g} m " f"(Z scale {self.z_scale:g}, actor Z {self.landscape_z_cm:g} cm); source {self.source}") def load_manifest(path=MANIFEST_PATH): with open(path, "r", encoding="utf-8") as f: return WorldManifest(json.load(f), path)