479 lines
17 KiB
Python
479 lines
17 KiB
Python
import hashlib
|
|
import json
|
|
import os
|
|
import traceback
|
|
|
|
import unreal
|
|
|
|
|
|
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
MAP_ROOT = "/Game/HyperTwistTraining/Maps"
|
|
MATERIAL_ROOT = "/Game/HyperTwistTraining/Materials"
|
|
VERTEX_COLOR_MATERIAL_PATH = f"{MATERIAL_ROOT}/M_HT_ProjectionVertexColor"
|
|
SAFE_TRANSIENT_LEVEL_PATH = "/Engine/Maps/Entry"
|
|
AUTHOR_TAG = "HyperTwistVirtual4DTrainingMap"
|
|
MANIFEST_RELATIVE_PATH = os.path.join(
|
|
"docs",
|
|
"generated",
|
|
"virtual_4d_training_maps",
|
|
"virtual_4d_dedicated_map_manifest.json",
|
|
)
|
|
MANIFEST_ABSOLUTE_PATH = os.path.join(PROJECT_ROOT, MANIFEST_RELATIVE_PATH)
|
|
AUTHORING_RECEIPT_DIRECTORY = os.path.join(
|
|
PROJECT_ROOT,
|
|
"UnrealHyperTwist",
|
|
"Saved",
|
|
"HyperTwistMapAuthoring",
|
|
)
|
|
|
|
MAP_CONFIGS = (
|
|
{
|
|
"order": 2,
|
|
"map_asset_path": f"{MAP_ROOT}/L_HyperTwist_MagicCube4D_2x2x2x2Training",
|
|
"game_mode_class_path": (
|
|
"/Script/UnrealHyperTwist.HyperTwistMelindaProjectionGameMode"
|
|
),
|
|
"projection_actor_class_path": (
|
|
"/Script/UnrealHyperTwist.HyperTwistMelindaProjectionActor"
|
|
),
|
|
"runtime_profile": "melinda-cell-first-exact-runtime-v1",
|
|
},
|
|
{
|
|
"order": 3,
|
|
"map_asset_path": f"{MAP_ROOT}/L_HyperTwist_MagicCube4D_3x3x3x3Training",
|
|
"game_mode_class_path": (
|
|
"/Script/UnrealHyperTwist.HyperTwistVirtual3333ProjectionGameMode"
|
|
),
|
|
"projection_actor_class_path": (
|
|
"/Script/UnrealHyperTwist.HyperTwistVirtual3333ProjectionActor"
|
|
),
|
|
"runtime_profile": "hypercube-3x3x3x3-runtime-v1",
|
|
},
|
|
{
|
|
"order": 4,
|
|
"map_asset_path": f"{MAP_ROOT}/L_HyperTwist_MagicCube4D_4x4x4x4Training",
|
|
"game_mode_class_path": (
|
|
"/Script/UnrealHyperTwist.HyperTwistVirtual4444ProjectionGameMode"
|
|
),
|
|
"projection_actor_class_path": (
|
|
"/Script/UnrealHyperTwist.HyperTwistVirtual3333ProjectionActor"
|
|
),
|
|
"runtime_profile": "hypercube-4x4x4x4-runtime-v1",
|
|
},
|
|
{
|
|
"order": 5,
|
|
"map_asset_path": f"{MAP_ROOT}/L_HyperTwist_MagicCube4D_5x5x5x5Training",
|
|
"game_mode_class_path": (
|
|
"/Script/UnrealHyperTwist.HyperTwistVirtual5555ProjectionGameMode"
|
|
),
|
|
"projection_actor_class_path": (
|
|
"/Script/UnrealHyperTwist.HyperTwistVirtual3333ProjectionActor"
|
|
),
|
|
"runtime_profile": "hypercube-5x5x5x5-runtime-v1",
|
|
},
|
|
{
|
|
"order": 6,
|
|
"map_asset_path": f"{MAP_ROOT}/L_HyperTwist_MagicCube4D_6x6x6x6Training",
|
|
"game_mode_class_path": (
|
|
"/Script/UnrealHyperTwist.HyperTwistVirtual6666ProjectionGameMode"
|
|
),
|
|
"projection_actor_class_path": (
|
|
"/Script/UnrealHyperTwist.HyperTwistVirtual3333ProjectionActor"
|
|
),
|
|
"runtime_profile": "hypercube-6x6x6x6-runtime-v1",
|
|
},
|
|
)
|
|
|
|
|
|
def log(message: str) -> None:
|
|
unreal.log(f"[HyperTwistVirtual4DMapAuthoring] {message}")
|
|
|
|
|
|
def ensure_directory(directory_path: str) -> None:
|
|
if not unreal.EditorAssetLibrary.does_directory_exist(directory_path):
|
|
if not unreal.EditorAssetLibrary.make_directory(directory_path):
|
|
raise RuntimeError(f"Failed to create content directory: {directory_path}")
|
|
|
|
|
|
def split_asset_path(asset_path: str):
|
|
package_path, asset_name = asset_path.rsplit("/", 1)
|
|
return package_path, asset_name
|
|
|
|
|
|
def require_class(class_path: str):
|
|
loaded_class = unreal.load_class(None, class_path)
|
|
if loaded_class is None:
|
|
raise RuntimeError(f"Required class was not found: {class_path}")
|
|
return loaded_class
|
|
|
|
|
|
def require_asset(asset_path: str):
|
|
asset = unreal.EditorAssetLibrary.load_asset(asset_path)
|
|
if asset is None:
|
|
raise RuntimeError(f"Required asset was not found: {asset_path}")
|
|
return asset
|
|
|
|
|
|
def create_asset(asset_path: str, asset_class, factory):
|
|
package_path, asset_name = split_asset_path(asset_path)
|
|
ensure_directory(package_path)
|
|
asset = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
|
|
asset_name,
|
|
package_path,
|
|
asset_class,
|
|
factory,
|
|
)
|
|
if asset is None:
|
|
raise RuntimeError(f"Failed to create asset: {asset_path}")
|
|
return asset
|
|
|
|
|
|
def ensure_projection_material():
|
|
if unreal.EditorAssetLibrary.does_asset_exist(VERTEX_COLOR_MATERIAL_PATH):
|
|
material = require_asset(VERTEX_COLOR_MATERIAL_PATH)
|
|
else:
|
|
material = create_asset(
|
|
VERTEX_COLOR_MATERIAL_PATH,
|
|
unreal.Material,
|
|
unreal.MaterialFactoryNew(),
|
|
)
|
|
|
|
vertex_color = unreal.MaterialEditingLibrary.create_material_expression(
|
|
material,
|
|
unreal.MaterialExpressionVertexColor,
|
|
-560,
|
|
-80,
|
|
)
|
|
glow_strength = unreal.MaterialEditingLibrary.create_material_expression(
|
|
material,
|
|
unreal.MaterialExpressionScalarParameter,
|
|
-560,
|
|
120,
|
|
)
|
|
glow_strength.set_editor_property("parameter_name", "GlowStrength")
|
|
glow_strength.set_editor_property("default_value", 1.15)
|
|
emissive = unreal.MaterialEditingLibrary.create_material_expression(
|
|
material,
|
|
unreal.MaterialExpressionMultiply,
|
|
-240,
|
|
-20,
|
|
)
|
|
unreal.MaterialEditingLibrary.connect_material_expressions(
|
|
vertex_color,
|
|
"RGB",
|
|
emissive,
|
|
"A",
|
|
)
|
|
unreal.MaterialEditingLibrary.connect_material_expressions(
|
|
glow_strength,
|
|
"",
|
|
emissive,
|
|
"B",
|
|
)
|
|
unreal.MaterialEditingLibrary.connect_material_property(
|
|
emissive,
|
|
"",
|
|
unreal.MaterialProperty.MP_EMISSIVE_COLOR,
|
|
)
|
|
unreal.MaterialEditingLibrary.connect_material_property(
|
|
vertex_color,
|
|
"A",
|
|
unreal.MaterialProperty.MP_OPACITY,
|
|
)
|
|
unreal.MaterialEditingLibrary.layout_material_expressions(material)
|
|
|
|
material.set_editor_property("two_sided", True)
|
|
material.set_editor_property("blend_mode", unreal.BlendMode.BLEND_TRANSLUCENT)
|
|
material.set_editor_property(
|
|
"shading_model",
|
|
unreal.MaterialShadingModel.MSM_UNLIT,
|
|
)
|
|
unreal.MaterialEditingLibrary.recompile_material(material)
|
|
if not unreal.EditorAssetLibrary.save_loaded_asset(material, False):
|
|
raise RuntimeError(
|
|
f"Failed to save projection material: {VERTEX_COLOR_MATERIAL_PATH}"
|
|
)
|
|
log(f"Verified projection material {VERTEX_COLOR_MATERIAL_PATH}")
|
|
return material
|
|
|
|
|
|
def map_file_relative_path(config) -> str:
|
|
map_name = config["map_asset_path"].rsplit("/", 1)[-1]
|
|
return os.path.join(
|
|
"UnrealHyperTwist",
|
|
"Content",
|
|
"HyperTwistTraining",
|
|
"Maps",
|
|
f"{map_name}.umap",
|
|
)
|
|
|
|
|
|
def map_file_absolute_path(config) -> str:
|
|
return os.path.join(PROJECT_ROOT, map_file_relative_path(config))
|
|
|
|
|
|
def map_object_path(config) -> str:
|
|
map_name = config["map_asset_path"].rsplit("/", 1)[-1]
|
|
return f"{config['map_asset_path']}.{map_name}"
|
|
|
|
|
|
def delete_existing_level(level_subsystem, config) -> None:
|
|
map_path = config["map_asset_path"]
|
|
map_file = map_file_absolute_path(config)
|
|
if (
|
|
not os.path.exists(map_file)
|
|
and not unreal.EditorAssetLibrary.does_asset_exist(map_path)
|
|
and not unreal.EditorAssetLibrary.does_asset_exist(map_object_path(config))
|
|
):
|
|
return
|
|
|
|
if not level_subsystem.load_level(SAFE_TRANSIENT_LEVEL_PATH):
|
|
raise RuntimeError(
|
|
f"Failed to load {SAFE_TRANSIENT_LEVEL_PATH} before replacing {map_path}."
|
|
)
|
|
|
|
for candidate_path in (map_object_path(config), map_path):
|
|
if unreal.EditorAssetLibrary.does_asset_exist(candidate_path):
|
|
unreal.EditorAssetLibrary.delete_asset(candidate_path)
|
|
|
|
if os.path.exists(map_file):
|
|
os.remove(map_file)
|
|
|
|
|
|
def configure_world_settings(config) -> None:
|
|
editor_subsystem = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
|
|
if editor_subsystem is None:
|
|
raise RuntimeError("UnrealEditorSubsystem was not available.")
|
|
world = editor_subsystem.get_editor_world()
|
|
world_settings = world.get_world_settings() if world is not None else None
|
|
if world_settings is None:
|
|
raise RuntimeError("World settings were not available.")
|
|
world_settings.set_editor_property(
|
|
"default_game_mode",
|
|
require_class(config["game_mode_class_path"]),
|
|
)
|
|
|
|
|
|
def spawn_actor(actor_class, label: str, location, rotation):
|
|
actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
|
if actor_subsystem is None:
|
|
raise RuntimeError("EditorActorSubsystem was not available.")
|
|
actor = actor_subsystem.spawn_actor_from_class(actor_class, location, rotation)
|
|
if actor is None:
|
|
raise RuntimeError(f"Failed to spawn actor: {label}")
|
|
actor.set_actor_label(label)
|
|
return actor
|
|
|
|
|
|
def place_map_anchor(config) -> None:
|
|
order = config["order"]
|
|
player_start = spawn_actor(
|
|
unreal.PlayerStart,
|
|
f"HT_Virtual4D_{order}x_PlayerStart",
|
|
unreal.Vector(-720.0, 0.0, 180.0),
|
|
unreal.Rotator(0.0, 0.0, 0.0),
|
|
)
|
|
player_start.set_editor_property(
|
|
"tags",
|
|
[
|
|
unreal.Name(AUTHOR_TAG),
|
|
unreal.Name(f"puzzle-order:{order}"),
|
|
unreal.Name(f"runtime-profile:{config['runtime_profile']}"),
|
|
unreal.Name("input:keyboard-mouse"),
|
|
unreal.Name("projection-spawn:runtime-game-mode"),
|
|
unreal.Name(
|
|
f"projection-class:{config['projection_actor_class_path']}"
|
|
),
|
|
unreal.Name("ownership:first-party-dedicated-map"),
|
|
],
|
|
)
|
|
|
|
|
|
def validate_current_map(config) -> None:
|
|
actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
|
editor_subsystem = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
|
|
if actor_subsystem is None or editor_subsystem is None:
|
|
raise RuntimeError("Editor subsystems were unavailable during validation.")
|
|
|
|
actors_by_label = {
|
|
actor.get_actor_label(): actor
|
|
for actor in actor_subsystem.get_all_level_actors()
|
|
if actor is not None
|
|
}
|
|
order = config["order"]
|
|
expected_anchor_label = f"HT_Virtual4D_{order}x_PlayerStart"
|
|
missing = {expected_anchor_label} - set(actors_by_label)
|
|
if missing:
|
|
raise RuntimeError(
|
|
"Authored map is missing required actors: " + ", ".join(sorted(missing))
|
|
)
|
|
|
|
anchor_tags = {
|
|
str(tag) for tag in actors_by_label[expected_anchor_label].tags
|
|
}
|
|
expected_anchor_tags = {
|
|
AUTHOR_TAG,
|
|
f"puzzle-order:{order}",
|
|
f"runtime-profile:{config['runtime_profile']}",
|
|
"input:keyboard-mouse",
|
|
"projection-spawn:runtime-game-mode",
|
|
f"projection-class:{config['projection_actor_class_path']}",
|
|
"ownership:first-party-dedicated-map",
|
|
}
|
|
missing_tags = expected_anchor_tags - anchor_tags
|
|
if missing_tags:
|
|
raise RuntimeError(
|
|
"Authored map anchor is missing required ownership tags: "
|
|
+ ", ".join(sorted(missing_tags))
|
|
)
|
|
|
|
if not unreal.EditorAssetLibrary.does_asset_exist(VERTEX_COLOR_MATERIAL_PATH):
|
|
raise RuntimeError(
|
|
f"Authored map projection material is missing: {VERTEX_COLOR_MATERIAL_PATH}"
|
|
)
|
|
|
|
world = editor_subsystem.get_editor_world()
|
|
world_settings = world.get_world_settings() if world is not None else None
|
|
game_mode = (
|
|
world_settings.get_editor_property("default_game_mode")
|
|
if world_settings is not None
|
|
else None
|
|
)
|
|
expected_game_mode_name = config["game_mode_class_path"].rsplit(".", 1)[-1]
|
|
if game_mode is None or game_mode.get_name() != expected_game_mode_name:
|
|
raise RuntimeError(
|
|
f"Map {config['map_asset_path']} does not own {expected_game_mode_name}."
|
|
)
|
|
|
|
|
|
def compute_md5(file_path: str) -> str:
|
|
digest = hashlib.md5()
|
|
with open(file_path, "rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def write_receipt(config) -> None:
|
|
map_file = map_file_absolute_path(config)
|
|
if not os.path.exists(map_file):
|
|
raise RuntimeError(f"Authored map file was not saved: {map_file}")
|
|
os.makedirs(AUTHORING_RECEIPT_DIRECTORY, exist_ok=True)
|
|
order = config["order"]
|
|
receipt_path = os.path.join(
|
|
AUTHORING_RECEIPT_DIRECTORY,
|
|
f"virtual_4d_{order}x_complete.json",
|
|
)
|
|
receipt = {
|
|
"schemaVersion": "hypertwist/virtual-4d-map-authoring-receipt/v1",
|
|
"order": order,
|
|
"mapAssetPath": config["map_asset_path"],
|
|
"mapHashMd5": compute_md5(map_file),
|
|
"gameModeClassPath": config["game_mode_class_path"],
|
|
"projectionActorClassPath": config["projection_actor_class_path"],
|
|
"projectionSpawnMode": "runtime-game-mode",
|
|
"runtimeProfile": config["runtime_profile"],
|
|
"validatedActorLabels": [
|
|
f"HT_Virtual4D_{order}x_PlayerStart",
|
|
],
|
|
"authoringComplete": True,
|
|
}
|
|
with open(receipt_path, "w", encoding="utf-8") as handle:
|
|
json.dump(receipt, handle, indent=2)
|
|
handle.write("\n")
|
|
|
|
|
|
def author_map(config) -> None:
|
|
level_subsystem = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
|
if level_subsystem is None:
|
|
raise RuntimeError("LevelEditorSubsystem was not available.")
|
|
|
|
delete_existing_level(level_subsystem, config)
|
|
if not level_subsystem.new_level(config["map_asset_path"]):
|
|
raise RuntimeError(f"Failed to create level: {config['map_asset_path']}")
|
|
configure_world_settings(config)
|
|
# Procedural projection actors are runtime-owned. Spawning them under
|
|
# UnrealEditor-Cmd + NullRHI can crash UE 5.7 before a map is certifiable.
|
|
place_map_anchor(config)
|
|
if not level_subsystem.save_current_level():
|
|
raise RuntimeError(f"Failed to save level: {config['map_asset_path']}")
|
|
validate_current_map(config)
|
|
write_receipt(config)
|
|
log(f"Authored {config['map_asset_path']}")
|
|
|
|
|
|
def write_manifest() -> None:
|
|
entries = []
|
|
for config in MAP_CONFIGS:
|
|
map_file = map_file_absolute_path(config)
|
|
if not os.path.exists(map_file):
|
|
raise RuntimeError(f"Cannot seal manifest; map is missing: {map_file}")
|
|
order = config["order"]
|
|
entries.append(
|
|
{
|
|
"order": order,
|
|
"puzzleId": f"hypercube/{order}x{order}x{order}x{order}",
|
|
"mapAssetPath": config["map_asset_path"],
|
|
"mapFileRelativePath": map_file_relative_path(config).replace("\\", "/"),
|
|
"mapHashMd5": compute_md5(map_file),
|
|
"gameModeClassPath": config["game_mode_class_path"],
|
|
"projectionActorClassPath": config["projection_actor_class_path"],
|
|
"projectionSpawnMode": "runtime-game-mode",
|
|
"runtimeProfile": config["runtime_profile"],
|
|
"inputMode": "keyboard-mouse",
|
|
"authorTag": AUTHOR_TAG,
|
|
}
|
|
)
|
|
|
|
os.makedirs(os.path.dirname(MANIFEST_ABSOLUTE_PATH), exist_ok=True)
|
|
manifest = {
|
|
"schemaVersion": "hypertwist/virtual-4d-dedicated-map-manifest/v1",
|
|
"manifestVersion": "2026.07.23",
|
|
"authoringScriptRelativePath": (
|
|
"scripts/hypertwist_author_virtual_4d_training_maps.py"
|
|
),
|
|
"projectionMaterialAssetPath": VERTEX_COLOR_MATERIAL_PATH,
|
|
"entries": entries,
|
|
}
|
|
with open(MANIFEST_ABSOLUTE_PATH, "w", encoding="utf-8") as handle:
|
|
json.dump(manifest, handle, indent=2)
|
|
handle.write("\n")
|
|
log(f"Wrote manifest {MANIFEST_ABSOLUTE_PATH}")
|
|
|
|
|
|
def all_map_files_exist() -> bool:
|
|
return all(os.path.exists(map_file_absolute_path(config)) for config in MAP_CONFIGS)
|
|
|
|
|
|
def main() -> None:
|
|
ensure_directory(MAP_ROOT)
|
|
ensure_directory(MATERIAL_ROOT)
|
|
ensure_projection_material()
|
|
requested_order = os.getenv("HYPERTWIST_VIRTUAL_4D_ORDER", "").strip()
|
|
targets = MAP_CONFIGS
|
|
if requested_order:
|
|
try:
|
|
order = int(requested_order)
|
|
except ValueError as error:
|
|
raise RuntimeError(
|
|
f"Invalid HYPERTWIST_VIRTUAL_4D_ORDER: {requested_order}"
|
|
) from error
|
|
targets = tuple(config for config in MAP_CONFIGS if config["order"] == order)
|
|
if not targets:
|
|
raise RuntimeError(f"Unsupported 4D puzzle order: {order}")
|
|
|
|
for config in targets:
|
|
author_map(config)
|
|
if not requested_order or all_map_files_exist():
|
|
write_manifest()
|
|
log("All requested virtual 4D training maps are ready.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except Exception as error:
|
|
unreal.log_error(
|
|
f"[HyperTwistVirtual4DMapAuthoring] {error}\n{traceback.format_exc()}"
|
|
)
|
|
raise
|