import os import json import datetime import traceback import unreal MAP_ROOT = "/Game/HyperTwistTraining/Maps" MATERIAL_ROOT = "/Game/HyperTwistTraining/Materials" CLASSIC_MAP_PATH = f"{MAP_ROOT}/L_HyperTwist_ClassicTraining" FOLLOW_ALONG_MAP_PATH = f"{MAP_ROOT}/L_HyperTwist_FollowAlongTraining" AUTHOR_TAG = "HyperTwistClassicCubeTrainingMap" AUTHORING_MARKER_PATH = os.getenv("HYPERTWIST_CLASSIC_CUBE_AUTHORING_MARKER", "").strip() SAFE_TRANSIENT_LEVEL_PATH = "/Engine/Maps/Entry" MASTER_MATERIAL_PATH = f"{MATERIAL_ROOT}/M_HT_ClassicCubeFaceMaster" INTERNAL_MATERIAL_PATH = f"{MATERIAL_ROOT}/MI_HT_ClassicCube_Internal" FACE_MATERIAL_SPECS = ( ("MI_HT_ClassicCube_Up", unreal.LinearColor(1.0, 1.0, 1.0, 1.0)), ("MI_HT_ClassicCube_Down", unreal.LinearColor(1.0, 0.92, 0.1, 1.0)), ("MI_HT_ClassicCube_Front", unreal.LinearColor(0.0, 0.55, 0.2, 1.0)), ("MI_HT_ClassicCube_Back", unreal.LinearColor(0.0, 0.22, 0.75, 1.0)), ("MI_HT_ClassicCube_Left", unreal.LinearColor(0.92, 0.36, 0.02, 1.0)), ("MI_HT_ClassicCube_Right", unreal.LinearColor(0.78, 0.04, 0.04, 1.0)), ) def log(message: str) -> None: unreal.log(f"[HyperTwistClassicCubeMapAuthoring] {message}") def warn(message: str) -> None: unreal.log_warning(f"[HyperTwistClassicCubeMapAuthoring] {message}") 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 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 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 create_asset(asset_path: str, asset_class, factory): package_path, asset_name = split_asset_path(asset_path) ensure_directory(package_path) asset_tools = unreal.AssetToolsHelpers.get_asset_tools() asset = 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_face_master_material(): if unreal.EditorAssetLibrary.does_asset_exist(MASTER_MATERIAL_PATH): material = require_asset(MASTER_MATERIAL_PATH) else: material = create_asset( MASTER_MATERIAL_PATH, unreal.Material, unreal.MaterialFactoryNew(), ) base_color = unreal.MaterialEditingLibrary.create_material_expression( material, unreal.MaterialExpressionVectorParameter, -640, -120, ) base_color.set_editor_property("parameter_name", "BaseColor") base_color.set_editor_property("default_value", unreal.LinearColor(1.0, 1.0, 1.0, 1.0)) glow_strength = unreal.MaterialEditingLibrary.create_material_expression( material, unreal.MaterialExpressionScalarParameter, -640, 80, ) glow_strength.set_editor_property("parameter_name", "GlowStrength") glow_strength.set_editor_property("default_value", 0.0) face_opacity = unreal.MaterialEditingLibrary.create_material_expression( material, unreal.MaterialExpressionScalarParameter, -640, 240, ) face_opacity.set_editor_property("parameter_name", "FaceOpacity") face_opacity.set_editor_property("default_value", 1.0) emissive = unreal.MaterialEditingLibrary.create_material_expression( material, unreal.MaterialExpressionMultiply, -280, 0, ) unreal.MaterialEditingLibrary.connect_material_expressions(base_color, "", emissive, "A") unreal.MaterialEditingLibrary.connect_material_expressions(glow_strength, "", emissive, "B") unreal.MaterialEditingLibrary.connect_material_property( base_color, "", unreal.MaterialProperty.MP_BASE_COLOR, ) unreal.MaterialEditingLibrary.connect_material_property( emissive, "", unreal.MaterialProperty.MP_EMISSIVE_COLOR, ) unreal.MaterialEditingLibrary.layout_material_expressions(material) material.set_editor_property("two_sided", False) # Phase 6C uses the same palette on instanced projection geometry. Without # this authored usage flag, cooked builds silently substitute a neutral # fallback shader even though the six material bindings remain non-null. material.set_editor_property("used_with_instanced_static_meshes", True) unreal.MaterialEditingLibrary.recompile_material(material) if not unreal.EditorAssetLibrary.save_loaded_asset(material, False): raise RuntimeError(f"Failed to save face master material: {MASTER_MATERIAL_PATH}") if not material.get_editor_property("used_with_instanced_static_meshes"): raise RuntimeError( "Face master material did not retain instanced-static-mesh usage after save." ) log(f"Verified face master material and instanced-mesh usage: {MASTER_MATERIAL_PATH}") return material def ensure_material_instance(asset_path: str, parent_material, color: unreal.LinearColor): if unreal.EditorAssetLibrary.does_asset_exist(asset_path): instance = require_asset(asset_path) else: instance = create_asset( asset_path, unreal.MaterialInstanceConstant, unreal.MaterialInstanceConstantFactoryNew(), ) unreal.MaterialEditingLibrary.set_material_instance_parent(instance, parent_material) unreal.MaterialEditingLibrary.set_material_instance_vector_parameter_value( instance, "BaseColor", color, ) unreal.MaterialEditingLibrary.set_material_instance_scalar_parameter_value( instance, "GlowStrength", 0.0, ) unreal.MaterialEditingLibrary.set_material_instance_scalar_parameter_value( instance, "FaceOpacity", 1.0, ) if not unreal.EditorAssetLibrary.save_loaded_asset(instance, False): raise RuntimeError(f"Failed to save material instance: {asset_path}") log(f"Verified material instance {asset_path}") return instance def ensure_cube_material_family(): ensure_directory(MATERIAL_ROOT) master_material = ensure_face_master_material() face_materials = [ ensure_material_instance(f"{MATERIAL_ROOT}/{asset_name}", master_material, color) for asset_name, color in FACE_MATERIAL_SPECS ] internal_material = ensure_material_instance( INTERNAL_MATERIAL_PATH, master_material, unreal.LinearColor(0.05, 0.05, 0.05, 1.0), ) return face_materials, internal_material def spawn_actor(actor_class, label: str, location: unreal.Vector, rotation: unreal.Rotator): 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) actor.set_editor_property("tags", [unreal.Name(AUTHOR_TAG)]) return actor def ensure_floor_plane() -> None: floor_actor = spawn_actor( unreal.StaticMeshActor, "HT_ClassicCubeTraining_Floor", unreal.Vector(0.0, 0.0, -24.0), unreal.Rotator(0.0, 0.0, 0.0), ) static_mesh_component = floor_actor.static_mesh_component static_mesh_component.set_editor_property( "static_mesh", require_asset("/Engine/BasicShapes/Cube.Cube"), ) static_mesh_component.set_editor_property("mobility", unreal.ComponentMobility.STATIC) # A thin volumetric floor avoids the zero-extent bounds path that can crash # UE 5.7 editor automation when a Plane is authored under a headless RHI. floor_actor.set_actor_scale3d(unreal.Vector(14.0, 14.0, 0.08)) def ensure_directional_light() -> None: directional_light = spawn_actor( unreal.DirectionalLight, "HT_ClassicCubeTraining_Sun", unreal.Vector(-300.0, 150.0, 420.0), unreal.Rotator(-42.0, -38.0, 0.0), ) light_component = directional_light.get_component_by_class(unreal.DirectionalLightComponent) if light_component is not None: light_component.set_editor_property("mobility", unreal.ComponentMobility.MOVABLE) light_component.set_editor_property("intensity", 9.0) def ensure_skylight() -> None: skylight = spawn_actor( unreal.SkyLight, "HT_ClassicCubeTraining_Sky", unreal.Vector(0.0, 0.0, 220.0), unreal.Rotator(0.0, 0.0, 0.0), ) skylight_component = skylight.get_component_by_class(unreal.SkyLightComponent) if skylight_component is not None: skylight_component.set_editor_property("mobility", unreal.ComponentMobility.MOVABLE) skylight_component.set_editor_property("real_time_capture", True) skylight_component.set_editor_property("intensity", 1.2) def ensure_sky_atmosphere() -> None: spawn_actor( unreal.SkyAtmosphere, "HT_ClassicCubeTraining_Atmosphere", unreal.Vector(0.0, 0.0, 0.0), unreal.Rotator(0.0, 0.0, 0.0), ) def ensure_reflection_capture() -> None: reflection_capture = spawn_actor( unreal.SphereReflectionCapture, "HT_ClassicCubeTraining_Reflection", unreal.Vector(0.0, 0.0, 140.0), unreal.Rotator(0.0, 0.0, 0.0), ) reflection_capture.set_actor_scale3d(unreal.Vector(12.0, 12.0, 12.0)) def ensure_player_start() -> None: spawn_actor( unreal.PlayerStart, "HT_ClassicCubeTraining_PlayerStart", unreal.Vector(-540.0, 0.0, 170.0), unreal.Rotator(0.0, 0.0, 0.0), ) def ensure_cube_actor(): cube_class = require_class("/Script/UnrealHyperTwist.HyperTwistClassicCubeActor") cube_actor = spawn_actor( cube_class, "HT_ClassicCubeTraining_Cube", unreal.Vector(0.0, 0.0, 0.0), unreal.Rotator(0.0, 0.0, 0.0), ) face_materials, internal_material = ensure_cube_material_family() cube_actor.set_editor_property("face_materials", face_materials) cube_actor.set_editor_property("internal_material", internal_material) if hasattr(cube_actor, "reset_cube"): cube_actor.reset_cube() else: warn("Classic cube actor did not expose reset_cube() to Python; relying on saved properties.") return cube_actor def save_current_level_or_raise(level_subsystem, label: str) -> None: if not level_subsystem.save_current_level(): raise RuntimeError(f"Failed to save level after {label}.") log(f"Saved current level after {label}") def validate_authored_actor_family(): actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) if actor_subsystem is None: raise RuntimeError("EditorActorSubsystem was not available for authored-map validation.") expected_labels = { "HT_ClassicCubeTraining_Floor", "HT_ClassicCubeTraining_Sun", "HT_ClassicCubeTraining_Sky", "HT_ClassicCubeTraining_Atmosphere", "HT_ClassicCubeTraining_Reflection", "HT_ClassicCubeTraining_PlayerStart", "HT_ClassicCubeTraining_Cube", } actor_labels = { actor.get_actor_label() for actor in actor_subsystem.get_all_level_actors() if actor is not None } missing_labels = sorted(expected_labels - actor_labels) if missing_labels: raise RuntimeError( "Authored map is missing required actors: " + ", ".join(missing_labels) ) return sorted(expected_labels) def write_completion_marker(target: str, map_asset_path: str, actor_labels) -> None: if not AUTHORING_MARKER_PATH: raise RuntimeError( "HYPERTWIST_CLASSIC_CUBE_AUTHORING_MARKER must identify the transactional completion marker." ) marker_path = os.path.abspath(AUTHORING_MARKER_PATH) os.makedirs(os.path.dirname(marker_path), exist_ok=True) marker_payload = { "schemaVersion": 1, "stage": "complete", "target": target, "mapAssetPath": map_asset_path, "actorLabels": actor_labels, "completedAtUtc": datetime.datetime.now(datetime.timezone.utc).isoformat(), } temporary_path = marker_path + ".tmp" with open(temporary_path, "w", encoding="utf-8", newline="\n") as marker_file: json.dump(marker_payload, marker_file, indent=2, sort_keys=True) marker_file.write("\n") marker_file.flush() os.fsync(marker_file.fileno()) os.replace(temporary_path, marker_path) log(f"Wrote transactional completion marker {marker_path}") def configure_world_settings(game_mode_class_path: str) -> 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() if world is None: raise RuntimeError("Editor world was not available after loading the target level.") world_settings = world.get_world_settings() if world_settings is None: raise RuntimeError("World settings were not available for the target level.") world_settings.set_editor_property("default_game_mode", require_class(game_mode_class_path)) def recreate_level(level_subsystem, map_asset_path: str) -> None: if unreal.EditorAssetLibrary.does_asset_exist(map_asset_path): if not level_subsystem.load_level(SAFE_TRANSIENT_LEVEL_PATH): raise RuntimeError( f"Failed to load transient level '{SAFE_TRANSIENT_LEVEL_PATH}' before rebuilding " f"'{map_asset_path}'." ) if not unreal.EditorAssetLibrary.delete_asset(map_asset_path): raise RuntimeError(f"Failed to delete existing level: {map_asset_path}") log(f"Deleted existing level {map_asset_path} before rebuild") if not level_subsystem.new_level(map_asset_path): raise RuntimeError(f"Failed to create level: {map_asset_path}") log(f"Created new level {map_asset_path}") def author_map(map_asset_path: str, game_mode_class_path: str): level_subsystem = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem) if level_subsystem is None: raise RuntimeError("LevelEditorSubsystem was not available.") recreate_level(level_subsystem, map_asset_path) save_current_level_or_raise(level_subsystem, "level recreation") configure_world_settings(game_mode_class_path) save_current_level_or_raise(level_subsystem, "world settings") ensure_floor_plane() save_current_level_or_raise(level_subsystem, "floor placement") ensure_directional_light() save_current_level_or_raise(level_subsystem, "directional light placement") ensure_skylight() save_current_level_or_raise(level_subsystem, "skylight placement") ensure_sky_atmosphere() save_current_level_or_raise(level_subsystem, "sky-atmosphere placement") ensure_reflection_capture() save_current_level_or_raise(level_subsystem, "reflection capture placement") ensure_player_start() save_current_level_or_raise(level_subsystem, "player start placement") ensure_cube_actor() save_current_level_or_raise(level_subsystem, "cube placement") actor_labels = validate_authored_actor_family() log(f"Finished authoring {map_asset_path}") return actor_labels def main() -> None: ensure_directory(MAP_ROOT) ensure_directory(MATERIAL_ROOT) ensure_cube_material_family() requested_map_kind = os.getenv("HYPERTWIST_CLASSIC_CUBE_MAP_KIND", "").strip().lower() if requested_map_kind in ("", "all", "both"): targets = ( ( CLASSIC_MAP_PATH, "/Script/UnrealHyperTwist.HyperTwistClassicCubeGameMode", ), ( FOLLOW_ALONG_MAP_PATH, "/Script/UnrealHyperTwist.HyperTwistClassicCubeFollowAlongGameMode", ), ) elif requested_map_kind == "classic": targets = ( ( CLASSIC_MAP_PATH, "/Script/UnrealHyperTwist.HyperTwistClassicCubeGameMode", ), ) elif requested_map_kind in ("followalong", "follow-along"): targets = ( ( FOLLOW_ALONG_MAP_PATH, "/Script/UnrealHyperTwist.HyperTwistClassicCubeFollowAlongGameMode", ), ) else: raise RuntimeError( f"Unsupported HYPERTWIST_CLASSIC_CUBE_MAP_KIND value: {requested_map_kind}" ) if len(targets) != 1: raise RuntimeError( "Transactional map authoring requires one target per UnrealEditor process." ) for map_asset_path, game_mode_class_path in targets: actor_labels = author_map(map_asset_path, game_mode_class_path) write_completion_marker(requested_map_kind, map_asset_path, actor_labels) log("Classic cube authored maps are ready.") if __name__ == "__main__": try: main() except Exception as error: unreal.log_error( f"[HyperTwistClassicCubeMapAuthoring] {error}\n{traceback.format_exc()}" ) raise