@gripforgeai/mcp 0.1.10 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +275 -4
- package/dist/ability-tools.js +21 -0
- package/dist/architecture-tools.js +73 -0
- package/dist/asset-production-tools.js +101 -0
- package/dist/creature-rig-tools.js +41 -0
- package/dist/game-concept-tools.js +56 -0
- package/dist/game-creation-tools.js +45 -0
- package/dist/gamekit-deliver-local.js +5 -7
- package/dist/gamekit-tools.js +127 -24
- package/dist/hero-ability-tools.js +48 -0
- package/dist/joystick-kit/godot.js +146 -0
- package/dist/joystick-kit/index.js +32 -0
- package/dist/joystick-kit/threejs.js +93 -0
- package/dist/joystick-kit/unity.js +182 -0
- package/dist/joystick-kit/unreal.js +231 -0
- package/dist/joystick-tools.js +26 -0
- package/dist/map-unreal-export-local.js +285 -0
- package/dist/map-unreal-import-local.js +421 -0
- package/dist/map-unreal-local.js +75 -0
- package/dist/motion-tools.js +50 -0
- package/dist/projectile-tools.js +28 -0
- package/dist/scene-tools.js +77 -13
- package/dist/server-tools.js +3 -3
- package/dist/server.js +164 -22
- package/dist/shield-vfx-tools.js +29 -0
- package/dist/studio-tools-tools.js +106 -0
- package/dist/unreal-export-package.js +212 -0
- package/dist/weapon-motion-tools.js +34 -0
- package/package.json +6 -1
- package/runtime/unreal-scene-export.py +341 -0
- package/runtime/unreal-scene-import.py +622 -0
- package/runtime/unreal-scene-upload.mjs +7 -0
- package/runtime/unreal_scene_math.py +96 -0
- package/runtime/unreal_spawn_review.py +135 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
export function unrealJoystickFiles(c) {
|
|
2
|
+
const p = 'Plugins/GripForgeJoystick/';
|
|
3
|
+
const src = p + 'Source/GripForgeJoystick/';
|
|
4
|
+
return [{ path: p + 'GripForgeJoystick.uplugin', content: JSON.stringify({ FileVersion: 3, Version: 1, VersionName: '1.0.0', FriendlyName: 'GripForge Joystick', Description: 'Independent touch/mouse movement and look sticks for an existing Pawn.', Category: 'Input', CreatedBy: 'GripForge', CanContainContent: false, Modules: [{ Name: 'GripForgeJoystick', Type: 'Runtime', LoadingPhase: 'Default' }] }, null, 2) },
|
|
5
|
+
{ path: src + 'GripForgeJoystick.Build.cs', content: `using UnrealBuildTool;
|
|
6
|
+
public class GripForgeJoystick : ModuleRules {
|
|
7
|
+
public GripForgeJoystick(ReadOnlyTargetRules Target) : base(Target) {
|
|
8
|
+
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
|
|
9
|
+
PublicDependencyModuleNames.AddRange(new string[]{"Core","CoreUObject","Engine","UMG"});
|
|
10
|
+
PrivateDependencyModuleNames.AddRange(new string[]{"Slate","SlateCore","InputCore","ApplicationCore"});
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
` }, { path: src + 'Private/GripForgeJoystickModule.cpp', content: `#include "Modules/ModuleManager.h"
|
|
14
|
+
IMPLEMENT_MODULE(FDefaultModuleImpl, GripForgeJoystick)
|
|
15
|
+
` }, { path: src + 'Public/GripForgeJoystickWidget.h', content: `#pragma once
|
|
16
|
+
#include "CoreMinimal.h"
|
|
17
|
+
#include "Blueprint/UserWidget.h"
|
|
18
|
+
#include "GripForgeJoystickWidget.generated.h"
|
|
19
|
+
class SGripForgeStick;
|
|
20
|
+
|
|
21
|
+
UCLASS()
|
|
22
|
+
class GRIPFORGEJOYSTICK_API UGripForgeJoystickWidget : public UUserWidget {
|
|
23
|
+
GENERATED_BODY()
|
|
24
|
+
public:
|
|
25
|
+
bool bDual = ${c.layout === 'dual' ? 'true' : 'false'};
|
|
26
|
+
float Radius = ${c.radius.toFixed(4)}f;
|
|
27
|
+
float Deadzone = ${c.deadzone.toFixed(4)}f;
|
|
28
|
+
FLinearColor Accent = FLinearColor(FColor::FromHex(TEXT("${c.accent.slice(1)}")));
|
|
29
|
+
FVector2D GetMove() const;
|
|
30
|
+
FVector2D GetLook() const;
|
|
31
|
+
void ResetSticks();
|
|
32
|
+
virtual void ReleaseSlateResources(bool bReleaseChildren) override;
|
|
33
|
+
protected:
|
|
34
|
+
virtual TSharedRef<SWidget> RebuildWidget() override;
|
|
35
|
+
private:
|
|
36
|
+
TSharedPtr<SGripForgeStick> MoveStick, LookStick;
|
|
37
|
+
};
|
|
38
|
+
` }, { path: src + 'Private/GripForgeJoystickWidget.cpp', content: `#include "GripForgeJoystickWidget.h"
|
|
39
|
+
#include "Widgets/SLeafWidget.h"
|
|
40
|
+
#include "Widgets/SOverlay.h"
|
|
41
|
+
#include "Widgets/Layout/SSafeZone.h"
|
|
42
|
+
#include "Widgets/Layout/SBox.h"
|
|
43
|
+
#include "Brushes/SlateRoundedBoxBrush.h"
|
|
44
|
+
#include "Rendering/DrawElements.h"
|
|
45
|
+
#include "Input/Reply.h"
|
|
46
|
+
#include "InputCoreTypes.h"
|
|
47
|
+
|
|
48
|
+
class SGripForgeStick : public SLeafWidget {
|
|
49
|
+
public:
|
|
50
|
+
SLATE_BEGIN_ARGS(SGripForgeStick) : _Radius(72), _Deadzone(.15f), _Accent(FLinearColor::White) {}
|
|
51
|
+
SLATE_ARGUMENT(float, Radius)
|
|
52
|
+
SLATE_ARGUMENT(float, Deadzone)
|
|
53
|
+
SLATE_ARGUMENT(FLinearColor, Accent)
|
|
54
|
+
SLATE_END_ARGS()
|
|
55
|
+
void Construct(const FArguments& Args) {
|
|
56
|
+
Radius=Args._Radius; Deadzone=Args._Deadzone; Accent=Args._Accent;
|
|
57
|
+
SetCanTick(false);
|
|
58
|
+
}
|
|
59
|
+
FVector2D Axis = FVector2D::ZeroVector;
|
|
60
|
+
void Reset() { Owner=INDEX_NONE; OwnerUser=INDEX_NONE; Axis=Visual=FVector2D::ZeroVector; Invalidate(EInvalidateWidgetReason::Paint); }
|
|
61
|
+
static FVector2D Radial(FVector2D Value, float Zone) {
|
|
62
|
+
const double M=Value.Size();
|
|
63
|
+
return M<=Zone || !FMath::IsFinite(M) ? FVector2D::ZeroVector : Value/M*FMath::Min(1.0,(M-Zone)/(1.0-Zone));
|
|
64
|
+
}
|
|
65
|
+
virtual FVector2D ComputeDesiredSize(float) const override { return FVector2D(Radius*2); }
|
|
66
|
+
virtual int32 OnPaint(const FPaintArgs&, const FGeometry& G, const FSlateRect&, FSlateWindowElementList& Out, int32 Layer, const FWidgetStyle&, bool) const override {
|
|
67
|
+
const float R=FMath::Min(G.GetLocalSize().X,G.GetLocalSize().Y)*.5f;
|
|
68
|
+
const FVector2D C=G.GetLocalSize()*.5;
|
|
69
|
+
FSlateDrawElement::MakeBox(Out,Layer,G.ToPaintGeometry(FVector2D(R*2),FSlateLayoutTransform(C-FVector2D(R))),&Circle,ESlateDrawEffect::None,FLinearColor(.06f,.08f,.11f,.72f));
|
|
70
|
+
const float TR=R*.28f;
|
|
71
|
+
FSlateDrawElement::MakeBox(Out,Layer+1,G.ToPaintGeometry(FVector2D(TR*2),FSlateLayoutTransform(C+Visual*R*.65f-FVector2D(TR))),&Circle,ESlateDrawEffect::None,Accent);
|
|
72
|
+
return Layer+1;
|
|
73
|
+
}
|
|
74
|
+
virtual FReply OnMouseButtonDown(const FGeometry& G,const FPointerEvent& E) override { return E.GetEffectingButton()==EKeys::LeftMouseButton ? Begin(G,E) : FReply::Unhandled(); }
|
|
75
|
+
virtual FReply OnMouseMove(const FGeometry& G,const FPointerEvent& E) override { return Move(G,E); }
|
|
76
|
+
virtual FReply OnMouseButtonUp(const FGeometry&,const FPointerEvent& E) override { return E.GetEffectingButton()==EKeys::LeftMouseButton ? End(E) : FReply::Unhandled(); }
|
|
77
|
+
virtual FReply OnTouchStarted(const FGeometry& G,const FPointerEvent& E) override { return Begin(G,E); }
|
|
78
|
+
virtual FReply OnTouchMoved(const FGeometry& G,const FPointerEvent& E) override { return Move(G,E); }
|
|
79
|
+
virtual FReply OnTouchEnded(const FGeometry&,const FPointerEvent& E) override { return End(E); }
|
|
80
|
+
virtual void OnMouseCaptureLost(const FCaptureLostEvent&) override { Reset(); }
|
|
81
|
+
private:
|
|
82
|
+
int32 Owner=INDEX_NONE, OwnerUser=INDEX_NONE;
|
|
83
|
+
float Radius=72, Deadzone=.15f;
|
|
84
|
+
FVector2D Visual=FVector2D::ZeroVector;
|
|
85
|
+
FLinearColor Accent;
|
|
86
|
+
const FSlateRoundedBoxBrush Circle{FLinearColor::White,1000.f};
|
|
87
|
+
bool Owns(const FPointerEvent& E) const { return Owner==(int32)E.GetPointerIndex() && OwnerUser==(int32)E.GetUserIndex(); }
|
|
88
|
+
void Update(const FGeometry& G,const FPointerEvent& E) {
|
|
89
|
+
const double Travel=FMath::Min(G.GetLocalSize().X,G.GetLocalSize().Y)*.325;
|
|
90
|
+
if(Travel<=0){Reset();return;}
|
|
91
|
+
const FVector2D Raw=(G.AbsoluteToLocal(E.GetScreenSpacePosition())-G.GetLocalSize()*.5)/Travel;
|
|
92
|
+
Visual=Raw/FMath::Max(1.0,Raw.Size());
|
|
93
|
+
Axis=Radial(FVector2D(Raw.X,-Raw.Y),Deadzone);
|
|
94
|
+
Invalidate(EInvalidateWidgetReason::Paint);
|
|
95
|
+
}
|
|
96
|
+
FReply Begin(const FGeometry& G,const FPointerEvent& E) {
|
|
97
|
+
if(Owner!=INDEX_NONE)return FReply::Handled();
|
|
98
|
+
Owner=E.GetPointerIndex(); OwnerUser=E.GetUserIndex(); Update(G,E);
|
|
99
|
+
return FReply::Handled().CaptureMouse(SharedThis(this));
|
|
100
|
+
}
|
|
101
|
+
FReply Move(const FGeometry& G,const FPointerEvent& E) { if(!Owns(E))return FReply::Unhandled(); Update(G,E); return FReply::Handled(); }
|
|
102
|
+
FReply End(const FPointerEvent& E) { if(!Owns(E))return FReply::Unhandled(); Reset(); return FReply::Handled().ReleaseMouseCapture(); }
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
TSharedRef<SWidget> UGripForgeJoystickWidget::RebuildWidget() {
|
|
106
|
+
auto Overlay=SNew(SOverlay).Visibility(EVisibility::SelfHitTestInvisible);
|
|
107
|
+
Overlay->AddSlot().HAlign(HAlign_Left).VAlign(VAlign_Bottom).Padding(24)[
|
|
108
|
+
SAssignNew(MoveStick,SGripForgeStick).Radius(Radius).Deadzone(Deadzone).Accent(Accent)];
|
|
109
|
+
if(bDual) Overlay->AddSlot().HAlign(HAlign_Right).VAlign(VAlign_Bottom).Padding(24)[
|
|
110
|
+
SAssignNew(LookStick,SGripForgeStick).Radius(Radius).Deadzone(Deadzone).Accent(Accent)];
|
|
111
|
+
return SNew(SSafeZone).Visibility(EVisibility::SelfHitTestInvisible)[Overlay];
|
|
112
|
+
}
|
|
113
|
+
FVector2D UGripForgeJoystickWidget::GetMove() const { return MoveStick.IsValid()?MoveStick->Axis:FVector2D::ZeroVector; }
|
|
114
|
+
FVector2D UGripForgeJoystickWidget::GetLook() const { return LookStick.IsValid()?LookStick->Axis:FVector2D::ZeroVector; }
|
|
115
|
+
void UGripForgeJoystickWidget::ResetSticks(){if(MoveStick)MoveStick->Reset();if(LookStick)LookStick->Reset();}
|
|
116
|
+
void UGripForgeJoystickWidget::ReleaseSlateResources(bool bReleaseChildren){ResetSticks();Super::ReleaseSlateResources(bReleaseChildren);MoveStick.Reset();LookStick.Reset();}
|
|
117
|
+
` }, { path: src + 'Public/GripForgeJoystickComponent.h', content: `#pragma once
|
|
118
|
+
#include "CoreMinimal.h"
|
|
119
|
+
#include "Components/ActorComponent.h"
|
|
120
|
+
#include "GripForgeJoystickComponent.generated.h"
|
|
121
|
+
class UGripForgeJoystickWidget;
|
|
122
|
+
class APlayerController;
|
|
123
|
+
|
|
124
|
+
UCLASS(ClassGroup=(Input), meta=(BlueprintSpawnableComponent))
|
|
125
|
+
class GRIPFORGEJOYSTICK_API UGripForgeJoystickComponent : public UActorComponent {
|
|
126
|
+
GENERATED_BODY()
|
|
127
|
+
public:
|
|
128
|
+
UGripForgeJoystickComponent();
|
|
129
|
+
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Joystick") bool bDual = ${c.layout === 'dual' ? 'true' : 'false'};
|
|
130
|
+
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Joystick",meta=(ClampMin="32",ClampMax="160")) float Radius = ${c.radius.toFixed(4)}f;
|
|
131
|
+
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Joystick",meta=(ClampMin="0",ClampMax="0.5")) float Deadzone = ${c.deadzone.toFixed(4)}f;
|
|
132
|
+
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Joystick") FLinearColor Accent = FLinearColor(FColor::FromHex(TEXT("${c.accent.slice(1)}")));
|
|
133
|
+
// Turn these off if your existing motor reads GetMove/GetLook itself.
|
|
134
|
+
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Joystick") bool bDriveMovement = true;
|
|
135
|
+
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Joystick") bool bDriveLook = true;
|
|
136
|
+
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Joystick") float LookDegreesPerSecond = 120.f;
|
|
137
|
+
UFUNCTION(BlueprintPure, Category="Joystick") FVector2D GetMove() const;
|
|
138
|
+
UFUNCTION(BlueprintPure, Category="Joystick") FVector2D GetLook() const;
|
|
139
|
+
UFUNCTION(BlueprintCallable, Category="Joystick") void ResetInput();
|
|
140
|
+
virtual void Deactivate() override;
|
|
141
|
+
virtual void TickComponent(float Delta, ELevelTick TickType, FActorComponentTickFunction* ThisTick) override;
|
|
142
|
+
protected:
|
|
143
|
+
virtual void BeginPlay() override;
|
|
144
|
+
virtual void EndPlay(const EEndPlayReason::Type Reason) override;
|
|
145
|
+
private:
|
|
146
|
+
UPROPERTY(Transient) TObjectPtr<UGripForgeJoystickWidget> Widget;
|
|
147
|
+
TWeakObjectPtr<APlayerController> Controller;
|
|
148
|
+
FDelegateHandle FocusHandle, BackgroundHandle;
|
|
149
|
+
void RemoveWidget();
|
|
150
|
+
};
|
|
151
|
+
` }, { path: src + 'Private/GripForgeJoystickComponent.cpp', content: `#include "GripForgeJoystickComponent.h"
|
|
152
|
+
#include "GripForgeJoystickWidget.h"
|
|
153
|
+
#include "GameFramework/Pawn.h"
|
|
154
|
+
#include "GameFramework/PlayerController.h"
|
|
155
|
+
#include "GameFramework/PawnMovementComponent.h"
|
|
156
|
+
#include "Misc/CoreDelegates.h"
|
|
157
|
+
#include "Math/RotationMatrix.h"
|
|
158
|
+
|
|
159
|
+
UGripForgeJoystickComponent::UGripForgeJoystickComponent(){bAutoActivate=true;PrimaryComponentTick.bCanEverTick=true;PrimaryComponentTick.TickGroup=TG_PrePhysics;}
|
|
160
|
+
void UGripForgeJoystickComponent::BeginPlay(){
|
|
161
|
+
Super::BeginPlay();
|
|
162
|
+
FocusHandle=FCoreDelegates::ApplicationWillDeactivateDelegate.AddUObject(this,&UGripForgeJoystickComponent::ResetInput);
|
|
163
|
+
BackgroundHandle=FCoreDelegates::ApplicationWillEnterBackgroundDelegate.AddUObject(this,&UGripForgeJoystickComponent::ResetInput);
|
|
164
|
+
if(auto* Pawn=Cast<APawn>(GetOwner()))if(auto* Motor=Pawn->GetMovementComponent())Motor->AddTickPrerequisiteComponent(this);
|
|
165
|
+
}
|
|
166
|
+
void UGripForgeJoystickComponent::TickComponent(float Delta,ELevelTick TickType,FActorComponentTickFunction* ThisTick){
|
|
167
|
+
Super::TickComponent(Delta,TickType,ThisTick);
|
|
168
|
+
auto* Pawn=Cast<APawn>(GetOwner());
|
|
169
|
+
auto* PC=Pawn?Cast<APlayerController>(Pawn->GetController()):nullptr;
|
|
170
|
+
if(!PC || !Pawn->IsLocallyControlled() || !PC->IsLocalController()){RemoveWidget();return;}
|
|
171
|
+
// Possession may occur AFTER BeginPlay. Recreate on local controller changes.
|
|
172
|
+
if(Controller.Get()!=PC){RemoveWidget();Controller=PC;}
|
|
173
|
+
if(!Widget){
|
|
174
|
+
Widget=CreateWidget<UGripForgeJoystickWidget>(PC);
|
|
175
|
+
if(!Widget)return;
|
|
176
|
+
Widget->bDual=bDual;Widget->Radius=FMath::Clamp(Radius,32.f,160.f);Widget->Deadzone=FMath::Clamp(Deadzone,0.f,.5f);Widget->Accent=Accent;
|
|
177
|
+
Widget->SetVisibility(ESlateVisibility::SelfHitTestInvisible);
|
|
178
|
+
Widget->AddToPlayerScreen(20);
|
|
179
|
+
}
|
|
180
|
+
if(PC->IsPaused()){ResetInput();return;}
|
|
181
|
+
const FVector2D Move=GetMove(),Look=GetLook();
|
|
182
|
+
if(bDriveMovement && !PC->IsMoveInputIgnored()){
|
|
183
|
+
const FRotationMatrix Basis(FRotator(0,PC->GetControlRotation().Yaw,0));
|
|
184
|
+
Pawn->AddMovementInput(Basis.GetUnitAxis(EAxis::X),Move.Y);
|
|
185
|
+
Pawn->AddMovementInput(Basis.GetUnitAxis(EAxis::Y),Move.X);
|
|
186
|
+
}
|
|
187
|
+
if(bDriveLook && !PC->IsLookInputIgnored()){
|
|
188
|
+
// InputYaw/PitchScale belong to the game's controller, just as for other axes.
|
|
189
|
+
Pawn->AddControllerYawInput(Look.X*LookDegreesPerSecond*Delta);
|
|
190
|
+
Pawn->AddControllerPitchInput(-Look.Y*LookDegreesPerSecond*Delta);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
FVector2D UGripForgeJoystickComponent::GetMove() const {return IsActive()&&Widget?Widget->GetMove():FVector2D::ZeroVector;}
|
|
194
|
+
FVector2D UGripForgeJoystickComponent::GetLook() const {return IsActive()&&Widget?Widget->GetLook():FVector2D::ZeroVector;}
|
|
195
|
+
void UGripForgeJoystickComponent::ResetInput(){if(Widget)Widget->ResetSticks();}
|
|
196
|
+
void UGripForgeJoystickComponent::Deactivate(){RemoveWidget();Super::Deactivate();}
|
|
197
|
+
void UGripForgeJoystickComponent::RemoveWidget(){if(Widget){Widget->ResetSticks();Widget->RemoveFromParent();Widget=nullptr;}Controller.Reset();}
|
|
198
|
+
void UGripForgeJoystickComponent::EndPlay(const EEndPlayReason::Type Reason){
|
|
199
|
+
RemoveWidget();FCoreDelegates::ApplicationWillDeactivateDelegate.Remove(FocusHandle);FCoreDelegates::ApplicationWillEnterBackgroundDelegate.Remove(BackgroundHandle);
|
|
200
|
+
if(auto* Pawn=Cast<APawn>(GetOwner()))if(auto* Motor=Pawn->GetMovementComponent())Motor->RemoveTickPrerequisiteComponent(this);
|
|
201
|
+
Super::EndPlay(Reason);
|
|
202
|
+
}
|
|
203
|
+
` }, { path: 'README.md', content: `# GripForge joystick · Unreal Engine 5
|
|
204
|
+
|
|
205
|
+
1. Copy Plugins/GripForgeJoystick into the project (not Engine). Compile the
|
|
206
|
+
runtime plugin with your UE version/toolchain, then reopen the editor. A
|
|
207
|
+
Blueprint-only project can add a C++ class first to enable project builds.
|
|
208
|
+
2. Enable GripForge Joystick in Plugins if needed. On the actual possessed Pawn
|
|
209
|
+
Blueprint, Add Component → GripForge Joystick. It waits for local possession.
|
|
210
|
+
3. Press Play. Left stick feeds your Pawn's existing AddMovementInput; right
|
|
211
|
+
stick feeds controller yaw/pitch. CharacterMovement still owns collision,
|
|
212
|
+
gravity, acceleration and network prediction. No GameMode replacement.
|
|
213
|
+
|
|
214
|
+
Mouse and touch work directly through Slate; Use Mouse for Touch is not required.
|
|
215
|
+
For a desktop mouse test, use Game and UI input mode and show the cursor in your
|
|
216
|
+
PlayerController. Keep the game viewport focused. The plugin deliberately does
|
|
217
|
+
not change your game's input mode/cursor. Native touch works in Game Only mode.
|
|
218
|
+
Remove/disable the previous virtual joystick to avoid overlapping controls.
|
|
219
|
+
|
|
220
|
+
For a custom motor disable Drive Movement/Drive Look and read Get Move/Get Look
|
|
221
|
+
from the component every tick. Axes: X right, Y forward/up; magnitude <= 1. Apply
|
|
222
|
+
look deltaTime once. Existing gamepad/keyboard actions are not overwritten.
|
|
223
|
+
Deadzone, radius, color and dual-stick layout are set before the UI is created.
|
|
224
|
+
The overlay respects Slate DPI/safe zones. Release, capture loss, application
|
|
225
|
+
focus loss, losing possession and EndPlay reset the axes. Use Reset Input when
|
|
226
|
+
opening your own modal menus. One component per locally controlled pawn.
|
|
227
|
+
|
|
228
|
+
This is a source plugin, not a precompiled binary for every UE release. Compile
|
|
229
|
+
for the target UE/platform and test in your real project before packaging.
|
|
230
|
+
` }];
|
|
231
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Identical deterministic delivery in the hosted MCP and npm client. */
|
|
2
|
+
import { z } from 'zod/v4';
|
|
3
|
+
import { createJoystickKit } from './joystick-kit/index.js';
|
|
4
|
+
export const JOYSTICK_TOOL_NAMES = ['gripforge_joystick_kit'];
|
|
5
|
+
export function registerJoystickTools(register, schema = z) {
|
|
6
|
+
const shape = {
|
|
7
|
+
target: schema.enum(['threejs', 'godot', 'unity', 'unreal', 'all']).describe('Engine delivery. all returns each of the four source kits.'),
|
|
8
|
+
layout: schema.enum(['move', 'dual']).default('dual').describe('One movement stick, or independent movement + camera sticks.'),
|
|
9
|
+
deadzone: schema.number().min(0).max(.5).default(.15).describe('Radial deadzone, continuously rescaled to a unit disc.'),
|
|
10
|
+
radius: schema.number().min(32).max(160).default(72).describe('Stick radius in CSS/UI logical pixels, before engine DPI scaling.'),
|
|
11
|
+
accent: schema.string().regex(/^#[\da-f]{6}$/i).default('#ff681f').describe('Thumb color, six-digit hexadecimal RGB.'),
|
|
12
|
+
};
|
|
13
|
+
register('gripforge_joystick_kit', {
|
|
14
|
+
title: 'Ready-to-use joystick · Unreal / Godot / Unity / Three.js',
|
|
15
|
+
description: 'Generate input.joystick source files and installation instructions: touch + mouse, independent dual sticks, radial deadzone, continuous holding and release/focus reset. Unreal runtime Pawn component, Godot CanvasLayer scene, Unity UI component, Three.js DOM controls + GripForge input.actions bridge. Feeds the existing movement controller rather than replacing physics, collision or networking. Returns files with relative path/content; the agent writes them into the target project and compiles/tests there. Does not install automatically, modify project settings or claim runtime validation. 0 credits.',
|
|
16
|
+
inputSchema: shape,
|
|
17
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
18
|
+
}, async (args) => {
|
|
19
|
+
const parsed = schema.object(shape).safeParse(args);
|
|
20
|
+
if (!parsed.success)
|
|
21
|
+
return { isError: true, content: [{ type: 'text', text: parsed.error.message }] };
|
|
22
|
+
const { target, ...config } = parsed.data;
|
|
23
|
+
const result = createJoystickKit(target, config);
|
|
24
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { z } from 'zod/v4';
|
|
2
|
+
import { basename, dirname, isAbsolute, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { readFile, writeFile, mkdir, copyFile, realpath, open, unlink } from 'node:fs/promises';
|
|
5
|
+
import { spawn, execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { unrealLaunchCommand } from './map-unreal-import-local.js';
|
|
8
|
+
import { atomicJson, readJson, sha256, buildUnrealStudioPackage, exportFile } from './unreal-export-package.js';
|
|
9
|
+
const absolute = z.string().min(1).max(4096).refine(isAbsolute, 'Absolute local path required.').refine(s => !/[\r\n\0"]/.test(s), 'Invalid path.');
|
|
10
|
+
const level = z.string().regex(/^\/Game(?:\/[\w-]+)+$/);
|
|
11
|
+
const qualities = { texture_size: z.union([z.literal(512), z.literal(1024), z.literal(2048)]).default(1024), terrain_texture_size: z.union([z.literal(1024), z.literal(2048), z.literal(4096)]).default(4096), terrain_resolution: z.union([z.literal(129), z.literal(257), z.literal(513), z.literal(1025)]).default(513) };
|
|
12
|
+
const requestSchema = z.object({ schema: z.literal('gripforge.unreal-export-request.v1'), project_file: absolute, source_level: level, source_sha256: z.string().regex(/^[a-f0-9]{64}$/), ...qualities }).strict();
|
|
13
|
+
function alive(pid) { if (typeof pid !== 'number' || pid <= 0)
|
|
14
|
+
return false; try {
|
|
15
|
+
process.kill(pid, 0);
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
if (e.code === 'EPERM')
|
|
20
|
+
return true;
|
|
21
|
+
if (e.code !== 'ESRCH')
|
|
22
|
+
throw e;
|
|
23
|
+
return false;
|
|
24
|
+
} }
|
|
25
|
+
async function optionalJson(file) { try {
|
|
26
|
+
return await readJson(file);
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
if (e.code !== 'ENOENT')
|
|
30
|
+
throw e;
|
|
31
|
+
return null;
|
|
32
|
+
} }
|
|
33
|
+
async function withLaunchLock(dir, stage, task) {
|
|
34
|
+
const file = join(dir, stage + '-launch.lock');
|
|
35
|
+
try {
|
|
36
|
+
await writeFile(file, JSON.stringify({ pid: process.pid }), { flag: 'wx' });
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
if (e.code !== 'EEXIST')
|
|
40
|
+
throw e;
|
|
41
|
+
if (alive((await readJson(file)).pid))
|
|
42
|
+
throw Error('Another launch request is in progress.');
|
|
43
|
+
await unlink(file);
|
|
44
|
+
return withLaunchLock(dir, stage, task);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
return await task();
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await unlink(file);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export async function prepareUnrealExport(input) {
|
|
54
|
+
absolute.parse(input.job_directory);
|
|
55
|
+
absolute.parse(input.project_file);
|
|
56
|
+
level.parse(input.source_level);
|
|
57
|
+
const project = await realpath(input.project_file);
|
|
58
|
+
if (!project.endsWith('.uproject'))
|
|
59
|
+
throw Error('Expected an installed .uproject.');
|
|
60
|
+
const source = join(dirname(project), 'Content', input.source_level.slice(6) + '.umap');
|
|
61
|
+
const request = requestSchema.parse({ schema: 'gripforge.unreal-export-request.v1', project_file: project, source_level: input.source_level, source_sha256: sha256(await readFile(source)), texture_size: input.texture_size, terrain_texture_size: input.terrain_texture_size, terrain_resolution: input.terrain_resolution });
|
|
62
|
+
await mkdir(input.job_directory, { recursive: true });
|
|
63
|
+
const dir = await realpath(input.job_directory);
|
|
64
|
+
const previous = await optionalJson(join(dir, 'request.json'));
|
|
65
|
+
if (previous && JSON.stringify(requestSchema.parse(previous)) !== JSON.stringify(request))
|
|
66
|
+
throw Error('Job inputs changed. Use a new job directory.');
|
|
67
|
+
if (!previous)
|
|
68
|
+
await writeFile(join(dir, 'request.json'), JSON.stringify(request), { flag: 'wx' });
|
|
69
|
+
const worker = fileURLToPath(new URL('../runtime/unreal-scene-export.py', import.meta.url)), target = join(dir, 'unreal-scene-export.py');
|
|
70
|
+
try {
|
|
71
|
+
await copyFile(worker, target, 1);
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
if (e.code !== 'EEXIST')
|
|
75
|
+
throw e;
|
|
76
|
+
if (sha256(await readFile(worker)) !== sha256(await readFile(target)))
|
|
77
|
+
throw Error('Worker version changed. Prepare a new job directory.');
|
|
78
|
+
}
|
|
79
|
+
return { job_directory: dir, request, lifecycle: 'work' };
|
|
80
|
+
}
|
|
81
|
+
export async function unrealExportStatus(directory) {
|
|
82
|
+
absolute.parse(directory);
|
|
83
|
+
const dir = await realpath(directory);
|
|
84
|
+
requestSchema.parse(await readJson(join(dir, 'request.json')));
|
|
85
|
+
const result = { job_directory: dir, lifecycle: 'work' };
|
|
86
|
+
for (const stage of ['export', 'upload']) {
|
|
87
|
+
const file = stage === 'export' ? 'status.json' : 'upload-status.json', launch = await optionalJson(join(dir, stage + '-launch.json'));
|
|
88
|
+
let state = await optionalJson(join(dir, file));
|
|
89
|
+
if (launch && (!state || launch.started_at > (state.updated_at ?? 0)))
|
|
90
|
+
state = { ...launch, status: 'running', stage: 'launching' };
|
|
91
|
+
if (!state) {
|
|
92
|
+
result[stage] = { status: 'pending' };
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const running = alive(launch?.pid ?? state.pid);
|
|
96
|
+
if (state.status === 'running' && !running)
|
|
97
|
+
state = { ...state, status: 'interrupted', error: 'Worker exited. Read its log and resume the saved job; completed steps remain available.' };
|
|
98
|
+
result[stage] = { ...state, worker_running: running };
|
|
99
|
+
}
|
|
100
|
+
if (result.upload.studio_url)
|
|
101
|
+
result.studio_url = result.upload.studio_url;
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
export async function startUnrealExport(directory, editor) {
|
|
105
|
+
absolute.parse(directory);
|
|
106
|
+
absolute.parse(editor);
|
|
107
|
+
const dir = await realpath(directory), executable = await realpath(editor);
|
|
108
|
+
if (!/^UnrealEditor(?:-Cmd)?(?:\.exe)?$/.test(basename(executable)))
|
|
109
|
+
throw Error('Expected UnrealEditor or UnrealEditor-Cmd executable.');
|
|
110
|
+
return withLaunchLock(dir, 'export', async () => {
|
|
111
|
+
const state = await unrealExportStatus(dir);
|
|
112
|
+
if (state.export.worker_running || state.export.status === 'succeeded')
|
|
113
|
+
return state;
|
|
114
|
+
const request = requestSchema.parse(await readJson(join(dir, 'request.json')));
|
|
115
|
+
const source = join(dirname(request.project_file), 'Content', request.source_level.slice(6) + '.umap');
|
|
116
|
+
if (sha256(await readFile(source)) !== request.source_sha256)
|
|
117
|
+
throw Error('Source level changed. Prepare a new export.');
|
|
118
|
+
const worker = await exportFile(dir, 'unreal-scene-export.py');
|
|
119
|
+
const current = fileURLToPath(new URL('../runtime/unreal-scene-export.py', import.meta.url));
|
|
120
|
+
if (sha256(await readFile(worker)) !== sha256(await readFile(current)))
|
|
121
|
+
throw Error('Worker changed. Prepare a new job.');
|
|
122
|
+
await atomicJson(join(dir, 'control.json'), { cancel: false });
|
|
123
|
+
const arm = process.platform === 'darwin' && (await promisify(execFile)('/usr/sbin/sysctl', ['-n', 'hw.optional.arm64'])).stdout.trim() === '1';
|
|
124
|
+
const launch = unrealLaunchCommand(executable, [request.project_file, '-run=pythonscript', '-script=' + worker, '-GFExportJob=' + dir, '-AllowCommandletRendering', '-EnablePlugins=PythonScriptPlugin,EditorScriptingUtilities,GLTFExporter', '-unattended', '-nosplash', '-nosound', '-abslog=' + join(dir, 'export.log')], arm);
|
|
125
|
+
const log = await open(join(dir, 'export-launch.log'), 'a');
|
|
126
|
+
try {
|
|
127
|
+
const child = spawn(launch.command, launch.args, { cwd: dirname(executable), detached: true, shell: false, stdio: ['ignore', log.fd, log.fd] });
|
|
128
|
+
await new Promise((ok, bad) => { child.once('spawn', ok); child.once('error', bad); });
|
|
129
|
+
child.unref();
|
|
130
|
+
await atomicJson(join(dir, 'export-launch.json'), { pid: child.pid, started_at: Date.now() / 1000 });
|
|
131
|
+
return { job_directory: dir, status: 'running', stage: 'launching', pid: child.pid, next: 'Poll gripforge_map_unreal_export_status_local. No source level/assets will be saved.' };
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
await log.close();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
function apiOrigin(value) {
|
|
139
|
+
const url = new URL(value);
|
|
140
|
+
if (url.username || url.password || url.search || url.hash || url.pathname !== '/' || url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))
|
|
141
|
+
throw Error('Use an HTTPS API origin, or HTTP loopback for local development.');
|
|
142
|
+
return url.origin;
|
|
143
|
+
}
|
|
144
|
+
async function api(url, key, workspace, path, method = 'GET', body, headers = {}) {
|
|
145
|
+
const response = await fetch(url + path, { method, redirect: 'error', headers: { 'x-api-key': key, 'x-workspace-id': workspace, 'x-gripforge-client': 'mcp', ...headers }, ...(body === undefined ? {} : { body: typeof body === 'string' ? body : new Uint8Array(body) }), signal: AbortSignal.timeout(120000) });
|
|
146
|
+
const data = await response.json();
|
|
147
|
+
if (!response.ok)
|
|
148
|
+
throw Error('GripForge API ' + response.status + ': ' + JSON.stringify(data).slice(0, 1500));
|
|
149
|
+
return data;
|
|
150
|
+
}
|
|
151
|
+
/** Runs in a detached Node worker. The credential is inherited in memory, never persisted in its checkpoints. */
|
|
152
|
+
export async function uploadUnrealExport(directory, origin, workspace, key) {
|
|
153
|
+
const dir = await realpath(directory), url = apiOrigin(origin), scope = sha256(url + ':' + workspace), stateFile = join(dir, 'upload-status.json');
|
|
154
|
+
let state = await optionalJson(stateFile) ?? {};
|
|
155
|
+
if (state.scope && state.scope !== scope)
|
|
156
|
+
throw Error('Upload job belongs to another workspace or API origin. Use a separate copy of the export.');
|
|
157
|
+
state = { ...state, scope, workspace_id: workspace, api_origin: url, status: 'running', pid: process.pid, lifecycle: 'work', updated_at: Date.now() / 1000, uploaded: state.uploaded ?? {} };
|
|
158
|
+
const progress = async (stage) => { state.stage = stage; state.updated_at = Date.now() / 1000; await atomicJson(stateFile, state); };
|
|
159
|
+
const checkCancel = async () => {
|
|
160
|
+
if ((await optionalJson(join(dir, 'upload-control.json')))?.cancel) {
|
|
161
|
+
if (state.job_id)
|
|
162
|
+
await api(url, key, workspace, '/api/v1/generation-jobs/' + encodeURIComponent(state.job_id), 'POST', JSON.stringify({ action: 'cancel' }), { 'content-type': 'application/json' });
|
|
163
|
+
throw Error('Upload cancelled; completed assets retained.');
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
try {
|
|
167
|
+
await progress('assembling');
|
|
168
|
+
await checkCancel();
|
|
169
|
+
const portable = await buildUnrealStudioPackage(dir), hash = portable.manifest.sceneHash;
|
|
170
|
+
if (state.scene_hash && state.scene_hash !== hash)
|
|
171
|
+
throw Error('Portable scene changed during upload. Use a new job.');
|
|
172
|
+
state.scene_hash = hash;
|
|
173
|
+
state.total = portable.manifest.assets.length;
|
|
174
|
+
for (const file of portable.manifest.assets) {
|
|
175
|
+
await checkCancel();
|
|
176
|
+
if (state.uploaded[file.sha256])
|
|
177
|
+
continue;
|
|
178
|
+
state.file = file.name;
|
|
179
|
+
state.completed = Object.keys(state.uploaded).length;
|
|
180
|
+
await progress('uploading_assets');
|
|
181
|
+
const bytes = await readFile(await exportFile(dir, file.file));
|
|
182
|
+
if (sha256(bytes) !== file.sha256)
|
|
183
|
+
throw Error('Export changed: ' + file.file);
|
|
184
|
+
const response = await api(url, key, workspace, '/api/v1/maps/import/unreal/assets', 'POST', bytes, { 'content-type': file.mime, 'x-asset-filename': basename(file.file), 'x-content-sha256': file.sha256 });
|
|
185
|
+
if (response.sha256 !== file.sha256 || !response.asset?.assetId || !response.asset?.revisionId)
|
|
186
|
+
throw Error('Unexpected asset upload response.');
|
|
187
|
+
state.uploaded[file.sha256] = response.asset;
|
|
188
|
+
await progress('uploading_assets');
|
|
189
|
+
}
|
|
190
|
+
await checkCancel();
|
|
191
|
+
const refs = new Map(portable.manifest.assets.map(a => [a.assetId + ':' + a.revisionId, state.uploaded[a.sha256]]));
|
|
192
|
+
const replaceRefs = (v) => {
|
|
193
|
+
if (!v || typeof v !== 'object')
|
|
194
|
+
return v;
|
|
195
|
+
if (typeof v.assetId === 'string' && typeof v.revisionId === 'string') {
|
|
196
|
+
const ref = refs.get(v.assetId + ':' + v.revisionId);
|
|
197
|
+
if (!ref)
|
|
198
|
+
throw Error('Unresolved asset reference.');
|
|
199
|
+
return { ...ref, ...(v.fileRole ? { fileRole: v.fileRole } : {}) };
|
|
200
|
+
}
|
|
201
|
+
return Array.isArray(v) ? v.map(replaceRefs) : Object.fromEntries(Object.entries(v).map(([k, x]) => [k, replaceRefs(x)]));
|
|
202
|
+
};
|
|
203
|
+
const input = replaceRefs(portable.input);
|
|
204
|
+
input.document.workspaceId = workspace;
|
|
205
|
+
input.idempotency_key = 'ue-import-' + hash;
|
|
206
|
+
await progress('queueing_import');
|
|
207
|
+
if (!state.job_id) {
|
|
208
|
+
const response = await api(url, key, workspace, '/api/v1/maps/import/unreal', 'POST', JSON.stringify(input), { 'content-type': 'application/json' });
|
|
209
|
+
if (typeof response.job_id !== 'string')
|
|
210
|
+
throw Error('Missing persistent import job.');
|
|
211
|
+
state.job_id = response.job_id;
|
|
212
|
+
await progress('saving_scene');
|
|
213
|
+
}
|
|
214
|
+
let first = true;
|
|
215
|
+
for (;;) {
|
|
216
|
+
await checkCancel();
|
|
217
|
+
const response = await api(url, key, workspace, '/api/v1/generation-jobs/' + encodeURIComponent(state.job_id)), job = response.job ?? response;
|
|
218
|
+
if (job.status === 'failed' || job.status === 'cancelled') {
|
|
219
|
+
if (!first)
|
|
220
|
+
throw Error('Import job ' + job.status + ': ' + String(job.error ?? 'Read generation_read for details.'));
|
|
221
|
+
await api(url, key, workspace, '/api/v1/generation-jobs/' + encodeURIComponent(state.job_id), 'POST', JSON.stringify({ action: 'retry' }), { 'content-type': 'application/json' });
|
|
222
|
+
}
|
|
223
|
+
else if (job.status === 'succeeded') {
|
|
224
|
+
if (!job.result?.studio_url)
|
|
225
|
+
throw Error('Import completed without a Studio link.');
|
|
226
|
+
Object.assign(state, { status: 'succeeded', result: job.result, studio_url: new URL(job.result.studio_url, url).href, completed: state.total });
|
|
227
|
+
await progress('awaiting_visual_review');
|
|
228
|
+
return state;
|
|
229
|
+
}
|
|
230
|
+
first = false;
|
|
231
|
+
state.remote_status = job.status;
|
|
232
|
+
await progress('saving_scene');
|
|
233
|
+
await new Promise(ok => setTimeout(ok, 3000));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
catch (e) {
|
|
237
|
+
state.status = (await optionalJson(join(dir, 'upload-control.json')))?.cancel ? 'cancelled' : 'failed';
|
|
238
|
+
state.error = e instanceof Error ? e.message : String(e);
|
|
239
|
+
await progress(state.stage);
|
|
240
|
+
throw e;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
export async function startUnrealUpload(directory, origin, workspace, key) {
|
|
244
|
+
if (!key)
|
|
245
|
+
throw Error('Connect a GripForge API key before uploading.');
|
|
246
|
+
absolute.parse(directory);
|
|
247
|
+
z.string().regex(/^[\w-]{1,100}$/).parse(workspace);
|
|
248
|
+
const url = apiOrigin(origin), dir = await realpath(directory);
|
|
249
|
+
return withLaunchLock(dir, 'upload', async () => {
|
|
250
|
+
const status = await unrealExportStatus(dir);
|
|
251
|
+
if (status.export.status !== 'succeeded')
|
|
252
|
+
throw Error('Wait for a successful native export.');
|
|
253
|
+
if (status.upload.scope && status.upload.scope !== sha256(url + ':' + workspace))
|
|
254
|
+
throw Error('Job already belongs to another workspace/API.');
|
|
255
|
+
if (status.upload.worker_running || status.upload.status === 'succeeded')
|
|
256
|
+
return status;
|
|
257
|
+
await atomicJson(join(dir, 'upload-control.json'), { cancel: false });
|
|
258
|
+
const log = await open(join(dir, 'upload.log'), 'a');
|
|
259
|
+
try {
|
|
260
|
+
const child = spawn(process.execPath, [fileURLToPath(new URL('../runtime/unreal-scene-upload.mjs', import.meta.url)), dir, url, workspace], { detached: true, shell: false, stdio: ['ignore', log.fd, log.fd], env: { ...process.env, GRIPFORGE_API_KEY: key } });
|
|
261
|
+
await new Promise((ok, bad) => { child.once('spawn', ok); child.once('error', bad); });
|
|
262
|
+
child.unref();
|
|
263
|
+
await atomicJson(join(dir, 'upload-launch.json'), { pid: child.pid, started_at: Date.now() / 1000 });
|
|
264
|
+
return { job_directory: dir, status: 'running', stage: 'uploading', next: 'Poll gripforge_map_unreal_export_status_local for progress and studio_url.' };
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
await log.close();
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
export function registerMapUnrealExportLocalTools(register, options) {
|
|
272
|
+
const job = { job_directory: absolute.describe('Persistent local export directory; retain it to resume completed stages.') };
|
|
273
|
+
const wrap = (f) => async (a) => { try {
|
|
274
|
+
const data = await f(a);
|
|
275
|
+
return { structuredContent: data, content: [{ type: 'text', text: JSON.stringify(data) }] };
|
|
276
|
+
}
|
|
277
|
+
catch (e) {
|
|
278
|
+
return { isError: true, content: [{ type: 'text', text: e instanceof Error ? e.message : String(e) }] };
|
|
279
|
+
} };
|
|
280
|
+
const annotations = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
281
|
+
register('gripforge_map_unreal_export_local', { title: 'Export an Unreal map to GripForge', description: 'Generic local UE 5.7 exporter. Pins the selected saved map and runs a separate Unreal Python commandlet without saving source assets. Exports PBR GLBs, all supported static placements, compact foliage patches, Landscape height/holes and baked albedo. Durable per-file checkpoints survive closed MCP calls. Native shader graphs, animated water/sky, Niagara and gameplay Blueprints are reported, not silently recreated. Work version requires visual review in GripForge. Local MCP only; Unreal/Python/GLTFExporter must be installed.', inputSchema: { project_file: absolute, source_level: level, editor_executable: absolute, ...job, ...qualities }, annotations }, wrap(async (a) => { const prepared = await prepareUnrealExport(a); return startUnrealExport(prepared.job_directory, String(a.editor_executable)); }));
|
|
282
|
+
register('gripforge_map_unreal_export_status_local', { title: 'Read Unreal export/upload progress', description: 'Read durable stages, failures, completed assets and final GripForge Studio link. Does not equate technical export success with visual validation.', inputSchema: job, annotations: { ...annotations, readOnlyHint: true } }, wrap(a => unrealExportStatus(String(a.job_directory))));
|
|
283
|
+
register('gripforge_map_unreal_export_cancel_local', { title: 'Cancel an Unreal export or upload', description: 'Request cancellation at the next safe asset checkpoint. Keeps completed files and uploaded private assets; resume with export_local or upload_local using the same directory. Never kills an unrelated Unreal editor.', inputSchema: { ...job, stage: z.enum(['export', 'upload']) }, annotations }, wrap(async (a) => { const state = await unrealExportStatus(String(a.job_directory)); await atomicJson(join(state.job_directory, a.stage === 'export' ? 'control.json' : 'upload-control.json'), { cancel: true }); return { cancel_requested: true, stage: a.stage, job_directory: state.job_directory }; }));
|
|
284
|
+
register('gripforge_map_unreal_upload_local', { title: 'Import exported Unreal map into your workspace', description: 'Assemble a completed native export as a common SceneDocument, upload immutable assets to the authenticated workspace and queue a persistent API import. Detached upload resumes per-file checkpoints. Poll export_status_local for studio_url. Private work only, never auto-promotes or publishes. 0 generation credits; workspace storage quota applies.', inputSchema: { ...job, workspace_id: z.string().regex(/^[\w-]{1,100}$/) }, annotations }, wrap(a => startUnrealUpload(String(a.job_directory), options.apiUrl, String(a.workspace_id), options.getApiKey() ?? '')));
|
|
285
|
+
}
|