Files
2026-09-25 17:02:24 +03:00

216 lines
10 KiB
Python

"""Imports the world map's art and writes the definition asset the map view reads.
Scripts/Authoring/build_world_map.sh # the whole thing: render the art, then this
UnrealEditor-Cmd.exe Salty.uproject -run=pythonscript -script="<abs>/create_world_map.py --dry-run"
Two inputs and nothing else: RawContent/World/MapArt/layers.json (which pictures, how big) and
RawContent/World/Region.json (how big the world is, where its heights sit). The PNGs themselves are written by
Tools/MapArt, which is a Go tool because the engine's Python has no image library that can decode 33 megapixels
of RGB. This script does not render anything; if a layer's PNG is missing it says so and stops rather than
importing a stale one.
Idempotent. A rerun re-imports the textures in place and rewrites the definition, which is what makes changing
a layer a line in layers.json and one command. Nothing is ever deleted: a layer removed from the manifest
leaves its texture behind, unreferenced, for a person to decide about.
The projection is copied out of Region.json rather than typed here, so the map and the landscape can never
disagree about how big the world is - the one bug in a map view that looks like a plausible map.
"""
import os
import sys
import unreal
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from world_map_manifest import load_manifest # noqa: E402
asset_lib = unreal.EditorAssetLibrary
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
DRY_RUN = "--dry-run" in sys.argv
def log(message):
unreal.log(message)
def import_texture(png_path, package, asset_name, wraps_x):
"""One PNG to one UTexture2D, replacing whatever is at that path. Returns the texture, or None."""
asset_path = f"{package}/{asset_name}"
if DRY_RUN:
verb = "would re-import" if asset_lib.does_asset_exist(asset_path) else "would import"
log(f" {verb} {os.path.basename(png_path)} -> {asset_path}")
return None
task = unreal.AssetImportTask()
task.set_editor_property("filename", png_path)
task.set_editor_property("destination_path", package)
task.set_editor_property("destination_name", asset_name)
task.set_editor_property("automated", True)
task.set_editor_property("replace_existing", True)
task.set_editor_property("save", False) # saved once at the end, with the definition
asset_tools.import_asset_tasks([task])
texture = asset_lib.load_asset(asset_path)
if texture is None:
raise RuntimeError(f"{png_path} did not import to {asset_path}")
# Mips matter here more than anywhere: the map is almost always minified - the whole 4096 px world fitted
# into a panel a thousand pixels wide - and an unmipped map shimmers with every pan.
texture.set_editor_property("mip_gen_settings", unreal.TextureMipGenSettings.TMGS_SIMPLE_AVERAGE)
texture.set_editor_property("compression_settings", unreal.TextureCompressionSettings.TC_DEFAULT)
texture.set_editor_property("srgb", True)
texture.set_editor_property("filter", unreal.TextureFilter.TF_BILINEAR)
# X wraps when the art is a whole cylinder, so bilinear at the seam samples the other side of the world
# instead of clamping to the edge column. Y never wraps: there is no route across a pole.
texture.set_editor_property("address_x", unreal.TextureAddress.TA_WRAP if wraps_x else unreal.TextureAddress.TA_CLAMP)
texture.set_editor_property("address_y", unreal.TextureAddress.TA_CLAMP)
size = (texture.blueprint_get_size_x(), texture.blueprint_get_size_y())
log(f" {os.path.basename(png_path)} -> {asset_path} {size[0]}x{size[1]}")
return texture
def make_projection(values):
projection = unreal.WorldMapProjection()
projection.set_editor_property("width_m", values["width_m"])
projection.set_editor_property("height_m", values["height_m"])
projection.set_editor_property("centre_m", unreal.Vector2D(values["centre_m"][0], values["centre_m"][1]))
projection.set_editor_property("wraps_x", values["wraps_x"])
projection.set_editor_property("elevation_min_m", values["elevation_min_m"])
projection.set_editor_property("elevation_max_m", values["elevation_max_m"])
projection.set_editor_property("sea_level_m", values["sea_level_m"])
return projection
def ensure_definition(manifest):
"""The definition asset, created if it is not there. Never deleted and recreated: a Blueprint or a level
that already points at it would lose the reference."""
path = f"{manifest.package}/{manifest.definition_name}"
if asset_lib.does_asset_exist(path):
return asset_lib.load_asset(path)
if DRY_RUN:
log(f" would create {path}")
return None
factory = unreal.DataAssetFactory()
factory.set_editor_property("data_asset_class", unreal.WorldMapDefinition)
asset = asset_tools.create_asset(manifest.definition_name, manifest.package, None, factory)
if asset is None:
raise RuntimeError(f"could not create {path}")
log(f" created {path}")
return asset
def verify(definition, manifest, projection, textures):
"""Reads every property back off the asset and refuses to go on if one of them did not take.
This exists because a property set that silently does nothing looks exactly like one that worked, and this
asset has already had that failure once: every property here was EditDefaultsOnly, which Python declines to
write on an instance, and a UDataAsset is an instance. A save is not evidence; the value is.
`level` is read with export_text() rather than str(): an FSoftObjectPath's fields are not UPROPERTYs, so
Python reprs it as an empty struct whether it holds a path or not. str() here reports a correct asset as
broken, which cost an afternoon once and should not cost another.
"""
problems = []
def check(what, got, want):
if got != want:
problems.append(f"{what}: the asset says {got!r}, the manifests say {want!r}")
stored = definition.get_editor_property("projection")
for key in ("width_m", "height_m", "wraps_x", "elevation_min_m", "elevation_max_m", "sea_level_m"):
check(f"projection.{key}", stored.get_editor_property(key), projection[key])
centre = stored.get_editor_property("centre_m")
check("projection.centre_m", (centre.x, centre.y), tuple(projection["centre_m"]))
level = definition.get_editor_property("level")
check("level", level.export_text(), f"{manifest.level}.{manifest.level.rsplit('/', 1)[1]}")
check("default_layer", str(definition.get_editor_property("default_layer")), manifest.default_layer_id)
stored_layers = definition.get_editor_property("layers")
check("layer count", len(stored_layers), len(manifest.layers))
want_address = unreal.TextureAddress.TA_WRAP if projection["wraps_x"] else unreal.TextureAddress.TA_CLAMP
for entry, want in zip(stored_layers, manifest.layers):
check(f"layer[{want.id}].id", str(entry.get_editor_property("id")), want.id)
texture = entry.get_editor_property("texture")
check(f"layer[{want.id}].texture", texture.get_name() if texture else None, want.asset_name)
if texture:
check(f"layer[{want.id}].size",
(texture.blueprint_get_size_x(), texture.blueprint_get_size_y()),
(manifest.output_width, manifest.output_height))
# Compared as the enum, not as a string: str() on a Python enum is "<TextureAddress.TA_WRAP: 0>".
check(f"layer[{want.id}].address_x", texture.get_editor_property("address_x"), want_address)
if problems:
for line in problems:
unreal.log_error(f" {line}")
raise RuntimeError(f"the definition did not take {len(problems)} of its values; nothing saved")
log(f" verified: projection, level, {len(stored_layers)} layer(s), "
f"{'wrapping' if projection['wraps_x'] else 'non-wrapping'} textures")
def main():
manifest = load_manifest()
region = manifest.region()
report = manifest.report()
projection = manifest.projection(region, report)
log(f"world map: {manifest.describe(region)}")
log(f" projection: {projection['width_m'] / 1000:.2f} x {projection['height_m'] / 1000:.2f} km, "
f"elevation {projection['elevation_min_m']:.0f}..{projection['elevation_max_m']:.0f} m, "
f"{'wraps in X' if projection['wraps_x'] else 'does not wrap'}")
if report is None:
log(" (no mapart.json: the art has not been rendered, so the map is assumed not to wrap)")
missing = [layer for layer in manifest.layers if not os.path.exists(manifest.output_path(layer))]
if missing:
names = ", ".join(layer.output_name for layer in missing)
raise RuntimeError(
f"{names} not in {manifest.output_dir}. Render the art first:\n"
f" cd Tools/MapArt && go run . build")
textures = {}
for layer in manifest.layers:
textures[layer.id] = import_texture(
manifest.output_path(layer), manifest.package, layer.asset_name, projection["wraps_x"])
definition = ensure_definition(manifest)
if definition is None:
log("dry run: nothing written")
return
layers = []
for layer in manifest.layers:
entry = unreal.WorldMapLayer()
entry.set_editor_property("id", unreal.Name(layer.id))
entry.set_editor_property("display_name", unreal.Text(layer.name))
entry.set_editor_property("texture", textures[layer.id])
entry.set_editor_property("note", layer.note)
layers.append(entry)
definition.set_editor_property("projection", make_projection(projection))
definition.set_editor_property("level", unreal.SoftObjectPath(f"{manifest.level}.{manifest.level.rsplit('/', 1)[1]}"))
definition.set_editor_property("layers", layers)
definition.set_editor_property("default_layer", unreal.Name(manifest.default_layer_id))
definition.set_editor_property("built_from", (
f"Scripts/Authoring/create_world_map.py from {os.path.relpath(manifest.path, manifest.resolve('.'))} "
f"and {manifest.region_path}; art at {manifest.output_width}x{manifest.output_height}"))
verify(definition, manifest, projection, textures)
saved = [f"{manifest.package}/{manifest.definition_name}"]
saved += [f"{manifest.package}/{layer.asset_name}" for layer in manifest.layers]
for path in saved:
if not asset_lib.save_asset(path, only_if_is_dirty=False):
raise RuntimeError(f"could not save {path}")
log(f"world map saved: {len(manifest.layers)} layer(s), default '{manifest.default_layer_id}', "
f"definition {manifest.package}/{manifest.definition_name}")
main()