hypertwist/scripts/hypertwist_author_virtual_4d_training_maps.py
2026-07-23 17:23:24 +00:00

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