Tooling
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user