@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,622 @@
|
|
|
1
|
+
"""GripForge native map adapter, UE 5.7. Recipe-independent; consumes plan.json only.
|
|
2
|
+
|
|
3
|
+
Run in the full editor with -ExecutePythonScript=<this file> -GFMapPlan=<plan.json>.
|
|
4
|
+
The common scene, bindings and completed work stay on disk. Never saves native source packages.
|
|
5
|
+
"""
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
import traceback
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import unreal
|
|
16
|
+
|
|
17
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
18
|
+
from unreal_scene_math import world_matrices, native_transform, point, identity, exposure_brightness
|
|
19
|
+
from unreal_spawn_review import SpawnReview
|
|
20
|
+
|
|
21
|
+
unreal.EditorPythonScripting.set_keep_python_script_alive(True)
|
|
22
|
+
match = re.search(r'-GFMapPlan=(?:"([^"]+)"|(\S+))', unreal.SystemLibrary.get_command_line())
|
|
23
|
+
if not match:
|
|
24
|
+
raise RuntimeError('Missing -GFMapPlan')
|
|
25
|
+
plan_file = Path(match.group(1) or match.group(2)).resolve()
|
|
26
|
+
job_dir = plan_file.parent
|
|
27
|
+
plan_bytes = plan_file.read_bytes()
|
|
28
|
+
plan = json.loads(plan_bytes)
|
|
29
|
+
plan_hash = hashlib.sha256(plan_bytes).hexdigest()
|
|
30
|
+
status_file = job_dir / 'status.json'
|
|
31
|
+
state = json.loads(status_file.read_text()) if status_file.exists() else {}
|
|
32
|
+
if state.get('plan_hash') not in (None, plan_hash):
|
|
33
|
+
raise RuntimeError('Immutable job plan changed. Prepare a new work revision.')
|
|
34
|
+
state.update(plan_hash=plan_hash, lifecycle='work', target_level=plan['target_level'], pid=os.getpid())
|
|
35
|
+
actors = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
|
36
|
+
levels = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
|
37
|
+
assets = unreal.EditorAssetLibrary
|
|
38
|
+
folder = plan['asset_folder']
|
|
39
|
+
existing, sources, materials, world = {}, {}, {}, None
|
|
40
|
+
nodes = {n['id']: n for n in plan['document']['nodes']}
|
|
41
|
+
matrices = world_matrices(list(nodes.values()))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def progress(stage, **values):
|
|
45
|
+
state.update(stage=stage, updated_at=time.time(), **values)
|
|
46
|
+
temp = status_file.with_suffix('.tmp')
|
|
47
|
+
temp.write_text(json.dumps(state, indent=2))
|
|
48
|
+
temp.replace(status_file)
|
|
49
|
+
unreal.log('GRIPFORGE_MAP ' + stage)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def save():
|
|
53
|
+
if world and not levels.save_current_level():
|
|
54
|
+
raise RuntimeError('Cannot save the work level.')
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cancelled():
|
|
58
|
+
file = job_dir / 'control.json'
|
|
59
|
+
if file.exists() and json.loads(file.read_text()).get('cancel'):
|
|
60
|
+
# Geometry was checkpointed before the PIE review; never save a play world.
|
|
61
|
+
if not levels.is_in_play_in_editor():
|
|
62
|
+
save()
|
|
63
|
+
raise InterruptedError('Cancelled at a saved checkpoint. Relaunch the same job to resume.')
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def vec(p):
|
|
67
|
+
return unreal.Vector(p['x'] * 100, p['z'] * 100, p['y'] * 100)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def rgb(value, gain=1):
|
|
71
|
+
# Scene hex colours are sRGB, Unreal's LinearColor is linear.
|
|
72
|
+
values = [int(value[i:i+2], 16)/255 for i in (1, 3, 5)]
|
|
73
|
+
return unreal.LinearColor(*[(v/12.92 if v <= .04045 else ((v+.055)/1.055)**2.4)*gain for v in values], 1)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def apply_transform(actor, matrix, source=None):
|
|
77
|
+
translation, rotation, scale = native_transform(matrix, source)
|
|
78
|
+
actor.set_actor_transform(unreal.Transform(location=unreal.Vector(*translation), rotation=unreal.Quat(*rotation).rotator(), scale=unreal.Vector(*scale)), False, True)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def new_actor(kind, key, name, group):
|
|
82
|
+
if key in existing:
|
|
83
|
+
return existing[key]
|
|
84
|
+
actor = actors.spawn_actor_from_class(kind, unreal.Vector())
|
|
85
|
+
if not actor:
|
|
86
|
+
raise RuntimeError('Cannot spawn ' + key)
|
|
87
|
+
actor.set_actor_label(name)
|
|
88
|
+
actor.set_folder_path('GripForge/' + group)
|
|
89
|
+
if kind == unreal.TargetPoint:
|
|
90
|
+
actor.root_component.set_mobility(unreal.ComponentMobility.STATIC)
|
|
91
|
+
actor.tags = ['GripForge.Scene', 'gf:' + key, 'gf:job:' + plan_hash]
|
|
92
|
+
existing[key] = actor
|
|
93
|
+
return actor
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def finished(actor):
|
|
97
|
+
return 'gf:complete' in [str(t) for t in actor.tags]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def finish(actor, node=None):
|
|
101
|
+
tags = [str(t) for t in actor.tags if str(t) != 'gf:complete' and not str(t).startswith('gf:data:')]
|
|
102
|
+
if node:
|
|
103
|
+
# Carry the portable data with the editable native actor, including polygons, roles and seeds.
|
|
104
|
+
payload = {k:v for k,v in node.items() if k not in ('heights', 'splat')}
|
|
105
|
+
tags.append('gf:data:' + json.dumps(payload, separators=(',', ':')))
|
|
106
|
+
actor.set_actor_hidden_in_game(not node['visible'] or node['type'] in ('group','path','region','zone'))
|
|
107
|
+
actor.set_is_temporarily_hidden_in_editor(not node['visible'])
|
|
108
|
+
actor.tags = tags + ['gf:complete']
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def owned_asset(path):
|
|
112
|
+
if not assets.does_asset_exist(path):
|
|
113
|
+
return None
|
|
114
|
+
asset = unreal.load_asset(path)
|
|
115
|
+
if assets.get_metadata_tag(asset, 'GripForgeJob') != plan_hash:
|
|
116
|
+
raise RuntimeError('Existing output asset is not owned by this job: ' + path)
|
|
117
|
+
return asset
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def save_asset(asset):
|
|
121
|
+
assets.set_metadata_tag(asset, 'GripForgeJob', plan_hash)
|
|
122
|
+
if not assets.save_loaded_asset(asset):
|
|
123
|
+
raise RuntimeError('Cannot save generated asset ' + asset.get_path_name())
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def material_texture(source, channel, normal_format):
|
|
127
|
+
if isinstance(source, str):
|
|
128
|
+
texture = unreal.load_asset(source)
|
|
129
|
+
if not isinstance(texture, unreal.Texture):
|
|
130
|
+
raise RuntimeError('Missing PBR texture: ' + source)
|
|
131
|
+
return texture
|
|
132
|
+
# Never alter source texture packages. Each interpretation is an owned imported asset:
|
|
133
|
+
# the same bytes can be sampled as sRGB color and as a linear mask independently.
|
|
134
|
+
interpretation = 'normal_' + normal_format if channel == 'normal' else 'srgb' if channel in ('color', 'emissive') else 'linear'
|
|
135
|
+
name = 'T_' + source['sha256'][:32] + '_' + interpretation
|
|
136
|
+
dest = folder + '/' + name
|
|
137
|
+
existing_texture = owned_asset(dest)
|
|
138
|
+
if existing_texture:
|
|
139
|
+
return existing_texture
|
|
140
|
+
task = unreal.AssetImportTask()
|
|
141
|
+
task.filename = source['file']
|
|
142
|
+
task.destination_path = folder
|
|
143
|
+
task.destination_name = name
|
|
144
|
+
task.automated = True
|
|
145
|
+
task.replace_existing = False
|
|
146
|
+
task.save = False
|
|
147
|
+
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
|
|
148
|
+
texture = unreal.load_asset(dest)
|
|
149
|
+
if not isinstance(texture, unreal.Texture2D):
|
|
150
|
+
raise RuntimeError('Cannot import portable texture: ' + source['assetId'])
|
|
151
|
+
texture.set_editor_property('srgb', channel in ('color', 'emissive'))
|
|
152
|
+
texture.set_editor_property('compression_settings', unreal.TextureCompressionSettings.TC_NORMALMAP if channel == 'normal' else unreal.TextureCompressionSettings.TC_DEFAULT if channel in ('color', 'emissive') else unreal.TextureCompressionSettings.TC_MASKS)
|
|
153
|
+
if channel == 'normal':
|
|
154
|
+
texture.set_editor_property('flip_green_channel', normal_format == 'opengl')
|
|
155
|
+
save_asset(texture)
|
|
156
|
+
return texture
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def material(definition):
|
|
160
|
+
key = definition['id']
|
|
161
|
+
if key in materials:
|
|
162
|
+
return materials[key]
|
|
163
|
+
dest = folder + '/M_' + key
|
|
164
|
+
existing_material = owned_asset(dest)
|
|
165
|
+
mat = existing_material or unreal.AssetToolsHelpers.get_asset_tools().create_asset('M_'+key, folder, unreal.Material, unreal.MaterialFactoryNew())
|
|
166
|
+
edit = unreal.MaterialEditingLibrary
|
|
167
|
+
if existing_material:
|
|
168
|
+
edit.delete_all_material_expressions(mat)
|
|
169
|
+
def connect(expression, output, prop):
|
|
170
|
+
if prop==unreal.MaterialProperty.MP_BASE_COLOR and definition.get('vertex_colors'):
|
|
171
|
+
vertex=edit.create_material_expression(mat,unreal.MaterialExpressionVertexColor,-100,-180)
|
|
172
|
+
product=edit.create_material_expression(mat,unreal.MaterialExpressionMultiply,50,-100)
|
|
173
|
+
edit.connect_material_expressions(expression,output,product,'A')
|
|
174
|
+
edit.connect_material_expressions(vertex,'RGB',product,'B')
|
|
175
|
+
edit.connect_material_property(product,'',prop)
|
|
176
|
+
else:
|
|
177
|
+
edit.connect_material_property(expression,output,prop)
|
|
178
|
+
channels = [('color', unreal.MaterialProperty.MP_BASE_COLOR), ('normal', unreal.MaterialProperty.MP_NORMAL),
|
|
179
|
+
('roughness', unreal.MaterialProperty.MP_ROUGHNESS), ('metalness', unreal.MaterialProperty.MP_METALLIC),
|
|
180
|
+
('ao', unreal.MaterialProperty.MP_AMBIENT_OCCLUSION), ('emissive', unreal.MaterialProperty.MP_EMISSIVE_COLOR)]
|
|
181
|
+
textures = definition.get('textures', {})
|
|
182
|
+
uv = edit.create_material_expression(mat, unreal.MaterialExpressionTextureCoordinate, -650, -220)
|
|
183
|
+
uv.set_editor_property('u_tiling', definition.get('repeat', [1, 1])[0])
|
|
184
|
+
uv.set_editor_property('v_tiling', definition.get('repeat', [1, 1])[1])
|
|
185
|
+
for index, (channel, prop) in enumerate(channels):
|
|
186
|
+
if channel in textures:
|
|
187
|
+
texture = material_texture(textures[channel], channel, definition.get('normal_format', 'opengl'))
|
|
188
|
+
node = edit.create_material_expression(mat, unreal.MaterialExpressionTextureSample, -400, index*180)
|
|
189
|
+
node.texture = texture
|
|
190
|
+
edit.connect_material_expressions(uv, '', node, 'UVs')
|
|
191
|
+
node.sampler_type = (unreal.MaterialSamplerType.SAMPLERTYPE_NORMAL if channel=='normal' else
|
|
192
|
+
unreal.MaterialSamplerType.SAMPLERTYPE_COLOR if texture.get_editor_property('srgb') else
|
|
193
|
+
unreal.MaterialSamplerType.SAMPLERTYPE_MASKS if texture.get_editor_property('compression_settings')==unreal.TextureCompressionSettings.TC_MASKS else unreal.MaterialSamplerType.SAMPLERTYPE_LINEAR_COLOR)
|
|
194
|
+
# Separate grayscale maps and glTF-style ORM images use the same channels as Three.js.
|
|
195
|
+
output='RGB' if channel in ('color','normal','emissive') else ('G' if channel=='roughness' else 'B' if channel=='metalness' else 'R') if isinstance(textures[channel],dict) else 'R'
|
|
196
|
+
if channel not in ('normal','ao'):
|
|
197
|
+
factor=edit.create_material_expression(mat,unreal.MaterialExpressionConstant3Vector if channel in ('color','emissive') else unreal.MaterialExpressionConstant,-600,index*180+70)
|
|
198
|
+
if channel in ('color','emissive'):
|
|
199
|
+
factor.constant=rgb(definition.get(channel,'#ffffff'),definition.get('emissive_intensity',1) if channel=='emissive' else 1)
|
|
200
|
+
else:
|
|
201
|
+
factor.r=definition.get(channel,1)
|
|
202
|
+
product=edit.create_material_expression(mat,unreal.MaterialExpressionMultiply,-150,index*180)
|
|
203
|
+
edit.connect_material_expressions(node,output,product,'A')
|
|
204
|
+
edit.connect_material_expressions(factor,'',product,'B')
|
|
205
|
+
connect(product,'',prop)
|
|
206
|
+
else:
|
|
207
|
+
connect(node,output,prop)
|
|
208
|
+
elif channel == 'color':
|
|
209
|
+
node = edit.create_material_expression(mat, unreal.MaterialExpressionConstant3Vector, -400, index*180)
|
|
210
|
+
node.constant = rgb(definition.get('color','#ffffff'))
|
|
211
|
+
connect(node, '', prop)
|
|
212
|
+
elif channel in ('roughness','metalness'):
|
|
213
|
+
node = edit.create_material_expression(mat, unreal.MaterialExpressionConstant, -400, index*180)
|
|
214
|
+
node.r = definition.get(channel, .8 if channel=='roughness' else 0)
|
|
215
|
+
edit.connect_material_property(node, '', prop)
|
|
216
|
+
if definition.get('emissive') and 'emissive' not in textures:
|
|
217
|
+
node = edit.create_material_expression(mat, unreal.MaterialExpressionConstant3Vector, -400, 800)
|
|
218
|
+
node.constant = rgb(definition['emissive'], definition.get('emissive_intensity',1))
|
|
219
|
+
edit.connect_material_property(node, '', unreal.MaterialProperty.MP_EMISSIVE_COLOR)
|
|
220
|
+
if definition.get('unlit'):
|
|
221
|
+
mat.set_editor_property('shading_model', unreal.MaterialShadingModel.MSM_UNLIT)
|
|
222
|
+
edit.recompile_material(mat)
|
|
223
|
+
save_asset(mat)
|
|
224
|
+
materials[key] = mat
|
|
225
|
+
return mat
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def imported_mesh(source):
|
|
229
|
+
name = 'SM_' + source['assetId'] + '_' + source['revisionId'][:24]
|
|
230
|
+
dest = folder + '/' + name
|
|
231
|
+
mesh = owned_asset(dest)
|
|
232
|
+
if mesh:
|
|
233
|
+
return mesh
|
|
234
|
+
task = unreal.AssetImportTask()
|
|
235
|
+
task.filename = source['file']
|
|
236
|
+
task.destination_path = folder + '/Imported_' + source['assetId']
|
|
237
|
+
task.destination_name = name
|
|
238
|
+
task.automated = True
|
|
239
|
+
task.replace_existing = False
|
|
240
|
+
task.save = False
|
|
241
|
+
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
|
|
242
|
+
imported = [unreal.load_asset(p) for p in task.imported_object_paths]
|
|
243
|
+
meshes = [asset for asset in imported if isinstance(asset, unreal.StaticMesh)]
|
|
244
|
+
if len(meshes) != 1:
|
|
245
|
+
raise RuntimeError('Each GLB binding must contain exactly one static mesh; split source assets before delivery: ' + source['file'])
|
|
246
|
+
mesh = meshes[0]
|
|
247
|
+
for asset in imported:
|
|
248
|
+
save_asset(asset)
|
|
249
|
+
if not assets.rename_asset(mesh.get_path_name(), dest):
|
|
250
|
+
raise RuntimeError('Cannot name generated mesh ' + name)
|
|
251
|
+
mesh = unreal.load_asset(dest)
|
|
252
|
+
save_asset(mesh)
|
|
253
|
+
return mesh
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def geometry_spline(actor, node):
|
|
257
|
+
spline = actor.get_component_by_class(unreal.SplineComponent)
|
|
258
|
+
if not spline:
|
|
259
|
+
subsystem = unreal.get_engine_subsystem(unreal.SubobjectDataSubsystem)
|
|
260
|
+
handles = subsystem.k2_gather_subobject_data_for_instance(actor)
|
|
261
|
+
handle, failure = subsystem.add_new_subobject(unreal.AddNewSubobjectParams(parent_handle=handles[0], new_class=unreal.SplineComponent))
|
|
262
|
+
data = unreal.SubobjectDataBlueprintFunctionLibrary.get_data(handle)
|
|
263
|
+
spline = unreal.SubobjectDataBlueprintFunctionLibrary.get_associated_object(data)
|
|
264
|
+
if not isinstance(spline, unreal.SplineComponent):
|
|
265
|
+
raise RuntimeError('Cannot create editable scene spline: ' + str(failure))
|
|
266
|
+
points = node['points'] if node['type']=='path' else [{'x':p[0],'y':0,'z':p[1]} for p in node['polygon']]
|
|
267
|
+
spline.set_spline_points([vec(p) for p in points], unreal.SplineCoordinateSpace.LOCAL, True)
|
|
268
|
+
for i in range(len(points)):
|
|
269
|
+
spline.set_spline_point_type(i, unreal.SplinePointType.LINEAR, False)
|
|
270
|
+
spline.set_closed_loop(node['type']!='path', True)
|
|
271
|
+
spline.set_editor_property('component_tags', ['GripForge.Geometry', 'width_m:'+str(node.get('width',0))])
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def environment(node):
|
|
275
|
+
settings = plan['environment']; prefix = node['id']
|
|
276
|
+
lighting = node.get('lighting', {})
|
|
277
|
+
sun = new_actor(unreal.DirectionalLight, prefix+'_sun', node['name']+' · Sun', 'Environment')
|
|
278
|
+
direction = vec(node.get('sky',{}).get('sun', {'x':-6,'y':6,'z':3}))
|
|
279
|
+
sun.set_actor_rotation(unreal.MathLibrary.find_look_at_rotation(direction,unreal.Vector()), False)
|
|
280
|
+
sun.light_component.set_intensity(settings['sun_lux'])
|
|
281
|
+
sun.light_component.set_light_color(rgb(lighting.get('sunColor','#ffffff')))
|
|
282
|
+
sun.light_component.set_editor_property('atmosphere_sun_light', True)
|
|
283
|
+
sky = new_actor(unreal.SkyLight, prefix+'_sky', node['name']+' · Sky light', 'Environment')
|
|
284
|
+
sky.light_component.set_intensity(settings['sky_intensity'])
|
|
285
|
+
sky.light_component.set_editor_property('real_time_capture', True)
|
|
286
|
+
new_actor(unreal.SkyAtmosphere, prefix+'_atmosphere', node['name']+' · Atmosphere', 'Environment')
|
|
287
|
+
if node.get('fog'):
|
|
288
|
+
fog = new_actor(unreal.ExponentialHeightFog, prefix+'_fog', node['name']+' · Fog', 'Environment')
|
|
289
|
+
fog.component.set_editor_property('fog_density', settings['fog_density'])
|
|
290
|
+
fog.component.set_editor_property('fog_inscattering_luminance', rgb(node['fog']['color']))
|
|
291
|
+
pp = new_actor(unreal.PostProcessVolume, prefix+'_exposure', node['name']+' · Exposure', 'Environment')
|
|
292
|
+
pp.set_editor_property('unbound', True)
|
|
293
|
+
post = pp.get_editor_property('settings')
|
|
294
|
+
extended=unreal.SystemLibrary.get_console_variable_int_value('r.DefaultFeature.AutoExposure.ExtendDefaultLuminanceRange')==1
|
|
295
|
+
exposure=exposure_brightness(settings['exposure_ev100'],extended)
|
|
296
|
+
for prop, value in [('override_auto_exposure_min_brightness',True),('override_auto_exposure_max_brightness',True),
|
|
297
|
+
('auto_exposure_min_brightness',exposure),('auto_exposure_max_brightness',exposure),
|
|
298
|
+
('override_auto_exposure_bias',True),('auto_exposure_bias',0.0),
|
|
299
|
+
('override_bloom_intensity',True),('bloom_intensity',settings['bloom_intensity'])]:
|
|
300
|
+
post.set_editor_property(prop,value)
|
|
301
|
+
pp.set_editor_property('settings',post)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def build_node(node):
|
|
305
|
+
key, kind = node['id'], node['type']
|
|
306
|
+
if key in existing and finished(existing[key]) and kind!='environment':
|
|
307
|
+
return
|
|
308
|
+
source = None
|
|
309
|
+
group = nodes.get(node['parentId'], {}).get('name', kind.title())
|
|
310
|
+
if kind in ('asset', 'terrain'):
|
|
311
|
+
ref = node['asset'] if kind=='asset' else next(t for t in plan['terrains'] if t['nodeId']==key)
|
|
312
|
+
source, resource = sources[(ref['assetId'], ref['revisionId'])]
|
|
313
|
+
cls = resource if source['kind']=='native_actor' else unreal.StaticMeshActor
|
|
314
|
+
actor = new_actor(cls, key, node['name'], group)
|
|
315
|
+
if source['kind']=='native_actor':
|
|
316
|
+
for prop,value in plan.get('actor_properties',{}).get(key,{}).items():
|
|
317
|
+
actor.set_editor_property(prop,value)
|
|
318
|
+
if any(p['nodeId']==key and p.get('hide_static_meshes') for p in plan.get('vfx_previews',[])):
|
|
319
|
+
for component in actor.get_components_by_class(unreal.StaticMeshComponent):
|
|
320
|
+
component.set_visibility(False,False)
|
|
321
|
+
if source['kind']!='native_actor':
|
|
322
|
+
actor.static_mesh_component.set_static_mesh(resource)
|
|
323
|
+
elif kind=='primitive':
|
|
324
|
+
shape = {'box':'Cube','sphere':'Sphere','cylinder':'Cylinder','plane':'Plane'}[node['shape']]
|
|
325
|
+
actor = new_actor(unreal.StaticMeshActor, key, node['name'], group)
|
|
326
|
+
actor.static_mesh_component.set_static_mesh(unreal.load_asset('/Engine/BasicShapes/'+shape))
|
|
327
|
+
mat = dict(node['material'], id='primitive_'+key)
|
|
328
|
+
actor.static_mesh_component.set_material(0,material(mat))
|
|
329
|
+
elif kind=='camera':
|
|
330
|
+
actor = new_actor(unreal.CameraActor, key, node['name'], 'Cameras')
|
|
331
|
+
actor.camera_component.set_field_of_view(node['fov'])
|
|
332
|
+
elif kind=='light':
|
|
333
|
+
cls = {'point':unreal.PointLight,'directional':unreal.DirectionalLight,'ambient':unreal.SkyLight,'hemisphere':unreal.SkyLight}[node['lightKind']]
|
|
334
|
+
actor = new_actor(cls,key,node['name'],group)
|
|
335
|
+
actor.light_component.set_intensity(node['intensity'])
|
|
336
|
+
actor.light_component.set_light_color(rgb(node['color']))
|
|
337
|
+
actor.light_component.set_cast_shadows(node['shadows'])
|
|
338
|
+
if node['lightKind']=='point':
|
|
339
|
+
actor.light_component.set_attenuation_radius(node['range']*100)
|
|
340
|
+
else:
|
|
341
|
+
actor = new_actor(unreal.TargetPoint, key, node['name'], group)
|
|
342
|
+
apply_transform(actor, matrices[key], source)
|
|
343
|
+
if kind=='primitive':
|
|
344
|
+
size = node['size']
|
|
345
|
+
s = actor.get_actor_scale3d()
|
|
346
|
+
# UE basic primitives are 100 cm across. Plane lies in native XY, as scene planes lie in XZ.
|
|
347
|
+
actor.set_actor_scale3d(unreal.Vector(s.x*size[0],s.y*size[2],s.z*size[1]))
|
|
348
|
+
if kind=='camera':
|
|
349
|
+
parent = matrices[node['parentId']] if node['parentId'] else identity()
|
|
350
|
+
target = point(parent,[node['target'][k] for k in 'xyz'])
|
|
351
|
+
actor.set_actor_rotation(unreal.MathLibrary.find_look_at_rotation(actor.get_actor_location(),unreal.Vector(target[0]*100,target[2]*100,target[1]*100)),False)
|
|
352
|
+
if kind in ('path','region','zone'):
|
|
353
|
+
geometry_spline(actor,node)
|
|
354
|
+
if kind=='environment':
|
|
355
|
+
environment(node)
|
|
356
|
+
if kind in ('primitive','terrain','asset') and hasattr(actor,'static_mesh_component'):
|
|
357
|
+
mode = plan['collision'].get(key, 'complex' if kind=='terrain' else 'source')
|
|
358
|
+
component = actor.static_mesh_component
|
|
359
|
+
if mode=='complex':
|
|
360
|
+
if not source or source['kind']!='glb':
|
|
361
|
+
raise RuntimeError('Cannot change shared native collision data: '+key)
|
|
362
|
+
mesh = component.static_mesh
|
|
363
|
+
mesh.get_editor_property('body_setup').set_editor_property('collision_trace_flag',unreal.CollisionTraceFlag.CTF_USE_COMPLEX_AS_SIMPLE)
|
|
364
|
+
save_asset(mesh)
|
|
365
|
+
component.set_collision_profile_name('NoCollision' if mode=='none' else 'BlockAll')
|
|
366
|
+
for binding in plan['material_bindings']:
|
|
367
|
+
if binding['nodeId']==key:
|
|
368
|
+
component.set_material(binding['slot'],materials[binding['materialId']])
|
|
369
|
+
finish(actor,node)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def build():
|
|
373
|
+
global world, existing
|
|
374
|
+
progress('checking_sources', error=None)
|
|
375
|
+
if plan['schema']!='gripforge.unreal-scene.v1' or any(not p.startswith('/Game/GripForge/Maps/') for p in (plan['target_level'],folder)):
|
|
376
|
+
raise RuntimeError('Invalid native work output namespace')
|
|
377
|
+
project = Path(plan['project_file']).resolve().parent
|
|
378
|
+
current = Path(unreal.Paths.convert_relative_path_to_full(unreal.Paths.project_dir())).resolve()
|
|
379
|
+
if project != current:
|
|
380
|
+
raise RuntimeError('The active Unreal project does not match the pinned delivery project')
|
|
381
|
+
for source in plan['assets']:
|
|
382
|
+
cancelled()
|
|
383
|
+
path = Path(source['file']) if source['kind']=='glb' else project/'Content'/(source['path'][6:]+'.uasset')
|
|
384
|
+
if hashlib.sha256(path.read_bytes()).hexdigest()!=source['sha256']:
|
|
385
|
+
raise RuntimeError('Source revision changed: '+source['assetId'])
|
|
386
|
+
texture_files = {t['file']: t for m in plan['materials'] for t in m.get('textures', {}).values() if isinstance(t, dict)}
|
|
387
|
+
for source in texture_files.values():
|
|
388
|
+
cancelled()
|
|
389
|
+
if hashlib.sha256(Path(source['file']).read_bytes()).hexdigest() != source['sha256']:
|
|
390
|
+
raise RuntimeError('Texture revision changed: ' + source['assetId'])
|
|
391
|
+
# Check every transform before creating a level, including shear / rotated non-uniform parents.
|
|
392
|
+
for m in matrices.values():
|
|
393
|
+
native_transform(m)
|
|
394
|
+
resuming=assets.does_asset_exist(plan['target_level'])
|
|
395
|
+
if resuming:
|
|
396
|
+
# Loading a World with load_asset and retaining its Python wrapper across LoadLevel
|
|
397
|
+
# prevents UE's old-world GC and crashes the editor. Inspect ownership after opening it.
|
|
398
|
+
if not levels.load_level(plan['target_level']):
|
|
399
|
+
raise RuntimeError('Cannot resume native work level')
|
|
400
|
+
else:
|
|
401
|
+
if not levels.new_level(plan['target_level']):
|
|
402
|
+
raise RuntimeError('Cannot create native work level')
|
|
403
|
+
opened_world=unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_editor_world()
|
|
404
|
+
if resuming and assets.get_metadata_tag(opened_world,'GripForgeJob')!=plan_hash:
|
|
405
|
+
raise RuntimeError('Target level exists without this job ownership marker. Use a new versioned target.')
|
|
406
|
+
world=opened_world
|
|
407
|
+
assets.set_metadata_tag(world,'GripForgeJob',plan_hash)
|
|
408
|
+
assets.set_metadata_tag(world,'GripForgeLifecycle','work')
|
|
409
|
+
save()
|
|
410
|
+
existing = {str(tag)[3:]:actor for actor in actors.get_all_level_actors() for tag in actor.tags
|
|
411
|
+
if str(tag).startswith('gf:') and not str(tag).startswith(('gf:job:','gf:data:')) and str(tag)!='gf:complete'}
|
|
412
|
+
progress('importing_assets',level_created=True,completed_assets=0,total_assets=len(plan['assets']))
|
|
413
|
+
for index,source in enumerate(plan['assets']):
|
|
414
|
+
cancelled()
|
|
415
|
+
progress('importing_assets',completed_assets=index,current_asset=source['assetId'])
|
|
416
|
+
if source['kind']=='glb':
|
|
417
|
+
resource=imported_mesh(source)
|
|
418
|
+
elif source['kind']=='native_actor':
|
|
419
|
+
resource=unreal.load_class(None, source['path']+'.'+source['path'].rsplit('/',1)[1]+'_C')
|
|
420
|
+
else:
|
|
421
|
+
resource=unreal.load_asset(source['path'])
|
|
422
|
+
if not resource:
|
|
423
|
+
raise RuntimeError('Cannot load asset '+source['assetId'])
|
|
424
|
+
sources[(source['assetId'],source['revisionId'])]=(source,resource)
|
|
425
|
+
progress('building_materials',completed_assets=len(plan['assets']),current_asset=None)
|
|
426
|
+
for index,definition in enumerate(plan['materials']):
|
|
427
|
+
cancelled()
|
|
428
|
+
progress('building_materials', completed_materials=index, total_materials=len(plan['materials']), current_material=definition['id'])
|
|
429
|
+
material(definition)
|
|
430
|
+
progress('building_materials', completed_materials=len(plan['materials']), current_material=None, imported_texture_files=len(texture_files))
|
|
431
|
+
ordered=sorted(nodes.values(),key=lambda n: 0 if n['type']=='terrain' else 1 if n['type']=='group' else 2)
|
|
432
|
+
for index,node in enumerate(ordered):
|
|
433
|
+
cancelled()
|
|
434
|
+
build_node(node)
|
|
435
|
+
if index%40==0:
|
|
436
|
+
save()
|
|
437
|
+
progress('placing_nodes',completed_nodes=index+1,total_nodes=len(nodes),progress=round((index+1)/len(nodes)*.85,3))
|
|
438
|
+
for node in nodes.values():
|
|
439
|
+
if node['parentId']:
|
|
440
|
+
parent_actor=existing[node['parentId']]
|
|
441
|
+
if isinstance(parent_actor,unreal.TargetPoint):
|
|
442
|
+
parent_actor.root_component.set_mobility(unreal.ComponentMobility.STATIC)
|
|
443
|
+
if not existing[node['id']].attach_to_actor(parent_actor, '', unreal.AttachmentRule.KEEP_WORLD, unreal.AttachmentRule.KEEP_WORLD, unreal.AttachmentRule.KEEP_WORLD, False):
|
|
444
|
+
raise RuntimeError('Cannot preserve native scene hierarchy: '+node['id'])
|
|
445
|
+
for override in plan.get('niagara_overrides',[]):
|
|
446
|
+
matched=0
|
|
447
|
+
for component in existing[override['nodeId']].get_components_by_class(unreal.NiagaraComponent):
|
|
448
|
+
system=component.get_asset()
|
|
449
|
+
if not system or system.get_path_name().split('.')[0]!=override['system']:
|
|
450
|
+
continue
|
|
451
|
+
for name,value in override['colors'].items():
|
|
452
|
+
component.set_variable_linear_color(name,rgb(value['color'],value['intensity']))
|
|
453
|
+
matched+=1
|
|
454
|
+
if not matched:
|
|
455
|
+
raise RuntimeError('Niagara system not found on requested instance: '+override['nodeId'])
|
|
456
|
+
for start in plan['gameplay']['player_starts']:
|
|
457
|
+
node=nodes[start['nodeId']]
|
|
458
|
+
actor=new_actor(unreal.PlayerStart,'start_'+node['id'],node['name']+' · Player start','Spawn Zones')
|
|
459
|
+
apply_transform(actor,matrices[node['id']])
|
|
460
|
+
actor.set_actor_location(actor.get_actor_location()+vec(start['offset']),False,True)
|
|
461
|
+
actor.set_editor_property('player_start_tag','gf:start_'+node['id'])
|
|
462
|
+
finish(actor)
|
|
463
|
+
mode=plan['gameplay'].get('game_mode')
|
|
464
|
+
if mode:
|
|
465
|
+
cls=unreal.load_class(None,mode)
|
|
466
|
+
if not cls:
|
|
467
|
+
raise RuntimeError('Missing gameplay class '+mode)
|
|
468
|
+
world.get_world_settings().set_editor_property('default_game_mode',cls)
|
|
469
|
+
active=existing.get(plan['document'].get('activeCameraId'))
|
|
470
|
+
if isinstance(active,unreal.CameraActor):
|
|
471
|
+
unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).set_level_viewport_camera_info(active.get_actor_location(),active.get_actor_rotation())
|
|
472
|
+
save()
|
|
473
|
+
measurements={}
|
|
474
|
+
for node in nodes.values():
|
|
475
|
+
if node['type']=='terrain':
|
|
476
|
+
lo,hi=existing[node['id']].static_mesh_component.get_local_bounds()
|
|
477
|
+
measured=[(hi.x-lo.x)/100,(hi.y-lo.y)/100]
|
|
478
|
+
if any(abs(a-b)>.1 for a,b in zip(measured,node['size'])):
|
|
479
|
+
raise RuntimeError('Terrain metre/centimetre mismatch: '+str(measured))
|
|
480
|
+
measurements[node['id']]=measured
|
|
481
|
+
checked=0
|
|
482
|
+
for node in nodes.values():
|
|
483
|
+
if node['type'] not in ('asset','terrain'):
|
|
484
|
+
continue
|
|
485
|
+
ref=node['asset'] if node['type']=='asset' else next(t for t in plan['terrains'] if t['nodeId']==node['id'])
|
|
486
|
+
source,_=sources[(ref['assetId'],ref['revisionId'])]
|
|
487
|
+
t,q,s=native_transform(matrices[node['id']],source)
|
|
488
|
+
actor=existing[node['id']]
|
|
489
|
+
location,scale=actor.get_actor_location(),actor.get_actor_scale3d()
|
|
490
|
+
rotation=actor.get_actor_rotation().quaternion()
|
|
491
|
+
delta=max(abs(t[i]-getattr(location,k)) for i,k in enumerate('xyz'))
|
|
492
|
+
scale_delta=max(abs(s[i]-getattr(scale,k)) for i,k in enumerate('xyz'))
|
|
493
|
+
dot=abs(sum(q[i]*getattr(rotation,k) for i,k in enumerate('xyzw')))
|
|
494
|
+
if delta>1 or scale_delta>1e-4 or dot<.99999:
|
|
495
|
+
raise RuntimeError('Construction or attachment changed transform of '+node['id']+'. Bind its editable Blueprint parameters in actor_properties. Delta: '+str((delta,scale_delta,dot)))
|
|
496
|
+
checked+=1
|
|
497
|
+
progress('verifying_gameplay',gameplay_validation=dict(status='pending'),completed_nodes=len(nodes),total_nodes=len(nodes),progress=.9,terrain_m=measurements,verified_instance_transforms=checked,actor_count=len(existing))
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def warmup_vfx():
|
|
501
|
+
count=0
|
|
502
|
+
for preview in plan.get('vfx_previews',[]):
|
|
503
|
+
components=existing[preview['nodeId']].get_components_by_class(unreal.NiagaraComponent)
|
|
504
|
+
if not components:
|
|
505
|
+
raise RuntimeError('No Niagara components on requested VFX preview: '+preview['nodeId'])
|
|
506
|
+
for component in components:
|
|
507
|
+
component.set_force_solo(True)
|
|
508
|
+
component.set_paused(False)
|
|
509
|
+
component.reinitialize_system()
|
|
510
|
+
component.activate(True)
|
|
511
|
+
component.advance_simulation_by_time(preview['warmup_seconds'],1/60)
|
|
512
|
+
component.set_paused(True)
|
|
513
|
+
count+=1
|
|
514
|
+
state['previewed_vfx_components']=count
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
try:
|
|
518
|
+
build()
|
|
519
|
+
captures=job_dir/'captures'; captures.mkdir(exist_ok=True)
|
|
520
|
+
queue=list(plan['capture']['camera_ids'])
|
|
521
|
+
captured=[]
|
|
522
|
+
next_shot=time.monotonic()+20
|
|
523
|
+
pending=None
|
|
524
|
+
pending_deadline=0
|
|
525
|
+
pending_started=0
|
|
526
|
+
capture_busy=False
|
|
527
|
+
spawn_review=None
|
|
528
|
+
gameplay_checked=False
|
|
529
|
+
def review_gameplay():
|
|
530
|
+
global spawn_review,gameplay_checked
|
|
531
|
+
if gameplay_checked:
|
|
532
|
+
return True
|
|
533
|
+
config=plan['gameplay'].get('spawn_review',{})
|
|
534
|
+
starts=plan['gameplay']['player_starts']
|
|
535
|
+
if not starts or not config.get('enabled',True):
|
|
536
|
+
result=dict(status='not_run',reason='no_player_starts' if not starts else 'disabled_in_plan')
|
|
537
|
+
else:
|
|
538
|
+
if spawn_review is None:
|
|
539
|
+
expected=[]
|
|
540
|
+
for start in starts:
|
|
541
|
+
location=existing['start_'+start['nodeId']].get_actor_location()
|
|
542
|
+
expected.append(dict(id=start['nodeId'],position=[location.x,location.y,location.z]))
|
|
543
|
+
spawn_review=SpawnReview(expected,config.get('duration_seconds',6),config.get('tolerance_cm',200),config.get('require_grounded',True))
|
|
544
|
+
if spawn_review.started_at is None:
|
|
545
|
+
if not spawn_review.start():
|
|
546
|
+
progress('waiting_for_editor',reason='Spawn review waits for the active user play session to end.')
|
|
547
|
+
return False
|
|
548
|
+
progress('verifying_gameplay',reason='Testing the actual GameMode spawn and floor contact in Play in Editor.')
|
|
549
|
+
return False
|
|
550
|
+
result=spawn_review.poll()
|
|
551
|
+
if result is None:
|
|
552
|
+
return False
|
|
553
|
+
(job_dir/'gameplay-review.json').write_text(json.dumps(result,indent=2))
|
|
554
|
+
progress('verifying_gameplay',gameplay_validation=result,reason=None)
|
|
555
|
+
if result['status']=='failed':
|
|
556
|
+
raise RuntimeError('Gameplay spawn review failed: '+', '.join(result['issues'])+'. Inspect gameplay-review.json; check GameMode spawn overrides and terrain collision.')
|
|
557
|
+
gameplay_checked=True
|
|
558
|
+
return True
|
|
559
|
+
def tick(_dt):
|
|
560
|
+
global next_shot,pending,capture_busy,pending_deadline,pending_started
|
|
561
|
+
if capture_busy or time.monotonic()<next_shot:
|
|
562
|
+
return
|
|
563
|
+
capture_busy=True
|
|
564
|
+
try:
|
|
565
|
+
cancelled()
|
|
566
|
+
if not review_gameplay():
|
|
567
|
+
next_shot=time.monotonic()+.2
|
|
568
|
+
return
|
|
569
|
+
# A user can start PIE while reviewing the imported level. Automation
|
|
570
|
+
# then captures the game's viewport, even when an editor camera was
|
|
571
|
+
# supplied. Preserve the play session and retry its interrupted view.
|
|
572
|
+
if levels.is_in_play_in_editor():
|
|
573
|
+
if pending:
|
|
574
|
+
queue.insert(0,pending)
|
|
575
|
+
pending=None
|
|
576
|
+
if state.get('stage')!='waiting_for_editor':
|
|
577
|
+
progress('waiting_for_editor',reason='Play in Editor is active; camera captures resume when it stops.',captures=captured)
|
|
578
|
+
next_shot=time.monotonic()+3
|
|
579
|
+
return
|
|
580
|
+
if pending:
|
|
581
|
+
file=captures/(pending+'.png')
|
|
582
|
+
if not file.exists() or file.stat().st_size<1024 or file.stat().st_mtime<pending_started:
|
|
583
|
+
if time.monotonic()<pending_deadline:
|
|
584
|
+
next_shot=time.monotonic()+2
|
|
585
|
+
return
|
|
586
|
+
raise RuntimeError('Unreal did not produce requested viewport capture within 180 seconds: '+pending)
|
|
587
|
+
captured.append(str(file)); pending=None
|
|
588
|
+
if not queue:
|
|
589
|
+
unreal.unregister_slate_post_tick_callback(tick_handle)
|
|
590
|
+
for preview in plan.get('vfx_previews',[]):
|
|
591
|
+
for component in existing[preview['nodeId']].get_components_by_class(unreal.NiagaraComponent):
|
|
592
|
+
component.set_paused(False)
|
|
593
|
+
component.set_force_solo(False)
|
|
594
|
+
active=existing.get(plan['document'].get('activeCameraId'))
|
|
595
|
+
if isinstance(active,unreal.CameraActor):
|
|
596
|
+
unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).set_level_viewport_camera_info(active.get_actor_location(),active.get_actor_rotation())
|
|
597
|
+
progress('awaiting_visual_review',progress=1,captures=captured)
|
|
598
|
+
return
|
|
599
|
+
key=queue.pop(0); camera=existing[key]
|
|
600
|
+
# Automation capture pumps editor ticks. Reserve the camera before calling UE to
|
|
601
|
+
# prevent a nested tick from consuming the other views or claiming completion early.
|
|
602
|
+
pending=key; pending_started=time.time();next_shot=time.monotonic()+15;pending_deadline=time.monotonic()+180
|
|
603
|
+
progress('capturing',current_camera=key,captures=captured,reason=None)
|
|
604
|
+
warmup_vfx()
|
|
605
|
+
unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).set_level_viewport_camera_info(camera.get_actor_location(),camera.get_actor_rotation())
|
|
606
|
+
unreal.AutomationLibrary.take_high_res_screenshot(plan['capture']['width'],plan['capture']['height'],str(captures/(key+'.png')),camera=camera)
|
|
607
|
+
# Shader/PSO compilation may block the call longer than the initial warmup.
|
|
608
|
+
# Start the file wait only after UE has returned to its event loop.
|
|
609
|
+
next_shot=time.monotonic()+5
|
|
610
|
+
pending_deadline=time.monotonic()+180
|
|
611
|
+
except Exception as error:
|
|
612
|
+
if spawn_review:
|
|
613
|
+
spawn_review.close()
|
|
614
|
+
unreal.unregister_slate_post_tick_callback(tick_handle)
|
|
615
|
+
progress('cancelled' if isinstance(error,InterruptedError) else 'failed',error=traceback.format_exc(),captures=captured)
|
|
616
|
+
finally:
|
|
617
|
+
capture_busy=False
|
|
618
|
+
tick_handle=unreal.register_slate_post_tick_callback(tick)
|
|
619
|
+
except Exception as error:
|
|
620
|
+
save()
|
|
621
|
+
progress('cancelled' if isinstance(error,InterruptedError) else 'failed',error=traceback.format_exc())
|
|
622
|
+
unreal.log_error(traceback.format_exc())
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { uploadUnrealExport } from '../dist/map-unreal-export-local.js';
|
|
2
|
+
try {
|
|
3
|
+
await uploadUnrealExport(process.argv[2], process.argv[3], process.argv[4], process.env.GRIPFORGE_API_KEY ?? '');
|
|
4
|
+
} catch (error) {
|
|
5
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
6
|
+
process.exitCode = 1;
|
|
7
|
+
}
|