179 lines
8.4 KiB
Python
179 lines
8.4 KiB
Python
"""Builds /Game/Maps/L_World: a world-partitioned level with the landscape that RawContent/World/World.json
|
|
describes, imported from the PNGs in RawContent/World/Heightmaps/ and dressed as Elite_RockyMeadows dresses its
|
|
demo maps: the pack's landscape material and three paint layers, its sun with the moving cloud shadows, its
|
|
skybox dome, sky light, height fog and post-process settings (numbers read from the pack's maps with
|
|
dump_level.py), plus a sea plane at sea level and two player starts on the spawn pad. Rebuilds from scratch
|
|
every run; the level is a product of this script, the manifest and the PNGs, never hand-edited.
|
|
|
|
UnrealEditor-Cmd.exe Salty.uproject -run=pythonscript -script=Scripts/Authoring/create_world.py -AllowCommandletRendering
|
|
|
|
-AllowCommandletRendering matters: the landscape's render heightmaps come from the edit-layer merge on the GPU,
|
|
and a commandlet without it silently skips that, leaving a landscape with collision but no visible surface.
|
|
|
|
Swapping the terrain: point the manifest's source at a real heightmap (or change the seed), rerun
|
|
generate_heightmap.py, then rerun this. If the PNGs are missing or the wrong size this script generates them
|
|
first. The landscape's component layout is the engine's choice for the resolution: 4081 vertices a side gives
|
|
16x16 components of 255 quads, each its own streaming proxy (see RawContent/World/README.md before changing it).
|
|
"""
|
|
import os
|
|
import shutil
|
|
import sys
|
|
|
|
import unreal
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, HERE)
|
|
sys.path.insert(0, os.path.join(HERE, ".pylib"))
|
|
import heightmap_io # noqa: E402
|
|
import rocky_meadows # noqa: E402
|
|
from world_manifest import LAYER_FILES, load_manifest # noqa: E402
|
|
|
|
# The pack's kit — its landscape material, layer infos, sun, skybox, fog and grade — is in rocky_meadows.py,
|
|
# shared with create_region_world.py so one set of numbers dresses both worlds.
|
|
|
|
manifest = load_manifest()
|
|
LEVEL_PATH = manifest.level
|
|
|
|
level_subsystem = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
|
actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
|
editor_subsystem = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
|
|
asset_lib = unreal.EditorAssetLibrary
|
|
|
|
|
|
def ensure_heightmaps():
|
|
files = [manifest.heightmap_path] + [manifest.weightmap_path(name) for name in LAYER_FILES]
|
|
reason = None
|
|
missing = [f for f in files if not os.path.isfile(f)]
|
|
if missing:
|
|
reason = f"missing {missing}"
|
|
else:
|
|
width, height, depth, _ = heightmap_io.read_png_header(manifest.heightmap_path)
|
|
if (width, height, depth) != (manifest.vertices_per_side, manifest.vertices_per_side, 16):
|
|
reason = f"{manifest.heightmap_path} is {width}x{height} at {depth} bit, the manifest wants {manifest.vertices_per_side} at 16"
|
|
if reason is None:
|
|
return
|
|
unreal.log(f"generating heightmaps: {reason}")
|
|
import generate_heightmap
|
|
generate_heightmap.main([])
|
|
|
|
|
|
def spawn(actor_class, label, location=unreal.Vector(0, 0, 0), rotation=unreal.Rotator(0, 0, 0)):
|
|
actor = actor_subsystem.spawn_actor_from_class(actor_class, location, rotation)
|
|
actor.set_actor_label(label)
|
|
return actor
|
|
|
|
|
|
KEEP_CLASSES = {"WorldSettings", "WorldDataLayers", "WorldPartitionMiniMap"} # what a fresh partitioned level has
|
|
|
|
|
|
def content_path():
|
|
return os.path.abspath(unreal.Paths.convert_relative_path_to_full(unreal.Paths.project_content_dir()))
|
|
|
|
|
|
def external_actor_dir():
|
|
return os.path.join(content_path(), "__ExternalActors__", LEVEL_PATH.replace("/Game/", "", 1))
|
|
|
|
|
|
def prepare_level():
|
|
"""An empty level to build into. An existing level is loaded and emptied rather than deleted and recreated:
|
|
recreating it resaves its two HLOD layer assets, and an open editor that has had the level loaded keeps
|
|
those files locked, so the recreation ends in a nameless temp world whose save goes nowhere (2026-09-16).
|
|
Streaming proxies the editor does not load cannot be destroyed here; sweep_stale_actor_packages removes
|
|
their files after the save."""
|
|
if asset_lib.does_asset_exist(LEVEL_PATH):
|
|
if not level_subsystem.load_level(LEVEL_PATH):
|
|
raise RuntimeError(f"could not load {LEVEL_PATH}")
|
|
removed = 0
|
|
for actor in actor_subsystem.get_all_level_actors():
|
|
if actor.get_class().get_name() in KEEP_CLASSES:
|
|
continue
|
|
actor_subsystem.destroy_actor(actor)
|
|
removed += 1
|
|
unreal.log(f"{LEVEL_PATH} loaded and emptied: {removed} loaded actors removed")
|
|
else:
|
|
folder = external_actor_dir()
|
|
if os.path.isdir(folder):
|
|
shutil.rmtree(folder, ignore_errors=True)
|
|
level_subsystem.new_level(LEVEL_PATH, True) # world-partitioned: 200 km2 must stream
|
|
world_path = editor_subsystem.get_editor_world().get_path_name()
|
|
if not world_path.startswith(LEVEL_PATH):
|
|
raise RuntimeError(f"the editor world is {world_path}, not {LEVEL_PATH}; a save would go nowhere")
|
|
|
|
|
|
def sweep_stale_actor_packages():
|
|
"""After the save: every file under the level's external actor folder that belongs to none of the level's
|
|
actors is a leftover of an earlier build (a proxy the editor never loaded, so it could not be destroyed).
|
|
Left there, it would come back as a second landscape the next time the level loads."""
|
|
keep = set()
|
|
for actor in actor_subsystem.get_all_level_actors():
|
|
package = actor.get_outermost().get_path_name() # /Game/__ExternalActors__/Maps/L_World/1/YM/XXXX
|
|
keep.add(os.path.normcase(os.path.join(content_path(), package.replace("/Game/", "", 1).replace("/", os.sep) + ".uasset")))
|
|
removed, failed = 0, 0
|
|
for root, _, files in os.walk(external_actor_dir()):
|
|
for name in files:
|
|
path = os.path.join(root, name)
|
|
if os.path.normcase(path) in keep:
|
|
continue
|
|
try:
|
|
os.remove(path)
|
|
removed += 1
|
|
except OSError as error:
|
|
failed += 1
|
|
unreal.log_warning(f"stale actor package {path} could not be removed: {error}")
|
|
unreal.log(f"stale actor packages: {removed} removed, {failed} left, {len(keep)} kept")
|
|
if failed:
|
|
raise RuntimeError(f"{failed} stale actor packages remain under {external_actor_dir()}; the level would load duplicates")
|
|
|
|
|
|
def create_landscape():
|
|
material = rocky_meadows.load_or_raise(rocky_meadows.LANDSCAPE_MATERIAL)
|
|
weightmaps = rocky_meadows.weightmap_entries(manifest.weightmap_path)
|
|
|
|
world = editor_subsystem.get_editor_world()
|
|
unreal.log(f"landscape: {manifest.describe()}")
|
|
landscape = unreal.LandscapeAuthoringLibrary.create_landscape_from_heightmap(
|
|
world, manifest.heightmap_path, weightmaps, material,
|
|
unreal.Vector(0.0, 0.0, manifest.landscape_z_cm),
|
|
unreal.Vector(manifest.quad_cm, manifest.quad_cm, manifest.z_scale),
|
|
manifest.streaming_grid_components)
|
|
if not landscape:
|
|
raise RuntimeError("landscape creation failed; see LogSaltyEditor")
|
|
return landscape
|
|
|
|
|
|
def ground_height_at(x, y, fallback):
|
|
world = editor_subsystem.get_editor_world()
|
|
reach = manifest.elevation_max_m * 100.0 + 100000.0
|
|
start, end = unreal.Vector(x, y, reach), unreal.Vector(x, y, -reach)
|
|
hit = unreal.SystemLibrary.line_trace_single(world, start, end, unreal.TraceTypeQuery.ECC_VISIBILITY,
|
|
False, [], unreal.DrawDebugTrace.NONE, True)
|
|
if hit and hit.to_tuple()[0]:
|
|
return hit.to_tuple()[4].z
|
|
unreal.log_warning("no landscape hit under the spawn; using the fallback height")
|
|
return fallback
|
|
|
|
|
|
def ensure_player_starts():
|
|
# The heightmap has a flat pad at its centre; two starts so two PIE clients spawn without a warning.
|
|
fallback = manifest.sea_level_z_cm + 15000.0
|
|
z = ground_height_at(0.0, 0.0, fallback) + 120.0
|
|
for index, y in enumerate((-200.0, 200.0)):
|
|
spawn(unreal.PlayerStart, f"World_PlayerStart_{index}", unreal.Vector(0.0, y, z))
|
|
|
|
|
|
def main():
|
|
ensure_heightmaps()
|
|
prepare_level()
|
|
landscape = create_landscape()
|
|
rocky_meadows.dress(spawn, manifest)
|
|
rocky_meadows.ensure_sea(spawn, manifest)
|
|
ensure_player_starts()
|
|
if not level_subsystem.save_current_level():
|
|
raise RuntimeError(f"saving {LEVEL_PATH} failed; see the log above")
|
|
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
|
|
sweep_stale_actor_packages()
|
|
unreal.log(f"{LEVEL_PATH} saved with landscape {landscape.get_actor_label()}")
|
|
|
|
|
|
main()
|