@beremaran/godot-agent-loop 1.0.0

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 (70) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +925 -0
  3. package/addons/godot_agent_loop/LICENSE +23 -0
  4. package/addons/godot_agent_loop/README.md +31 -0
  5. package/addons/godot_agent_loop/plugin.cfg +8 -0
  6. package/addons/godot_agent_loop/plugin.gd +417 -0
  7. package/agent-plugin/.claude-plugin/plugin.json +19 -0
  8. package/agent-plugin/.codex-plugin/plugin.json +37 -0
  9. package/agent-plugin/.mcp.json +14 -0
  10. package/agent-plugin/adapter-manifest.json +45 -0
  11. package/agent-plugin/pi/extension.ts +136 -0
  12. package/agent-plugin/skills/build-godot-game/SKILL.md +41 -0
  13. package/agent-plugin/skills/debug-godot-game/SKILL.md +37 -0
  14. package/agent-plugin/skills/ship-godot-game/SKILL.md +46 -0
  15. package/agent-plugin/skills/ship-godot-game/agents/openai.yaml +4 -0
  16. package/agent-plugin/skills/verify-godot-change/SKILL.md +35 -0
  17. package/build/authoring-session-manager.js +237 -0
  18. package/build/domain-tool-registries.js +207 -0
  19. package/build/editor-bridge-protocol.js +1 -0
  20. package/build/editor-connection.js +112 -0
  21. package/build/editor-mutation-guard.js +30 -0
  22. package/build/editor-plugin-installer.js +220 -0
  23. package/build/engine-api/extension_api-4.7.stable.official.5b4e0cb0f.json +356830 -0
  24. package/build/game-command-service.js +49 -0
  25. package/build/game-connection.js +337 -0
  26. package/build/godot-executable.js +156 -0
  27. package/build/godot-process-manager.js +112 -0
  28. package/build/godot-subprocess.js +23 -0
  29. package/build/headless-operation-runner.js +68 -0
  30. package/build/headless-operation-service.js +65 -0
  31. package/build/index.js +478 -0
  32. package/build/interaction-server-installer.js +234 -0
  33. package/build/opencode-setup.js +199 -0
  34. package/build/project-support.js +219 -0
  35. package/build/runtime-protocol.js +202 -0
  36. package/build/scripts/godot_operations.gd +1534 -0
  37. package/build/scripts/mcp_editor_plugin.gd +417 -0
  38. package/build/scripts/mcp_interaction_server.gd +1255 -0
  39. package/build/scripts/mcp_runtime/audio_animation_domain.gd +568 -0
  40. package/build/scripts/mcp_runtime/command_params.gd +339 -0
  41. package/build/scripts/mcp_runtime/core_domain.gd +591 -0
  42. package/build/scripts/mcp_runtime/input_domain.gd +695 -0
  43. package/build/scripts/mcp_runtime/networking_domain.gd +356 -0
  44. package/build/scripts/mcp_runtime/physics_domain.gd +653 -0
  45. package/build/scripts/mcp_runtime/privileged_command_policy.gd +81 -0
  46. package/build/scripts/mcp_runtime/rendering_domain.gd +807 -0
  47. package/build/scripts/mcp_runtime/runtime_domain.gd +108 -0
  48. package/build/scripts/mcp_runtime/scene_2d_domain.gd +527 -0
  49. package/build/scripts/mcp_runtime/scene_3d_domain.gd +573 -0
  50. package/build/scripts/mcp_runtime/system_domain.gd +277 -0
  51. package/build/scripts/mcp_runtime/ui_domain.gd +619 -0
  52. package/build/scripts/mcp_runtime/variant_codec.gd +335 -0
  53. package/build/scripts/validate_script.gd +28 -0
  54. package/build/server-instructions.js +11 -0
  55. package/build/session-timing.js +20 -0
  56. package/build/tool-argument-validation.js +120 -0
  57. package/build/tool-definitions.js +2727 -0
  58. package/build/tool-handlers/game-tool-handlers.js +1345 -0
  59. package/build/tool-handlers/lifecycle-tool-handlers.js +346 -0
  60. package/build/tool-handlers/project-handler-services.js +1458 -0
  61. package/build/tool-handlers/project-tool-handlers.js +1506 -0
  62. package/build/tool-handlers/visual-regression-service.js +139 -0
  63. package/build/tool-manifest.js +1193 -0
  64. package/build/tool-mutation-policy.js +122 -0
  65. package/build/tool-registry.js +34 -0
  66. package/build/tool-surface.js +70 -0
  67. package/build/utils.js +273 -0
  68. package/package.json +110 -0
  69. package/product.json +52 -0
  70. package/scripts/prepare-package.js +42 -0
