unreal-engine-mcp-server 0.4.0 → 0.4.4

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.
Files changed (135) hide show
  1. package/.env.production +1 -1
  2. package/.github/copilot-instructions.md +45 -0
  3. package/.github/workflows/publish-mcp.yml +3 -2
  4. package/README.md +21 -5
  5. package/dist/index.js +124 -31
  6. package/dist/prompts/index.d.ts +10 -3
  7. package/dist/prompts/index.js +186 -7
  8. package/dist/resources/actors.d.ts +19 -1
  9. package/dist/resources/actors.js +55 -64
  10. package/dist/resources/assets.js +46 -62
  11. package/dist/resources/levels.d.ts +21 -3
  12. package/dist/resources/levels.js +29 -54
  13. package/dist/tools/actors.d.ts +3 -14
  14. package/dist/tools/actors.js +246 -302
  15. package/dist/tools/animation.d.ts +57 -102
  16. package/dist/tools/animation.js +429 -450
  17. package/dist/tools/assets.d.ts +13 -2
  18. package/dist/tools/assets.js +52 -44
  19. package/dist/tools/audio.d.ts +22 -13
  20. package/dist/tools/audio.js +467 -121
  21. package/dist/tools/blueprint.d.ts +32 -13
  22. package/dist/tools/blueprint.js +699 -448
  23. package/dist/tools/build_environment_advanced.d.ts +0 -1
  24. package/dist/tools/build_environment_advanced.js +190 -45
  25. package/dist/tools/consolidated-tool-definitions.js +78 -252
  26. package/dist/tools/consolidated-tool-handlers.js +506 -133
  27. package/dist/tools/debug.d.ts +72 -10
  28. package/dist/tools/debug.js +167 -31
  29. package/dist/tools/editor.d.ts +9 -2
  30. package/dist/tools/editor.js +30 -44
  31. package/dist/tools/foliage.d.ts +34 -15
  32. package/dist/tools/foliage.js +97 -107
  33. package/dist/tools/introspection.js +19 -21
  34. package/dist/tools/landscape.d.ts +1 -2
  35. package/dist/tools/landscape.js +311 -168
  36. package/dist/tools/level.d.ts +3 -28
  37. package/dist/tools/level.js +642 -192
  38. package/dist/tools/lighting.d.ts +14 -3
  39. package/dist/tools/lighting.js +236 -123
  40. package/dist/tools/materials.d.ts +25 -7
  41. package/dist/tools/materials.js +102 -79
  42. package/dist/tools/niagara.d.ts +10 -12
  43. package/dist/tools/niagara.js +74 -94
  44. package/dist/tools/performance.d.ts +12 -4
  45. package/dist/tools/performance.js +38 -79
  46. package/dist/tools/physics.d.ts +34 -10
  47. package/dist/tools/physics.js +364 -292
  48. package/dist/tools/rc.js +97 -23
  49. package/dist/tools/sequence.d.ts +1 -0
  50. package/dist/tools/sequence.js +125 -22
  51. package/dist/tools/ui.d.ts +31 -4
  52. package/dist/tools/ui.js +83 -66
  53. package/dist/tools/visual.d.ts +11 -0
  54. package/dist/tools/visual.js +245 -30
  55. package/dist/types/tool-types.d.ts +0 -6
  56. package/dist/types/tool-types.js +1 -8
  57. package/dist/unreal-bridge.d.ts +32 -2
  58. package/dist/unreal-bridge.js +621 -127
  59. package/dist/utils/elicitation.d.ts +57 -0
  60. package/dist/utils/elicitation.js +104 -0
  61. package/dist/utils/error-handler.d.ts +0 -33
  62. package/dist/utils/error-handler.js +4 -111
  63. package/dist/utils/http.d.ts +2 -22
  64. package/dist/utils/http.js +12 -75
  65. package/dist/utils/normalize.d.ts +4 -4
  66. package/dist/utils/normalize.js +15 -7
  67. package/dist/utils/python-output.d.ts +18 -0
  68. package/dist/utils/python-output.js +290 -0
  69. package/dist/utils/python.d.ts +2 -0
  70. package/dist/utils/python.js +4 -0
  71. package/dist/utils/response-validator.js +28 -2
  72. package/dist/utils/result-helpers.d.ts +27 -0
  73. package/dist/utils/result-helpers.js +147 -0
  74. package/dist/utils/safe-json.d.ts +0 -2
  75. package/dist/utils/safe-json.js +0 -43
  76. package/dist/utils/validation.d.ts +16 -0
  77. package/dist/utils/validation.js +70 -7
  78. package/mcp-config-example.json +2 -2
  79. package/package.json +10 -9
  80. package/server.json +37 -14
  81. package/src/index.ts +130 -33
  82. package/src/prompts/index.ts +211 -13
  83. package/src/resources/actors.ts +59 -44
  84. package/src/resources/assets.ts +48 -51
  85. package/src/resources/levels.ts +35 -45
  86. package/src/tools/actors.ts +269 -313
  87. package/src/tools/animation.ts +556 -539
  88. package/src/tools/assets.ts +53 -43
  89. package/src/tools/audio.ts +507 -113
  90. package/src/tools/blueprint.ts +778 -462
  91. package/src/tools/build_environment_advanced.ts +266 -64
  92. package/src/tools/consolidated-tool-definitions.ts +90 -264
  93. package/src/tools/consolidated-tool-handlers.ts +630 -121
  94. package/src/tools/debug.ts +176 -33
  95. package/src/tools/editor.ts +35 -37
  96. package/src/tools/foliage.ts +110 -104
  97. package/src/tools/introspection.ts +24 -22
  98. package/src/tools/landscape.ts +334 -181
  99. package/src/tools/level.ts +683 -182
  100. package/src/tools/lighting.ts +244 -123
  101. package/src/tools/materials.ts +114 -83
  102. package/src/tools/niagara.ts +87 -81
  103. package/src/tools/performance.ts +49 -88
  104. package/src/tools/physics.ts +393 -299
  105. package/src/tools/rc.ts +102 -24
  106. package/src/tools/sequence.ts +136 -28
  107. package/src/tools/ui.ts +101 -70
  108. package/src/tools/visual.ts +250 -29
  109. package/src/types/tool-types.ts +0 -9
  110. package/src/unreal-bridge.ts +658 -140
  111. package/src/utils/elicitation.ts +129 -0
  112. package/src/utils/error-handler.ts +4 -159
  113. package/src/utils/http.ts +16 -115
  114. package/src/utils/normalize.ts +20 -10
  115. package/src/utils/python-output.ts +351 -0
  116. package/src/utils/python.ts +3 -0
  117. package/src/utils/response-validator.ts +25 -2
  118. package/src/utils/result-helpers.ts +193 -0
  119. package/src/utils/safe-json.ts +0 -50
  120. package/src/utils/validation.ts +94 -7
  121. package/tests/run-unreal-tool-tests.mjs +720 -0
  122. package/tsconfig.json +2 -2
  123. package/dist/python-utils.d.ts +0 -29
  124. package/dist/python-utils.js +0 -54
  125. package/dist/types/index.d.ts +0 -323
  126. package/dist/types/index.js +0 -28
  127. package/dist/utils/cache-manager.d.ts +0 -64
  128. package/dist/utils/cache-manager.js +0 -176
  129. package/dist/utils/errors.d.ts +0 -133
  130. package/dist/utils/errors.js +0 -256
  131. package/src/python/editor_compat.py +0 -181
  132. package/src/python-utils.ts +0 -57
  133. package/src/types/index.ts +0 -414
  134. package/src/utils/cache-manager.ts +0 -213
  135. package/src/utils/errors.ts +0 -312
