216 lines
10 KiB
Python
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()
|