@@ -0,0 +1,568 @@
1
+ extends "res://mcp_runtime/runtime_domain.gd"
2
+
3
+ # Audio and animation runtime commands.
4
+
5
+ func register_commands() -> void:
6
+ register_command("get_audio", _cmd_get_audio)
7
+ register_command("audio_play", _cmd_audio_play)
8
+ register_command("audio_bus", _cmd_audio_bus)
9
+ register_command("audio_effect", _cmd_audio_effect)
10
+ register_command("audio_bus_layout", _cmd_audio_bus_layout)
11
+ register_command("audio_spatial", _cmd_audio_spatial)
12
+ register_command("create_animation", _cmd_create_animation)
13
+ register_command("animation_tree", _cmd_animation_tree)
14
+ register_command("animation_control", _cmd_animation_control)
15
+ register_command("skeleton_ik", _cmd_skeleton_ik)
16
+ register_command("bone_pose", _cmd_bone_pose)
17
+
18
+
19
+ func _cmd_get_audio(_params: Dictionary) -> void:
20
+ var buses: Array = []
21
+ for i in AudioServer.bus_count:
22
+ buses.append({
23
+ "name": AudioServer.get_bus_name(i),
24
+ "volume_db": AudioServer.get_bus_volume_db(i),
25
+ "mute": AudioServer.is_bus_mute(i),
26
+ "solo": AudioServer.is_bus_solo(i),
27
+ })
28
+
29
+ var players: Array = []
30
+ _find_audio_players(get_tree().root, players)
31
+
32
+ respond({"success": true, "buses": buses, "players": players})
33
+
34
+
35
+ func _find_audio_players(node: Node, results: Array) -> void:
36
+ if node is AudioStreamPlayer:
37
+ var p: AudioStreamPlayer = node as AudioStreamPlayer
38
+ results.append({"path": str(p.get_path()), "type": "AudioStreamPlayer", "playing": p.playing, "bus": p.bus})
39
+ elif node is AudioStreamPlayer2D:
40
+ var p: AudioStreamPlayer2D = node as AudioStreamPlayer2D
41
+ results.append({"path": str(p.get_path()), "type": "AudioStreamPlayer2D", "playing": p.playing, "bus": p.bus})
42
+ elif node is AudioStreamPlayer3D:
43
+ var p: AudioStreamPlayer3D = node as AudioStreamPlayer3D
44
+ results.append({"path": str(p.get_path()), "type": "AudioStreamPlayer3D", "playing": p.playing, "bus": p.bus})
45
+ for child in node.get_children():
46
+ _find_audio_players(child, results)
47
+
48
+
49
+ # --- Spawn Node ---
50
+
51
+
52
+ func _cmd_audio_play(params: Dictionary) -> void:
53
+ var reader := CommandParams.new(params)
54
+ var node: Node = require_node(reader)
55
+ var action: String = reader.optional_enum("action", "play", ["play", "stop", "pause", "resume"])
56
+ if params_invalid(reader):
57
+ return
58
+ var node_path: String = str(node.get_path())
59
+ if not (node is AudioStreamPlayer or node is AudioStreamPlayer2D or node is AudioStreamPlayer3D):
60
+ reader.fail("node_path must reference an AudioStreamPlayer", {"param": "node_path", "reason": "invalid_value", "value": node_path, "class": node.get_class()})
61
+ send_params_error(reader)
62
+ return
63
+
64
+ # Optionally load a new stream
65
+ if params.has("stream"):
66
+ var stream_path: String = params["stream"]
67
+ var stream: AudioStream = load(stream_path) as AudioStream
68
+ if stream == null:
69
+ respond({"error": "Failed to load audio stream: %s" % stream_path})
70
+ return
71
+ node.set("stream", stream)
72
+
73
+ # Set optional properties
74
+ if params.has("volume"):
75
+ var linear_vol: float = CommandParams.to_float(params["volume"])
76
+ node.set("volume_db", linear_to_db(clampf(linear_vol, 0.0, 1.0)))
77
+ if params.has("pitch"):
78
+ node.set("pitch_scale", CommandParams.to_float(params["pitch"]))
79
+ if params.has("bus"):
80
+ node.set("bus", params["bus"])
81
+
82
+ match action:
83
+ "play":
84
+ var from_pos: float = CommandParams.json_float(params, "from_position", 0.0)
85
+ node.call("play", from_pos)
86
+ respond({"success": true, "action": "play", "node_path": node_path})
87
+ "stop":
88
+ node.call("stop")
89
+ respond({"success": true, "action": "stop", "node_path": node_path})
90
+ "pause":
91
+ node.set("stream_paused", true)
92
+ respond({"success": true, "action": "pause", "node_path": node_path})
93
+ "resume":
94
+ node.set("stream_paused", false)
95
+ respond({"success": true, "action": "resume", "node_path": node_path})
96
+ _:
97
+ respond({"error": "Unknown audio action: %s. Use play, stop, pause, or resume" % action})
98
+
99
+
100
+ # --- Audio Bus ---
101
+
102
+
103
+ func _cmd_audio_bus(params: Dictionary) -> void:
104
+ var bus_name: String = params.get("bus_name", "Master")
105
+ var bus_idx: int = AudioServer.get_bus_index(bus_name)
106
+ if bus_idx == -1:
107
+ respond({"error": "Audio bus not found: %s" % bus_name})
108
+ return
109
+
110
+ if params.has("volume"):
111
+ var linear_vol: float = CommandParams.to_float(params["volume"])
112
+ AudioServer.set_bus_volume_db(bus_idx, linear_to_db(clampf(linear_vol, 0.0, 1.0)))
113
+ if params.has("mute"):
114
+ AudioServer.set_bus_mute(bus_idx, CommandParams.to_bool(params["mute"]))
115
+ if params.has("solo"):
116
+ AudioServer.set_bus_solo(bus_idx, CommandParams.to_bool(params["solo"]))
117
+
118
+ respond({
119
+ "success": true,
120
+ "bus_name": bus_name,
121
+ "volume_db": AudioServer.get_bus_volume_db(bus_idx),
122
+ "mute": AudioServer.is_bus_mute(bus_idx),
123
+ "solo": AudioServer.is_bus_solo(bus_idx)
124
+ })
125
+
126
+
127
+ # --- Environment / Post-Processing ---
128
+
129
+
130
+ func _cmd_create_animation(params: Dictionary) -> void:
131
+ var node_path: String = params.get("node_path", "")
132
+ var anim_name: String = params.get("animation_name", "")
133
+ if node_path.is_empty() or anim_name.is_empty():
134
+ respond({"error": "node_path and animation_name are required"})
135
+ return
136
+
137
+ var node: Node = get_tree().root.get_node_or_null(node_path)
138
+ if node == null:
139
+ respond({"error": "Node not found: %s" % node_path})
140
+ return
141
+
142
+ if not node is AnimationPlayer:
143
+ respond({"error": "Node is not an AnimationPlayer: %s (is %s)" % [node_path, node.get_class()]})
144
+ return
145
+
146
+ var anim_player: AnimationPlayer = node as AnimationPlayer
147
+ var anim: Animation = Animation.new()
148
+ anim.length = CommandParams.json_float(params, "length", 1.0)
149
+ var loop_mode: int = CommandParams.json_int(params, "loop_mode", 0)
150
+ anim.loop_mode = loop_mode as Animation.LoopMode
151
+
152
+ var tracks: Array = CommandParams.json_array(params, "tracks")
153
+ var track_count: int = 0
154
+ for track_entry: Variant in tracks:
155
+ var track_data: Dictionary = CommandParams.as_dictionary(track_entry)
156
+ var track_type_str: String = CommandParams.json_string(track_data, "type", "value")
157
+ var track_path: String = CommandParams.json_string(track_data, "path")
158
+ if track_path.is_empty():
159
+ continue
160
+
161
+ var track_type: int = Animation.TYPE_VALUE
162
+ match track_type_str:
163
+ "value":
164
+ track_type = Animation.TYPE_VALUE
165
+ "method":
166
+ track_type = Animation.TYPE_METHOD
167
+ "bezier":
168
+ track_type = Animation.TYPE_BEZIER
169
+ "audio":
170
+ track_type = Animation.TYPE_AUDIO
171
+
172
+ var idx: int = anim.add_track(track_type)
173
+ anim.track_set_path(idx, NodePath(track_path))
174
+
175
+ var keys: Array = CommandParams.json_array(track_data, "keys")
176
+ for key_entry: Variant in keys:
177
+ var key_data: Dictionary = CommandParams.as_dictionary(key_entry)
178
+ var time: float = CommandParams.json_float(key_data, "time", 0.0)
179
+ match track_type:
180
+ Animation.TYPE_VALUE:
181
+ var value: Variant = json_to_variant(key_data.get("value"), CommandParams.json_string(key_data, "type_hint"))
182
+ @warning_ignore("return_value_discarded")
183
+ anim.track_insert_key(idx, time, value)
184
+ if key_data.has("transition"):
185
+ var key_idx: int = anim.track_find_key(idx, time, Animation.FIND_MODE_APPROX)
186
+ if key_idx >= 0:
187
+ anim.track_set_key_transition(idx, key_idx, CommandParams.to_float(key_data["transition"]))
188
+ Animation.TYPE_METHOD:
189
+ var method_name: String = CommandParams.json_string(key_data, "method")
190
+ var args: Array = CommandParams.json_array(key_data, "args")
191
+ @warning_ignore("return_value_discarded")
192
+ anim.track_insert_key(idx, time, {"method": method_name, "args": args})
193
+ Animation.TYPE_BEZIER:
194
+ var value: float = CommandParams.json_float(key_data, "value", 0.0)
195
+ @warning_ignore("return_value_discarded")
196
+ anim.bezier_track_insert_key(idx, time, value)
197
+ Animation.TYPE_AUDIO:
198
+ var stream_path: String = CommandParams.json_string(key_data, "stream")
199
+ if not stream_path.is_empty():
200
+ var stream: AudioStream = load(stream_path) as AudioStream
201
+ if stream != null:
202
+ @warning_ignore("return_value_discarded")
203
+ anim.audio_track_insert_key(idx, time, stream)
204
+ track_count += 1
205
+
206
+ # Add to library (use default "" library if it exists, otherwise create it)
207
+ var lib_name: String = CommandParams.json_string(params, "library")
208
+ var lib: AnimationLibrary = null
209
+ if anim_player.has_animation_library(lib_name):
210
+ lib = anim_player.get_animation_library(lib_name)
211
+ else:
212
+ lib = AnimationLibrary.new()
213
+ @warning_ignore("return_value_discarded")
214
+ anim_player.add_animation_library(lib_name, lib)
215
+ if lib.has_animation(anim_name):
216
+ respond({"error": "Animation already exists: %s" % anim_name})
217
+ return
218
+ @warning_ignore("return_value_discarded")
219
+ lib.add_animation(anim_name, anim)
220
+
221
+ respond({"success": true, "animation_name": anim_name, "length": anim.length, "loop_mode": loop_mode, "track_count": track_count})
222
+
223
+
224
+ # --- Serialize State ---
225
+
226
+
227
+ func _cmd_bone_pose(params: Dictionary) -> void:
228
+ var reader := CommandParams.new(params)
229
+ var node: Node = require_node(reader)
230
+ var action: String = reader.optional_enum("action", "list", ["list", "get", "set"])
231
+ if params_invalid(reader):
232
+ return
233
+ var node_path: String = str(node.get_path())
234
+ if not node is Skeleton3D:
235
+ reader.fail("node_path must reference a Skeleton3D", {"param": "node_path", "reason": "invalid_value", "value": node_path, "class": node.get_class()})
236
+ send_params_error(reader)
237
+ return
238
+
239
+ var skel: Skeleton3D = node as Skeleton3D
240
+
241
+ match action:
242
+ "list":
243
+ var bones: Array = []
244
+ for i in skel.get_bone_count():
245
+ bones.append({"index": i, "name": skel.get_bone_name(i), "parent": skel.get_bone_parent(i)})
246
+ respond({"success": true, "action": "list", "bone_count": skel.get_bone_count(), "bones": bones})
247
+ "get":
248
+ var bone_idx: int = _resolve_bone_index(skel, params)
249
+ if bone_idx < 0:
250
+ respond({"error": "Bone not found"})
251
+ return
252
+ respond({
253
+ "success": true, "action": "get", "bone_index": bone_idx,
254
+ "bone_name": skel.get_bone_name(bone_idx),
255
+ "position": variant_to_json(skel.get_bone_pose_position(bone_idx)),
256
+ "rotation": variant_to_json(skel.get_bone_pose_rotation(bone_idx)),
257
+ "scale": variant_to_json(skel.get_bone_pose_scale(bone_idx))
258
+ })
259
+ "set":
260
+ var bone_idx: int = _resolve_bone_index(skel, params)
261
+ if bone_idx < 0:
262
+ respond({"error": "Bone not found"})
263
+ return
264
+ if params.has("position"):
265
+ var p: Dictionary = params["position"]
266
+ skel.set_bone_pose_position(bone_idx, Vector3(CommandParams.json_float(p, "x", 0), CommandParams.json_float(p, "y", 0), CommandParams.json_float(p, "z", 0)))
267
+ if params.has("rotation"):
268
+ var r: Dictionary = params["rotation"]
269
+ skel.set_bone_pose_rotation(bone_idx, Quaternion(CommandParams.json_float(r, "x", 0), CommandParams.json_float(r, "y", 0), CommandParams.json_float(r, "z", 0), CommandParams.json_float(r, "w", 1)))
270
+ if params.has("scale"):
271
+ var s: Dictionary = params["scale"]
272
+ skel.set_bone_pose_scale(bone_idx, Vector3(CommandParams.json_float(s, "x", 1), CommandParams.json_float(s, "y", 1), CommandParams.json_float(s, "z", 1)))
273
+ respond({"success": true, "action": "set", "bone_index": bone_idx, "bone_name": skel.get_bone_name(bone_idx)})
274
+ _:
275
+ respond({"error": "Unknown bone action: %s. Use list, get, or set" % action})
276
+
277
+
278
+ # An AnimationTree only exposes a playback object when its root is a state
279
+ # machine; every other root reports the parameter as null.
280
+ func _state_machine_playback(tree: AnimationTree) -> AnimationNodeStateMachinePlayback:
281
+ var playback: Variant = tree.get("parameters/playback")
282
+ if playback is AnimationNodeStateMachinePlayback:
283
+ return playback
284
+ return null
285
+
286
+
287
+ func _resolve_bone_index(skel: Skeleton3D, params: Dictionary) -> int:
288
+ if params.has("bone_index"):
289
+ return CommandParams.to_int(params["bone_index"])
290
+ if params.has("bone_name"):
291
+ return skel.find_bone(CommandParams.json_string(params, "bone_name"))
292
+ return -1
293
+
294
+
295
+ # --- Viewport ---
296
+ # ==========================================================================
297
+ # Batch 1: Networking + Input + System + Signals + Script
298
+ # ==========================================================================
299
+
300
+
301
+ func _cmd_animation_tree(params: Dictionary) -> void:
302
+ var reader := CommandParams.new(params)
303
+ var node: Node = require_node(reader)
304
+ var action: String = reader.optional_enum("action", "get_state", ["travel", "set_param", "get_state"])
305
+ if params_invalid(reader):
306
+ return
307
+ if not node is AnimationTree:
308
+ reader.fail("node_path must reference an AnimationTree", {"param": "node_path", "reason": "invalid_value", "class": node.get_class()})
309
+ send_params_error(reader)
310
+ return
311
+ var tree: AnimationTree = node as AnimationTree
312
+ match action:
313
+ "travel":
314
+ var state_name: String = CommandParams.json_string(params, "state_name")
315
+ var playback: AnimationNodeStateMachinePlayback = _state_machine_playback(tree)
316
+ if playback != null:
317
+ playback.travel(state_name)
318
+ respond({"success": true, "action": "travel", "state": state_name})
319
+ "set_param":
320
+ var param_name: String = CommandParams.json_string(params, "param_name")
321
+ var param_value: Variant = params.get("param_value", 0)
322
+ tree.set("parameters/" + param_name, param_value)
323
+ respond({"success": true, "action": "set_param", "param": param_name})
324
+ "get_state":
325
+ var playback: AnimationNodeStateMachinePlayback = _state_machine_playback(tree)
326
+ var current: String = ""
327
+ if playback != null:
328
+ current = playback.get_current_node()
329
+ respond({"success": true, "action": "get_state", "current": current})
330
+ _:
331
+ respond({"error": "Unknown animation_tree action: %s" % action})
332
+
333
+
334
+ func _cmd_animation_control(params: Dictionary) -> void:
335
+ var reader := CommandParams.new(params)
336
+ var node: Node = require_node(reader)
337
+ var action: String = reader.optional_enum("action", "get_info", ["seek", "queue", "set_speed", "stop", "get_info"])
338
+ if params_invalid(reader):
339
+ return
340
+ if not node is AnimationPlayer:
341
+ reader.fail("node_path must reference an AnimationPlayer", {"param": "node_path", "reason": "invalid_value", "class": node.get_class()})
342
+ send_params_error(reader)
343
+ return
344
+ var player: AnimationPlayer = node as AnimationPlayer
345
+ match action:
346
+ "seek":
347
+ if player.current_animation.is_empty():
348
+ reader.fail("Cannot seek without a current animation", {"param": "node_path", "reason": "invalid_state"})
349
+ var max_position: float = player.current_animation_length if not player.current_animation.is_empty() else INF
350
+ var pos: float = reader.required_number("position", 0.0, max_position)
351
+ if params_invalid(reader):
352
+ return
353
+ player.seek(pos)
354
+ respond({"success": true, "action": "seek", "position": pos})
355
+ "queue":
356
+ var anim: String = reader.required_string("animation_name")
357
+ if not player.has_animation(anim):
358
+ reader.fail("Animation not found: %s" % anim, {"param": "animation_name", "reason": "not_found", "value": anim})
359
+ if params_invalid(reader):
360
+ return
361
+ player.queue(anim)
362
+ respond({"success": true, "action": "queue", "animation": anim})
363
+ "set_speed":
364
+ player.speed_scale = reader.required_number("speed")
365
+ if params_invalid(reader):
366
+ return
367
+ respond({"success": true, "action": "set_speed", "speed": player.speed_scale})
368
+ "stop":
369
+ player.stop()
370
+ respond({"success": true, "action": "stop"})
371
+ "get_info":
372
+ var anims: PackedStringArray = player.get_animation_list()
373
+ respond({"success": true, "action": "get_info", "current": player.current_animation, "playing": player.is_playing(), "animations": Array(anims), "queued": Array(player.get_queue()), "speed_scale": player.speed_scale, "position": player.current_animation_position})
374
+ _:
375
+ respond({"error": "Unknown animation_control action: %s" % action})
376
+
377
+
378
+ func _cmd_skeleton_ik(params: Dictionary) -> void:
379
+ var reader := CommandParams.new(params)
380
+ var node: Node = require_node(reader)
381
+ var action: String = reader.optional_enum("action", "start", ["start", "stop", "set_target"])
382
+ if params_invalid(reader):
383
+ return
384
+ if not node is SkeletonIK3D:
385
+ reader.fail("node_path must reference a SkeletonIK3D", {"param": "node_path", "reason": "invalid_value", "class": node.get_class()})
386
+ send_params_error(reader)
387
+ return
388
+ var ik: SkeletonIK3D = node as SkeletonIK3D
389
+ match action:
390
+ "start":
391
+ ik.start()
392
+ respond({"success": true, "action": "start"})
393
+ "stop":
394
+ ik.stop()
395
+ respond({"success": true, "action": "stop"})
396
+ "set_target":
397
+ var t: Dictionary = reader.required_dictionary("target")
398
+ if params_invalid(reader):
399
+ return
400
+ var target_tf: Transform3D = Transform3D.IDENTITY
401
+ target_tf.origin = Vector3(CommandParams.json_float(t, "x", 0), CommandParams.json_float(t, "y", 0), CommandParams.json_float(t, "z", 0))
402
+ ik.target = target_tf
403
+ respond({"success": true, "action": "set_target"})
404
+ _:
405
+ respond({"error": "Unknown skeleton_ik action: %s" % action})
406
+
407
+
408
+ func _cmd_audio_effect(params: Dictionary) -> void:
409
+ var reader := CommandParams.new(params)
410
+ var bus_name: String = params.get("bus_name", "Master")
411
+ var action: String = reader.optional_enum("action", "list", ["list", "add", "remove", "configure"])
412
+ if params_invalid(reader):
413
+ return
414
+ var bus_idx: int = AudioServer.get_bus_index(bus_name)
415
+ if bus_idx < 0:
416
+ respond({"error": "Audio bus not found: %s" % bus_name})
417
+ return
418
+ match action:
419
+ "list":
420
+ var effects: Array = []
421
+ for i in AudioServer.get_bus_effect_count(bus_idx):
422
+ var eff: AudioEffect = AudioServer.get_bus_effect(bus_idx, i)
423
+ effects.append({"index": i, "type": eff.get_class(), "enabled": AudioServer.is_bus_effect_enabled(bus_idx, i)})
424
+ respond({"success": true, "action": "list", "bus": bus_name, "effects": effects})
425
+ "add":
426
+ var effect_type: String = params.get("effect_type", "reverb")
427
+ var effect: AudioEffect
428
+ match effect_type:
429
+ "reverb": effect = AudioEffectReverb.new()
430
+ "delay": effect = AudioEffectDelay.new()
431
+ "chorus": effect = AudioEffectChorus.new()
432
+ "eq": effect = AudioEffectEQ6.new()
433
+ "compressor": effect = AudioEffectCompressor.new()
434
+ "limiter": effect = AudioEffectLimiter.new()
435
+ _:
436
+ respond({"error": "Unknown effect type: %s" % effect_type})
437
+ return
438
+ AudioServer.add_bus_effect(bus_idx, effect)
439
+ respond({"success": true, "action": "add", "effect_type": effect_type, "index": AudioServer.get_bus_effect_count(bus_idx) - 1})
440
+ "remove":
441
+ var idx: int = CommandParams.json_int(params, "index", 0)
442
+ if idx < 0 or idx >= AudioServer.get_bus_effect_count(bus_idx):
443
+ respond({"error": "Effect index out of range: %d" % idx})
444
+ return
445
+ AudioServer.remove_bus_effect(bus_idx, idx)
446
+ respond({"success": true, "action": "remove", "index": idx})
447
+ "configure":
448
+ var idx: int = CommandParams.json_int(params, "index", 0)
449
+ if idx < 0 or idx >= AudioServer.get_bus_effect_count(bus_idx):
450
+ respond({"error": "Effect index out of range: %d" % idx})
451
+ return
452
+ var eff: AudioEffect = AudioServer.get_bus_effect(bus_idx, idx)
453
+ var applied: Array = []
454
+ var props: Dictionary = CommandParams.json_dictionary(params, "properties")
455
+ for key: Variant in props:
456
+ eff.set(StringName(str(key)), props[key])
457
+ applied.append(str(key))
458
+ if params.has("enabled"):
459
+ AudioServer.set_bus_effect_enabled(bus_idx, idx, CommandParams.to_bool(params["enabled"]))
460
+ applied.append("enabled")
461
+ respond({"success": true, "action": "configure", "index": idx, "applied": applied})
462
+ _:
463
+ respond({"error": "Unknown audio_effect action: %s" % action})
464
+
465
+
466
+ func _cmd_audio_bus_layout(params: Dictionary) -> void:
467
+ var reader := CommandParams.new(params)
468
+ var action: String = reader.optional_enum("action", "list", ["list", "add", "remove", "move", "set_send"])
469
+ if params_invalid(reader):
470
+ return
471
+ match action:
472
+ "list":
473
+ var buses: Array = []
474
+ for i in AudioServer.bus_count:
475
+ buses.append({"index": i, "name": AudioServer.get_bus_name(i), "volume": AudioServer.get_bus_volume_db(i), "mute": AudioServer.is_bus_mute(i), "solo": AudioServer.is_bus_solo(i), "send": AudioServer.get_bus_send(i), "effect_count": AudioServer.get_bus_effect_count(i)})
476
+ respond({"success": true, "action": "list", "buses": buses})
477
+ "add":
478
+ var bus_name: String = reader.required_string("bus_name")
479
+ if bus_name.is_empty():
480
+ reader.fail("bus_name must not be empty", {"param": "bus_name", "reason": "invalid_value"})
481
+ if AudioServer.get_bus_index(bus_name) >= 0:
482
+ reader.fail("Audio bus already exists: %s" % bus_name, {"param": "bus_name", "reason": "already_exists", "value": bus_name})
483
+ if params_invalid(reader):
484
+ return
485
+ AudioServer.add_bus()
486
+ var idx: int = AudioServer.bus_count - 1
487
+ AudioServer.set_bus_name(idx, bus_name)
488
+ respond({"success": true, "action": "add", "bus_name": bus_name, "index": idx})
489
+ "remove":
490
+ var bus_name: String = reader.required_string("bus_name")
491
+ var idx: int = AudioServer.get_bus_index(bus_name)
492
+ if idx <= 0:
493
+ reader.fail("Cannot remove bus: %s" % bus_name, {"param": "bus_name", "reason": "not_found_or_protected", "value": bus_name})
494
+ send_params_error(reader)
495
+ return
496
+ AudioServer.remove_bus(idx)
497
+ respond({"success": true, "action": "remove", "bus_name": bus_name})
498
+ "move":
499
+ var bus_name: String = reader.required_string("bus_name")
500
+ var from_index: int = AudioServer.get_bus_index(bus_name)
501
+ var to_index: int = reader.required_int("index", 1, AudioServer.bus_count - 1)
502
+ if from_index <= 0:
503
+ reader.fail("Cannot move bus: %s" % bus_name, {"param": "bus_name", "reason": "not_found_or_protected", "value": bus_name})
504
+ if params_invalid(reader):
505
+ return
506
+ AudioServer.move_bus(from_index, to_index)
507
+ respond({"success": true, "action": "move", "bus_name": bus_name, "index": AudioServer.get_bus_index(bus_name)})
508
+ "set_send":
509
+ var bus_name: String = reader.required_string("bus_name")
510
+ var send_to: String = reader.required_string("send_to")
511
+ var idx: int = AudioServer.get_bus_index(bus_name)
512
+ if idx <= 0:
513
+ reader.fail("Bus not found or protected: %s" % bus_name, {"param": "bus_name", "reason": "not_found_or_protected", "value": bus_name})
514
+ if AudioServer.get_bus_index(send_to) < 0:
515
+ reader.fail("Send target not found: %s" % send_to, {"param": "send_to", "reason": "not_found", "value": send_to})
516
+ if bus_name == send_to:
517
+ reader.fail("An audio bus cannot send to itself", {"param": "send_to", "reason": "invalid_value", "value": send_to})
518
+ if params_invalid(reader):
519
+ return
520
+ AudioServer.set_bus_send(idx, send_to)
521
+ respond({"success": true, "action": "set_send", "bus": bus_name, "send_to": send_to})
522
+ _:
523
+ respond({"error": "Unknown audio_bus_layout action: %s" % action})
524
+
525
+
526
+ func _cmd_audio_spatial(params: Dictionary) -> void:
527
+ var reader := CommandParams.new(params)
528
+ var node: Node = require_node(reader)
529
+ var action: String = reader.optional_enum("action", "get_info", ["get_info", "configure"])
530
+ if params_invalid(reader):
531
+ return
532
+ if not node is AudioStreamPlayer3D:
533
+ reader.fail("node_path must reference an AudioStreamPlayer3D", {"param": "node_path", "reason": "invalid_value", "class": node.get_class()})
534
+ send_params_error(reader)
535
+ return
536
+ var player: AudioStreamPlayer3D = node as AudioStreamPlayer3D
537
+ if action == "get_info":
538
+ respond({"success": true, "max_distance": player.max_distance, "unit_size": player.unit_size, "max_db": player.max_db, "attenuation_model": _attenuation_model_name(player.attenuation_model), "playing": player.playing})
539
+ return
540
+ if params.has("max_distance"):
541
+ player.max_distance = CommandParams.to_float(params["max_distance"])
542
+ if params.has("unit_size"):
543
+ player.unit_size = CommandParams.to_float(params["unit_size"])
544
+ if params.has("max_db"):
545
+ player.max_db = CommandParams.to_float(params["max_db"])
546
+ if params.has("attenuation_model"):
547
+ var attenuation_model: String = CommandParams.json_string(params, "attenuation_model")
548
+ match attenuation_model:
549
+ "inverse": player.attenuation_model = AudioStreamPlayer3D.ATTENUATION_INVERSE_DISTANCE
550
+ "inverse_square": player.attenuation_model = AudioStreamPlayer3D.ATTENUATION_INVERSE_SQUARE_DISTANCE
551
+ "logarithmic": player.attenuation_model = AudioStreamPlayer3D.ATTENUATION_LOGARITHMIC
552
+ _:
553
+ respond({"error": "Unknown attenuation model: %s" % attenuation_model})
554
+ return
555
+ respond({"success": true, "action": "configure"})
556
+
557
+
558
+ func _attenuation_model_name(model: AudioStreamPlayer3D.AttenuationModel) -> String:
559
+ match model:
560
+ AudioStreamPlayer3D.ATTENUATION_INVERSE_SQUARE_DISTANCE: return "inverse_square"
561
+ AudioStreamPlayer3D.ATTENUATION_LOGARITHMIC: return "logarithmic"
562
+ AudioStreamPlayer3D.ATTENUATION_DISABLED: return "disabled"
563
+ _: return "inverse"
564
+
565
+
566
+ # ==========================================================================
567
+ # Batch 4: Locale (runtime)
568
+ # ==========================================================================