@@ -1,67 +1,304 @@
1
+ // Audio tools for Unreal Engine
2
+ import JSON5 from 'json5';
3
+ import { escapePythonString } from '../utils/python.js';
1
4
  export class AudioTools {
2
5
  bridge;
3
6
  constructor(bridge) {
4
7
  this.bridge = bridge;
5
8
  }
6
- // Execute console command
7
- async _executeCommand(command) {
8
- return this.bridge.httpCall('/remote/object/call', 'PUT', {
9
- objectPath: '/Script/Engine.Default__KismetSystemLibrary',
10
- functionName: 'ExecuteConsoleCommand',
11
- parameters: {
12
- WorldContextObject: null,
13
- Command: command,
14
- SpecificPlayer: null
15
- },
16
- generateTransaction: false
17
- });
9
+ interpretResult(resp, defaults) {
10
+ const normalizePayload = (payload) => {
11
+ const warningsValue = payload?.warnings;
12
+ const warningsText = Array.isArray(warningsValue)
13
+ ? (warningsValue.length > 0 ? warningsValue.join('; ') : undefined)
14
+ : typeof warningsValue === 'string' && warningsValue.trim() !== ''
15
+ ? warningsValue
16
+ : undefined;
17
+ if (payload?.success === true) {
18
+ const message = typeof payload?.message === 'string' && payload.message.trim() !== ''
19
+ ? payload.message
20
+ : defaults.successMessage;
21
+ return {
22
+ success: true,
23
+ message,
24
+ details: warningsText
25
+ };
26
+ }
27
+ const error = (typeof payload?.error === 'string' && payload.error.trim() !== ''
28
+ ? payload.error
29
+ : undefined)
30
+ ?? (typeof payload?.message === 'string' && payload.message.trim() !== ''
31
+ ? payload.message
32
+ : undefined)
33
+ ?? defaults.failureMessage;
34
+ return {
35
+ success: false,
36
+ error,
37
+ details: warningsText
38
+ };
39
+ };
40
+ if (resp && typeof resp === 'object') {
41
+ const payload = resp;
42
+ if ('success' in payload || 'error' in payload || 'message' in payload || 'warnings' in payload) {
43
+ return normalizePayload(payload);
44
+ }
45
+ }
46
+ const raw = typeof resp === 'string' ? resp : JSON.stringify(resp);
47
+ const extractJson = (input) => {
48
+ const marker = 'RESULT:';
49
+ const markerIndex = input.lastIndexOf(marker);
50
+ if (markerIndex === -1) {
51
+ return undefined;
52
+ }
53
+ const afterMarker = input.slice(markerIndex + marker.length);
54
+ const firstBraceIndex = afterMarker.indexOf('{');
55
+ if (firstBraceIndex === -1) {
56
+ return undefined;
57
+ }
58
+ let depth = 0;
59
+ let inString = false;
60
+ let escapeNext = false;
61
+ for (let i = firstBraceIndex; i < afterMarker.length; i++) {
62
+ const char = afterMarker[i];
63
+ if (inString) {
64
+ if (escapeNext) {
65
+ escapeNext = false;
66
+ continue;
67
+ }
68
+ if (char === '\\') {
69
+ escapeNext = true;
70
+ continue;
71
+ }
72
+ if (char === '"') {
73
+ inString = false;
74
+ }
75
+ continue;
76
+ }
77
+ if (char === '"') {
78
+ inString = true;
79
+ continue;
80
+ }
81
+ if (char === '{') {
82
+ depth += 1;
83
+ }
84
+ else if (char === '}') {
85
+ depth -= 1;
86
+ if (depth === 0) {
87
+ return afterMarker.slice(firstBraceIndex, i + 1);
88
+ }
89
+ }
90
+ }
91
+ const fallbackMatch = /\{[\s\S]*\}/.exec(afterMarker);
92
+ return fallbackMatch ? fallbackMatch[0] : undefined;
93
+ };
94
+ const jsonPayload = extractJson(raw);
95
+ if (jsonPayload) {
96
+ const parseAttempts = [
97
+ {
98
+ label: 'json',
99
+ parser: () => JSON.parse(jsonPayload)
100
+ },
101
+ {
102
+ label: 'json5',
103
+ parser: () => JSON5.parse(jsonPayload)
104
+ }
105
+ ];
106
+ const sanitizedForJson5 = jsonPayload
107
+ .replace(/\bTrue\b/g, 'true')
108
+ .replace(/\bFalse\b/g, 'false')
109
+ .replace(/\bNone\b/g, 'null');
110
+ if (sanitizedForJson5 !== jsonPayload) {
111
+ parseAttempts.push({ label: 'json5-sanitized', parser: () => JSON5.parse(sanitizedForJson5) });
112
+ }
113
+ const parseErrors = [];
114
+ for (const attempt of parseAttempts) {
115
+ try {
116
+ const parsed = attempt.parser();
117
+ if (parsed && typeof parsed === 'object') {
118
+ return normalizePayload(parsed);
119
+ }
120
+ }
121
+ catch (err) {
122
+ parseErrors.push(`${attempt.label}: ${err instanceof Error ? err.message : String(err)}`);
123
+ }
124
+ }
125
+ const errorMatch = /["']error["']\s*:\s*(?:"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)')/i.exec(jsonPayload);
126
+ const messageMatch = /["']message["']\s*:\s*(?:"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)')/i.exec(jsonPayload);
127
+ const fallbackText = errorMatch?.[1] ?? errorMatch?.[2] ?? messageMatch?.[1] ?? messageMatch?.[2];
128
+ const errorText = fallbackText && fallbackText.trim().length > 0
129
+ ? fallbackText.trim()
130
+ : `${defaults.failureMessage}: ${parseErrors[0] ?? 'Unable to parse RESULT payload'}`;
131
+ const snippet = jsonPayload.length > 240 ? `${jsonPayload.slice(0, 240)}…` : jsonPayload;
132
+ const detailsParts = [];
133
+ if (parseErrors.length > 0) {
134
+ detailsParts.push(`Parse attempts failed: ${parseErrors.join('; ')}`);
135
+ }
136
+ detailsParts.push(`Raw payload: ${snippet}`);
137
+ const detailsText = detailsParts.join(' | ');
138
+ return {
139
+ success: false,
140
+ error: errorText,
141
+ details: detailsText
142
+ };
143
+ }
144
+ return { success: false, error: defaults.failureMessage };
18
145
  }
19
146
  // Create sound cue
20
147
  async createSoundCue(params) {
148
+ const escapePyString = (value) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
149
+ const toPyNumber = (value) => value === undefined || value === null || !Number.isFinite(value) ? 'None' : String(value);
150
+ const toPyBool = (value) => value === undefined || value === null ? 'None' : value ? 'True' : 'False';
21
151
  const path = params.savePath || '/Game/Audio/Cues';
152
+ const wavePath = params.wavePath || '';
153
+ const attenuationPath = params.settings?.attenuationSettings || '';
154
+ const volumeLiteral = toPyNumber(params.settings?.volume);
155
+ const pitchLiteral = toPyNumber(params.settings?.pitch);
156
+ const loopingLiteral = toPyBool(params.settings?.looping);
22
157
  const py = `
23
158
  import unreal
24
159
  import json
25
- name = r"${params.name}"
26
- path = r"${path}"
160
+
161
+ name = r"${escapePyString(params.name)}"
162
+ package_path = r"${escapePyString(path)}"
163
+ wave_path = r"${escapePyString(wavePath)}"
164
+ attenuation_path = r"${escapePyString(attenuationPath)}"
165
+ attach_wave = ${params.wavePath ? 'True' : 'False'}
166
+ volume_override = ${volumeLiteral}
167
+ pitch_override = ${pitchLiteral}
168
+ looping_override = ${loopingLiteral}
169
+
170
+ result = {
171
+ "success": False,
172
+ "message": "",
173
+ "error": "",
174
+ "warnings": []
175
+ }
176
+
27
177
  try:
28
- asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
178
+ asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
179
+ if not asset_tools:
180
+ result["error"] = "AssetToolsHelpers unavailable"
181
+ raise SystemExit(0)
182
+
183
+ factory = None
184
+ try:
185
+ factory = unreal.SoundCueFactoryNew()
186
+ except Exception:
187
+ factory = None
188
+
189
+ if not factory:
190
+ result["error"] = "SoundCueFactoryNew unavailable"
191
+ raise SystemExit(0)
192
+
193
+ package_path = package_path.rstrip('/') if package_path else package_path
194
+
195
+ asset = asset_tools.create_asset(
196
+ asset_name=name,
197
+ package_path=package_path,
198
+ asset_class=unreal.SoundCue,
199
+ factory=factory
200
+ )
201
+
202
+ if not asset:
203
+ result["error"] = "Failed to create SoundCue"
204
+ raise SystemExit(0)
205
+
206
+ asset_subsystem = None
207
+ try:
208
+ asset_subsystem = unreal.get_editor_subsystem(unreal.EditorAssetSubsystem)
209
+ except Exception:
210
+ asset_subsystem = None
211
+
212
+ editor_library = unreal.EditorAssetLibrary
213
+
214
+ if attach_wave:
215
+ wave_exists = False
29
216
  try:
30
- factory = unreal.SoundCueFactoryNew()
31
- except Exception:
32
- factory = None
33
- if not factory:
34
- print('RESULT:' + json.dumps({'success': False, 'error': 'SoundCueFactoryNew unavailable'}))
217
+ if asset_subsystem and hasattr(asset_subsystem, "does_asset_exist"):
218
+ wave_exists = asset_subsystem.does_asset_exist(wave_path)
219
+ else:
220
+ wave_exists = editor_library.does_asset_exist(wave_path)
221
+ except Exception as existence_error:
222
+ result["warnings"].append(f"Wave lookup failed: {existence_error}")
223
+
224
+ if not wave_exists:
225
+ result["warnings"].append(f"Wave asset not found: {wave_path}")
35
226
  else:
36
- asset = asset_tools.create_asset(asset_name=name, package_path=path, asset_class=unreal.SoundCue, factory=factory)
37
- if asset:
38
- if ${params.wavePath !== undefined ? 'True' : 'False'}:
39
- try:
40
- wave_path = r"${params.wavePath || ''}"
41
- if wave_path and unreal.EditorAssetLibrary.does_asset_exist(wave_path):
42
- snd = unreal.EditorAssetLibrary.load_asset(wave_path)
43
- # Simple node hookup via SoundCueGraph is non-trivial via Python; leave as empty cue
44
- except Exception:
45
- pass
46
- unreal.EditorAssetLibrary.save_asset(f"{path}/{name}")
47
- print('RESULT:' + json.dumps({'success': True}))
227
+ try:
228
+ if asset_subsystem and hasattr(asset_subsystem, "load_asset"):
229
+ wave_asset = asset_subsystem.load_asset(wave_path)
48
230
  else:
49
- print('RESULT:' + json.dumps({'success': False, 'error': 'Failed to create SoundCue'}))
50
- except Exception as e:
51
- print('RESULT:' + json.dumps({'success': False, 'error': str(e)}))
231
+ wave_asset = editor_library.load_asset(wave_path)
232
+ if wave_asset:
233
+ # Hooking up cue nodes via Python is non-trivial; surface warning for manual setup
234
+ result["warnings"].append("Sound cue created without automatic wave node hookup")
235
+ except Exception as wave_error:
236
+ result["warnings"].append(f"Failed to load wave asset: {wave_error}")
237
+
238
+ if volume_override is not None and hasattr(asset, "volume_multiplier"):
239
+ asset.volume_multiplier = volume_override
240
+ if pitch_override is not None and hasattr(asset, "pitch_multiplier"):
241
+ asset.pitch_multiplier = pitch_override
242
+ if looping_override is not None and hasattr(asset, "b_looping"):
243
+ asset.b_looping = looping_override
244
+
245
+ if attenuation_path:
246
+ try:
247
+ attenuation_asset = editor_library.load_asset(attenuation_path)
248
+ if attenuation_asset:
249
+ applied = False
250
+ if hasattr(asset, "set_attenuation_settings"):
251
+ try:
252
+ asset.set_attenuation_settings(attenuation_asset)
253
+ applied = True
254
+ except Exception:
255
+ applied = False
256
+ if not applied and hasattr(asset, "attenuation_settings"):
257
+ asset.attenuation_settings = attenuation_asset
258
+ applied = True
259
+ if not applied:
260
+ result["warnings"].append("Attenuation asset loaded but could not be applied automatically")
261
+ except Exception as attenuation_error:
262
+ result["warnings"].append(f"Failed to apply attenuation: {attenuation_error}")
263
+
264
+ try:
265
+ save_target = f"{package_path}/{name}" if package_path else name
266
+ if asset_subsystem and hasattr(asset_subsystem, "save_asset"):
267
+ asset_subsystem.save_asset(save_target)
268
+ else:
269
+ editor_library.save_asset(save_target)
270
+ except Exception as save_error:
271
+ result["warnings"].append(f"Save failed: {save_error}")
272
+
273
+ result["success"] = True
274
+ result["message"] = "Sound cue created"
275
+
276
+ except SystemExit:
277
+ pass
278
+ except Exception as error:
279
+ result["error"] = str(error)
280
+
281
+ finally:
282
+ payload = dict(result)
283
+ if payload.get("success"):
284
+ if not payload.get("message"):
285
+ payload["message"] = "Sound cue created"
286
+ payload.pop("error", None)
287
+ else:
288
+ if not payload.get("error"):
289
+ payload["error"] = payload.get("message") or "Failed to create SoundCue"
290
+ if not payload.get("message"):
291
+ payload["message"] = payload["error"]
292
+ if not payload.get("warnings"):
293
+ payload.pop("warnings", None)
294
+ print('RESULT:' + json.dumps(payload))
52
295
  `.trim();
53
296
  try {
54
297
  const resp = await this.bridge.executePython(py);
55
- const out = typeof resp === 'string' ? resp : JSON.stringify(resp);
56
- const m = out.match(/RESULT:({.*})/);
57
- if (m) {
58
- try {
59
- const parsed = JSON.parse(m[1]);
60
- return parsed.success ? { success: true, message: 'Sound cue created' } : { success: false, error: parsed.error };
61
- }
62
- catch { }
63
- }
64
- return { success: true, message: 'Sound cue creation attempted' };
298
+ return this.interpretResult(resp, {
299
+ successMessage: 'Sound cue created',
300
+ failureMessage: 'Failed to create SoundCue'
301
+ });
65
302
  }
66
303
  catch (e) {
67
304
  return { success: false, error: `Failed to create sound cue: ${e}` };
@@ -72,43 +309,82 @@ except Exception as e:
72
309
  const volume = params.volume ?? 1.0;
73
310
  const pitch = params.pitch ?? 1.0;
74
311
  const startTime = params.startTime ?? 0.0;
312
+ const soundPath = params.soundPath ?? '';
75
313
  const py = `
76
314
  import unreal
77
315
  import json
78
- loc = unreal.Vector(${params.location[0]}, ${params.location[1]}, ${params.location[2]})
79
- path = r"${params.soundPath}"
316
+
317
+ result = {
318
+ "success": False,
319
+ "message": "",
320
+ "error": "",
321
+ "warnings": []
322
+ }
323
+
80
324
  try:
81
- if not unreal.EditorAssetLibrary.does_asset_exist(path):
82
- print('RESULT:' + json.dumps({'success': False, 'error': 'Sound asset not found'}))
83
- else:
84
- snd = unreal.EditorAssetLibrary.load_asset(path)
85
- # Get editor world via EditorSubsystem first to avoid deprecation
86
- try:
87
- world = unreal.EditorSubsystemLibrary.get_editor_world()
88
- except Exception:
89
- try:
90
- world = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_editor_world()
91
- except Exception:
92
- world = unreal.EditorLevelLibrary.get_editor_world()
93
- rot = unreal.Rotator(0.0, 0.0, 0.0)
94
- # Use spawn_* variant with explicit rotation before optional floats
95
- unreal.GameplayStatics.spawn_sound_at_location(world, snd, loc, rot, ${volume}, ${pitch}, ${startTime})
96
- print('RESULT:' + json.dumps({'success': True}))
325
+ path = "${escapePythonString(soundPath)}"
326
+ if not unreal.EditorAssetLibrary.does_asset_exist(path):
327
+ result["error"] = "Sound asset not found"
328
+ raise SystemExit(0)
329
+
330
+ snd = unreal.EditorAssetLibrary.load_asset(path)
331
+ if not snd:
332
+ result["error"] = f"Failed to load sound asset: {path}"
333
+ raise SystemExit(0)
334
+
335
+ world = None
336
+ try:
337
+ world = unreal.EditorUtilityLibrary.get_editor_world()
338
+ except Exception:
339
+ world = None
340
+
341
+ if not world:
342
+ editor_subsystem = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
343
+ if editor_subsystem and hasattr(editor_subsystem, 'get_editor_world'):
344
+ world = editor_subsystem.get_editor_world()
345
+
346
+ if not world:
347
+ try:
348
+ world = unreal.EditorSubsystemLibrary.get_editor_world()
349
+ except Exception:
350
+ world = None
351
+
352
+ if not world:
353
+ result["error"] = "Unable to resolve editor world. Start PIE and ensure Editor Scripting Utilities is enabled."
354
+ raise SystemExit(0)
355
+
356
+ loc = unreal.Vector(${params.location[0]}, ${params.location[1]}, ${params.location[2]})
357
+ rot = unreal.Rotator(0.0, 0.0, 0.0)
358
+ unreal.GameplayStatics.spawn_sound_at_location(world, snd, loc, rot, ${volume}, ${pitch}, ${startTime})
359
+
360
+ result["success"] = True
361
+ result["message"] = "Sound played"
362
+
363
+ except SystemExit:
364
+ pass
97
365
  except Exception as e:
98
- print('RESULT:' + json.dumps({'success': False, 'error': str(e)}))
366
+ result["error"] = str(e)
367
+ finally:
368
+ payload = dict(result)
369
+ if payload.get("success"):
370
+ if not payload.get("message"):
371
+ payload["message"] = "Sound played"
372
+ payload.pop("error", None)
373
+ else:
374
+ if not payload.get("error"):
375
+ payload["error"] = payload.get("message") or "Failed to play sound"
376
+ if not payload.get("message"):
377
+ payload["message"] = payload["error"]
378
+ if not payload.get("warnings"):
379
+ payload.pop("warnings", None)
380
+ print('RESULT:' + json.dumps(payload))
99
381
  `.trim();
100
382
  try {
101
- const resp = await this.bridge.executePython(py);
102
- const out = typeof resp === 'string' ? resp : JSON.stringify(resp);
103
- const m = out.match(/RESULT:({.*})/);
104
- if (m) {
105
- try {
106
- const parsed = JSON.parse(m[1]);
107
- return parsed.success ? { success: true, message: 'Sound played' } : { success: false, error: parsed.error };
108
- }
109
- catch { }
110
- }
111
- return { success: true, message: 'Sound play attempted' };
383
+ const resp = await this.bridge.executePythonWithResult(py);
384
+ return this.interpretResult(resp, {
385
+ successMessage: 'Sound played',
386
+ failureMessage: 'Failed to play sound'
387
+ });
112
388
  }
113
389
  catch (e) {
114
390
  return { success: false, error: `Failed to play sound: ${e}` };
@@ -119,57 +395,119 @@ except Exception as e:
119
395
  const volume = params.volume ?? 1.0;
120
396
  const pitch = params.pitch ?? 1.0;
121
397
  const startTime = params.startTime ?? 0.0;
398
+ const soundPath = params.soundPath ?? '';
122
399
  const py = `
123
400
  import unreal
124
401
  import json
125
- path = r"${params.soundPath}"
402
+
403
+ result = {
404
+ "success": False,
405
+ "message": "",
406
+ "error": "",
407
+ "warnings": []
408
+ }
409
+
126
410
  try:
127
- if not unreal.EditorAssetLibrary.does_asset_exist(path):
128
- print('RESULT:' + json.dumps({'success': False, 'error': 'Sound asset not found'}))
129
- else:
130
- snd = unreal.EditorAssetLibrary.load_asset(path)
131
- # Get editor world via EditorSubsystem first to avoid deprecation
132
- try:
133
- world = unreal.EditorSubsystemLibrary.get_editor_world()
134
- except Exception:
135
- try:
136
- world = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_editor_world()
137
- except Exception:
138
- world = unreal.EditorLevelLibrary.get_editor_world()
139
- ok = False
140
- try:
141
- unreal.GameplayStatics.spawn_sound_2d(world, snd, ${volume}, ${pitch}, ${startTime})
142
- ok = True
143
- except AttributeError:
144
- try:
145
- unreal.GameplayStatics.play_sound_2d(world, snd, ${volume}, ${pitch}, ${startTime})
146
- ok = True
147
- except AttributeError:
148
- # Fallback: play at camera location as 2D substitute
149
- try:
150
- info = unreal.EditorLevelLibrary.get_level_viewport_camera_info()
151
- cam_loc = info[0] if isinstance(info, (list, tuple)) and len(info) > 0 else unreal.Vector(0.0, 0.0, 0.0)
152
- except Exception:
153
- cam_loc = unreal.Vector(0.0, 0.0, 0.0)
154
- rot = unreal.Rotator(0.0, 0.0, 0.0)
155
- unreal.GameplayStatics.spawn_sound_at_location(world, snd, cam_loc, rot, ${volume}, ${pitch}, ${startTime})
156
- ok = True
157
- print('RESULT:' + json.dumps({'success': True} if ok else {'success': False, 'error': 'No suitable 2D playback method found'}))
411
+ path = "${escapePythonString(soundPath)}"
412
+ if not unreal.EditorAssetLibrary.does_asset_exist(path):
413
+ result["error"] = "Sound asset not found"
414
+ raise SystemExit(0)
415
+
416
+ snd = unreal.EditorAssetLibrary.load_asset(path)
417
+ if not snd:
418
+ result["error"] = f"Failed to load sound asset: {path}"
419
+ raise SystemExit(0)
420
+
421
+ world = None
422
+ try:
423
+ world = unreal.EditorUtilityLibrary.get_editor_world()
424
+ except Exception:
425
+ world = None
426
+
427
+ if not world:
428
+ editor_subsystem = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
429
+ if editor_subsystem and hasattr(editor_subsystem, 'get_editor_world'):
430
+ world = editor_subsystem.get_editor_world()
431
+
432
+ if not world:
433
+ try:
434
+ world = unreal.EditorSubsystemLibrary.get_editor_world()
435
+ except Exception:
436
+ world = None
437
+
438
+ if not world:
439
+ result["error"] = "Unable to resolve editor world. Start PIE and ensure Editor Scripting Utilities is enabled."
440
+ raise SystemExit(0)
441
+
442
+ ok = False
443
+ try:
444
+ unreal.GameplayStatics.spawn_sound_2d(world, snd, ${volume}, ${pitch}, ${startTime})
445
+ ok = True
446
+ except AttributeError:
447
+ try:
448
+ unreal.GameplayStatics.play_sound_2d(world, snd, ${volume}, ${pitch}, ${startTime})
449
+ ok = True
450
+ except AttributeError:
451
+ pass
452
+
453
+ if not ok:
454
+ cam_loc = unreal.Vector(0.0, 0.0, 0.0)
455
+ try:
456
+ editor_subsystem = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
457
+ if editor_subsystem and hasattr(editor_subsystem, 'get_level_viewport_camera_info'):
458
+ info = editor_subsystem.get_level_viewport_camera_info()
459
+ if isinstance(info, (list, tuple)) and len(info) > 0:
460
+ cam_loc = info[0]
461
+ except Exception:
462
+ try:
463
+ controller = world.get_first_player_controller()
464
+ if controller:
465
+ pawn = controller.get_pawn()
466
+ if pawn:
467
+ cam_loc = pawn.get_actor_location()
468
+ except Exception:
469
+ pass
470
+
471
+ try:
472
+ rot = unreal.Rotator(0.0, 0.0, 0.0)
473
+ unreal.GameplayStatics.spawn_sound_at_location(world, snd, cam_loc, rot, ${volume}, ${pitch}, ${startTime})
474
+ ok = True
475
+ result["warnings"].append("Fell back to 3D playback at camera location")
476
+ except Exception as location_error:
477
+ result["warnings"].append(f"Failed fallback playback: {location_error}")
478
+
479
+ if not ok:
480
+ result["error"] = "Failed to play sound in 2D or fallback configuration"
481
+ raise SystemExit(0)
482
+
483
+ result["success"] = True
484
+ result["message"] = "Sound2D played"
485
+
486
+ except SystemExit:
487
+ pass
158
488
  except Exception as e:
159
- print('RESULT:' + json.dumps({'success': False, 'error': str(e)}))
489
+ result["error"] = str(e)
490
+ finally:
491
+ payload = dict(result)
492
+ if payload.get("success"):
493
+ if not payload.get("message"):
494
+ payload["message"] = "Sound2D played"
495
+ payload.pop("error", None)
496
+ else:
497
+ if not payload.get("error"):
498
+ payload["error"] = payload.get("message") or "Failed to play sound2D"
499
+ if not payload.get("message"):
500
+ payload["message"] = payload["error"]
501
+ if not payload.get("warnings"):
502
+ payload.pop("warnings", None)
503
+ print('RESULT:' + json.dumps(payload))
160
504
  `.trim();
161
505
  try {
162
- const resp = await this.bridge.executePython(py);
163
- const out = typeof resp === 'string' ? resp : JSON.stringify(resp);
164
- const m = out.match(/RESULT:({.*})/);
165
- if (m) {
166
- try {
167
- const parsed = JSON.parse(m[1]);
168
- return parsed.success ? { success: true, message: 'Sound2D played' } : { success: false, error: parsed.error };
169
- }
170
- catch { }
171
- }
172
- return { success: true, message: 'Sound2D play attempted' };
506
+ const resp = await this.bridge.executePythonWithResult(py);
507
+ return this.interpretResult(resp, {
508
+ successMessage: 'Sound2D played',
509
+ failureMessage: 'Failed to play sound2D'
510
+ });
173
511
  }
174
512
  catch (e) {
175
513
  return { success: false, error: `Failed to play sound2D: ${e}` };
@@ -292,9 +630,17 @@ except Exception as e:
292
630
  unreal.AudioMixerBlueprintLibrary.set_overall_volume_multiplier(${vol})
293
631
  print('RESULT:' + json.dumps({'success': True}))
294
632
  except AttributeError:
295
- # Fallback to GameplayStatics method
633
+ # Fallback to GameplayStatics method using modern subsystems
296
634
  try:
297
- world = unreal.EditorLevelLibrary.get_editor_world()
635
+ # Try modern subsystem first
636
+ try:
637
+ editor_subsystem = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
638
+ if hasattr(editor_subsystem, 'get_editor_world'):
639
+ world = editor_subsystem.get_editor_world()
640
+ else:
641
+ world = unreal.EditorLevelLibrary.get_editor_world()
642
+ except Exception:
643
+ world = unreal.EditorLevelLibrary.get_editor_world()
298
644
  unreal.GameplayStatics.set_global_pitch_modulation(world, 1.0, 0.0) # Reset pitch
299
645
  unreal.GameplayStatics.set_global_time_dilation(world, 1.0) # Reset time
300
646
  # Note: There's no direct master volume in GameplayStatics, use sound class