Merge of the two project copies into one self-contained plugin (the superset: variable_op + variables.py, full pcg_op runtime/declarative/preset ops, the CreateWidgetFloatAnimation widget tool, and full Voxel graph authoring). Made fully project- and machine-agnostic — no hardcoded paths: - New src/projectPaths.js auto-detects the host .uproject (walk-up), project name, Editor build target, log file, and engine install (EngineAssociation via launcher manifest/registry, else installed-engine scan). All overridable via UE_* env vars. - Rewired buildOrchestrator/insights/launcher/insightsExporter/server.js and the Python workers (cpp_scaffold, live_coding, apply_graph, console) off the old C:/Github/ihy, IHY*, E:/UE_Versions and UE_5.7 literals onto the resolver. - Voxel made optional: Build.cs auto-detects the Voxel plugin (env UE_MCP_WITH_VOXEL override) and the C++ compiles to stubs under WITH_MCP_VOXEL, so the module builds in projects without Voxel; .uplugin marks Voxel optional. - De-branded the agent-gateway and docs; scrubbed a leaked API key; excluded node_modules/Binaries/Intermediate/__pycache__/secrets from the repo. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
272 lines
9.5 KiB
Python
272 lines
9.5 KiB
Python
"""C++ class scaffold — generate .h/.cpp templates under a project module.
|
|
|
|
This does NOT compile the code — pair with live_coding.compile or a regular
|
|
hot-reload after the files appear. UE will pick up new files on next build.
|
|
|
|
Ops:
|
|
list_modules {} — read Source/*/Build.cs
|
|
create_class {name, parent_class, module?(default: primary game module), subfolder?, header_only?:false, public?:true, api_macro?:auto}
|
|
list_classes {module?, subfolder?} — list .h files under a module
|
|
read_class {name, module?} — return .h+.cpp text
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import traceback
|
|
|
|
try:
|
|
import unreal # type: ignore
|
|
except ImportError:
|
|
unreal = None
|
|
|
|
|
|
def _project_dir():
|
|
if unreal is not None:
|
|
return unreal.Paths.convert_relative_path_to_full(unreal.Paths.project_dir()).rstrip("/\\")
|
|
return os.getcwd()
|
|
|
|
|
|
def _source_dir():
|
|
return os.path.join(_project_dir(), "Source")
|
|
|
|
|
|
def _project_name():
|
|
"""Project name = the .uproject base name (also the conventional primary module)."""
|
|
if unreal is not None:
|
|
try:
|
|
up = unreal.Paths.get_project_file_path() # e.g. .../MyGame.uproject
|
|
return os.path.splitext(os.path.basename(up))[0]
|
|
except Exception:
|
|
pass
|
|
return os.path.basename(_project_dir().rstrip("/\\"))
|
|
|
|
|
|
def _default_module():
|
|
"""Primary game module to scaffold into. Prefer the Runtime module declared in
|
|
the .uproject; fall back to the module whose folder matches the project name;
|
|
else the first Source module; else the project name."""
|
|
proj = _project_name()
|
|
# 1. Read the .uproject Modules list for a Runtime/Game module.
|
|
try:
|
|
up = unreal.Paths.get_project_file_path() if unreal is not None else None
|
|
if up and os.path.isfile(up):
|
|
with open(up, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
mods = data.get("Modules") or []
|
|
runtime = [m for m in mods if str(m.get("Type", "")).lower() in ("runtime", "")]
|
|
if runtime:
|
|
return runtime[0].get("Name") or proj
|
|
if mods:
|
|
return mods[0].get("Name") or proj
|
|
except Exception:
|
|
pass
|
|
# 2. Folder matching the project name.
|
|
src = _source_dir()
|
|
if os.path.isdir(os.path.join(src, proj)):
|
|
return proj
|
|
# 3. First Source module with a Build.cs.
|
|
try:
|
|
for name in sorted(os.listdir(src)):
|
|
if os.path.isfile(os.path.join(src, name, f"{name}.Build.cs")):
|
|
return name
|
|
except Exception:
|
|
pass
|
|
return proj
|
|
|
|
|
|
def list_modules(args):
|
|
out = []
|
|
src = _source_dir()
|
|
if not os.path.isdir(src):
|
|
return {"ok": False, "error": f"no Source dir at {src}"}
|
|
for name in os.listdir(src):
|
|
p = os.path.join(src, name)
|
|
if os.path.isdir(p):
|
|
build_cs = os.path.join(p, f"{name}.Build.cs")
|
|
if os.path.isfile(build_cs):
|
|
out.append({"name": name, "build_cs": build_cs,
|
|
"has_public": os.path.isdir(os.path.join(p, "Public")),
|
|
"has_private": os.path.isdir(os.path.join(p, "Private"))})
|
|
return {"ok": True, "modules": out}
|
|
|
|
|
|
def _api_macro(module):
|
|
return f"{module.upper()}_API"
|
|
|
|
|
|
_HEADER_TPL = """// Generated by UEBlueprintMCP cpp_scaffold.
|
|
#pragma once
|
|
|
|
#include "CoreMinimal.h"
|
|
#include "{base_header}"
|
|
#include "{name}.generated.h"
|
|
|
|
UCLASS()
|
|
class {api} {name} : public {parent}
|
|
{{
|
|
\tGENERATED_BODY()
|
|
|
|
public:
|
|
\t{name}();
|
|
}};
|
|
"""
|
|
|
|
_CPP_TPL = """// Generated by UEBlueprintMCP cpp_scaffold.
|
|
#include "{include_path}{name}.h"
|
|
|
|
{name}::{name}()
|
|
{{
|
|
\tPrimaryActorTick.bCanEverTick = false;
|
|
}}
|
|
"""
|
|
|
|
|
|
# Map of common parents -> include they need
|
|
_PARENT_HEADERS = {
|
|
"AActor": "GameFramework/Actor.h",
|
|
"APawn": "GameFramework/Pawn.h",
|
|
"ACharacter": "GameFramework/Character.h",
|
|
"APlayerController": "GameFramework/PlayerController.h",
|
|
"AGameModeBase": "GameFramework/GameModeBase.h",
|
|
"AAIController": "AIController.h",
|
|
"UObject": "UObject/NoExportTypes.h",
|
|
"UActorComponent": "Components/ActorComponent.h",
|
|
"USceneComponent": "Components/SceneComponent.h",
|
|
"UPrimitiveComponent": "Components/PrimitiveComponent.h",
|
|
"UStaticMeshComponent": "Components/StaticMeshComponent.h",
|
|
"USkeletalMeshComponent":"Components/SkeletalMeshComponent.h",
|
|
"UAnimInstance": "Animation/AnimInstance.h",
|
|
"UUserWidget": "Blueprint/UserWidget.h",
|
|
"UDataAsset": "Engine/DataAsset.h",
|
|
"UInterface": "UObject/Interface.h",
|
|
}
|
|
|
|
|
|
def create_class(args):
|
|
name = args["name"]
|
|
parent = args["parent_class"] # e.g. AActor / UObject / UActorComponent
|
|
module = args.get("module") or _default_module()
|
|
subfolder = (args.get("subfolder") or "").replace("\\", "/").strip("/")
|
|
header_only = bool(args.get("header_only"))
|
|
public = bool(args.get("public", True))
|
|
|
|
if not re.match(r"^[AU][A-Z]\w+$", name):
|
|
return {"ok": False, "error": "class name must start with A or U followed by PascalCase (e.g. AMyActor, UMyComp)"}
|
|
|
|
src_mod = os.path.join(_source_dir(), module)
|
|
if not os.path.isdir(src_mod):
|
|
return {"ok": False, "error": f"module not found: {module}"}
|
|
|
|
pub_dir = os.path.join(src_mod, "Public", subfolder) if public else os.path.join(src_mod, subfolder)
|
|
priv_dir = os.path.join(src_mod, "Private", subfolder) if public else os.path.join(src_mod, subfolder)
|
|
os.makedirs(pub_dir, exist_ok=True)
|
|
if not header_only:
|
|
os.makedirs(priv_dir, exist_ok=True)
|
|
|
|
base_header = args.get("base_header") or _PARENT_HEADERS.get(parent, "CoreMinimal.h")
|
|
api = args.get("api_macro") or _api_macro(module)
|
|
|
|
h_path = os.path.join(pub_dir, f"{name}.h")
|
|
c_path = os.path.join(priv_dir, f"{name}.cpp")
|
|
|
|
if os.path.exists(h_path):
|
|
return {"ok": False, "error": f"file exists: {h_path}"}
|
|
|
|
h_txt = _HEADER_TPL.format(name=name, parent=parent, api=api, base_header=base_header)
|
|
with open(h_path, "w", encoding="utf-8", newline="\n") as f:
|
|
f.write(h_txt)
|
|
out = {"ok": True, "header": h_path}
|
|
|
|
if not header_only:
|
|
include_rel = (subfolder + "/") if subfolder else ""
|
|
c_txt = _CPP_TPL.format(name=name, include_path=include_rel)
|
|
with open(c_path, "w", encoding="utf-8", newline="\n") as f:
|
|
f.write(c_txt)
|
|
out["cpp"] = c_path
|
|
|
|
out["note"] = "Files written. Run live_coding.compile or rebuild from VS to register the class."
|
|
return out
|
|
|
|
|
|
def list_classes(args):
|
|
module = args.get("module") or _default_module()
|
|
subfolder = (args.get("subfolder") or "").replace("\\", "/").strip("/")
|
|
mod_root = os.path.join(_source_dir(), module)
|
|
if not os.path.isdir(mod_root):
|
|
return {"ok": False, "error": f"module not found: {mod_root}"}
|
|
scan_root = os.path.join(mod_root, subfolder) if subfolder else mod_root
|
|
out = []
|
|
for dirpath, _, fnames in os.walk(scan_root):
|
|
for fn in fnames:
|
|
if fn.endswith(".h"):
|
|
full = os.path.join(dirpath, fn)
|
|
rel = os.path.relpath(full, mod_root).replace("\\", "/")
|
|
# Skip the module's own header (e.g. <Module>.h)
|
|
if rel == f"{module}.h":
|
|
continue
|
|
out.append(rel)
|
|
return {"ok": True, "module": module, "headers": sorted(out), "count": len(out)}
|
|
|
|
|
|
def read_class(args):
|
|
module = args.get("module") or _default_module()
|
|
name = args["name"]
|
|
found = {"header": None, "cpp": None}
|
|
for dirpath, _, fnames in os.walk(os.path.join(_source_dir(), module)):
|
|
for fn in fnames:
|
|
if fn == f"{name}.h":
|
|
found["header"] = os.path.join(dirpath, fn)
|
|
elif fn == f"{name}.cpp":
|
|
found["cpp"] = os.path.join(dirpath, fn)
|
|
out = {"ok": True}
|
|
for k, p in found.items():
|
|
if p and os.path.isfile(p):
|
|
try:
|
|
with open(p, "r", encoding="utf-8") as f:
|
|
out[k] = {"path": p, "content": f.read()}
|
|
except Exception as e:
|
|
out[k] = {"path": p, "error": str(e)}
|
|
if not (found["header"] or found["cpp"]):
|
|
return {"ok": False, "error": f"class {name} not found under Source/{module}"}
|
|
return out
|
|
|
|
|
|
OPS = {
|
|
"list_modules": list_modules,
|
|
"create_class": create_class,
|
|
"list_classes": list_classes,
|
|
"read_class": read_class,
|
|
}
|
|
|
|
|
|
def entrypoint(payload_json):
|
|
try:
|
|
p = json.loads(payload_json)
|
|
except Exception as e:
|
|
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
|
op = p.get("op"); fn = OPS.get(op)
|
|
if not fn:
|
|
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
|
|
try:
|
|
return _tee(fn(p), p)
|
|
except Exception as e:
|
|
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
|
|
|
|
|
def _tee(result, p):
|
|
txt = json.dumps(result)
|
|
try:
|
|
if unreal is not None:
|
|
sd = unreal.Paths.project_saved_dir() + "MCP/"
|
|
os.makedirs(sd, exist_ok=True)
|
|
rid = p.get("__rid") or "last"
|
|
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
|
|
if rid != "last":
|
|
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
|
|
unreal.log("[MCP-CPP] " + txt[:1500])
|
|
except Exception:
|
|
pass
|
|
return txt
|