@tool extends "res://addons/godot_mcp/commands/base_command.gd" func get_commands() -> Dictionary: return { "create_particles": _create_particles, "set_particle_material": _set_particle_material, "set_particle_color_gradient": _set_particle_color_gradient, "apply_particle_preset": _apply_particle_preset, "get_particle_info": _get_particle_info, } func _get_particles_node(node_path: String) -> GPUParticles2D: # Returns any GPUParticles2D or GPUParticles3D (both share similar API) var node := find_node_by_path(node_path) if node is GPUParticles2D: return node as GPUParticles2D return null func _get_particles_node_any(node_path: String) -> Node: var node := find_node_by_path(node_path) if node is GPUParticles2D or node is GPUParticles3D: return node return null func _parse_color(color_str: String) -> Color: # Support hex "#RRGGBB", "#RRGGBBAA", or named colors if color_str.begins_with("#"): return Color.html(color_str) # Try named color match color_str.to_lower(): "red": return Color.RED "green": return Color.GREEN "blue": return Color.BLUE "white": return Color.WHITE "black": return Color.BLACK "yellow": return Color.YELLOW "orange": return Color(1.0, 0.5, 0.0) "gray", "grey": return Color.GRAY "cyan": return Color.CYAN "magenta": return Color.MAGENTA "transparent": return Color(0, 0, 0, 0) # Try Expression parser for Color(r,g,b,a) var expr := Expression.new() if expr.parse(color_str) == OK: var parsed = expr.execute() if parsed is Color: return parsed return Color.WHITE func _create_particles(params: Dictionary) -> Dictionary: var result := require_string(params, "parent_path") if result[1] != null: return result[1] var parent_path: String = result[0] var root := get_edited_root() if root == null: return error_no_scene() var parent := find_node_by_path(parent_path) if parent == null: return error_not_found("Node at '%s'" % parent_path) var node_name: String = optional_string(params, "name", "Particles") var is_3d: bool = optional_bool(params, "is_3d", false) var amount: int = optional_int(params, "amount", 16) var lifetime: float = float(params.get("lifetime", 1.0)) var one_shot: bool = optional_bool(params, "one_shot", false) var explosiveness: float = float(params.get("explosiveness", 0.0)) var randomness: float = float(params.get("randomness", 0.0)) var emitting: bool = optional_bool(params, "emitting", true) var particles_node: Node if is_3d: var p := GPUParticles3D.new() p.name = node_name p.amount = amount p.lifetime = lifetime p.one_shot = one_shot p.explosiveness = explosiveness p.randomness = randomness p.emitting = emitting var mat := ParticleProcessMaterial.new() p.process_material = mat particles_node = p else: var p := GPUParticles2D.new() p.name = node_name p.amount = amount p.lifetime = lifetime p.one_shot = one_shot p.explosiveness = explosiveness p.randomness = randomness p.emitting = emitting var mat := ParticleProcessMaterial.new() p.process_material = mat particles_node = p add_child_with_undo(parent, particles_node, root, "MCP: Create particles") return success({ "name": particles_node.name, "parent": parent_path, "is_3d": is_3d, "amount": amount, "lifetime": lifetime, "one_shot": one_shot, "created": true, }) func _set_particle_material(params: Dictionary) -> Dictionary: var result := require_string(params, "node_path") if result[1] != null: return result[1] var node_path: String = result[0] var node := _get_particles_node_any(node_path) if node == null: return error_not_found("GPUParticles2D/3D at '%s'" % node_path) var old_mat: ParticleProcessMaterial = node.get("process_material") var mat: ParticleProcessMaterial if old_mat != null: mat = old_mat.duplicate(true) as ParticleProcessMaterial else: mat = ParticleProcessMaterial.new() var changes: Array = [] # Direction if params.has("direction"): var dir = params["direction"] if dir is Dictionary: mat.direction = Vector3(float(dir.get("x", 0)), float(dir.get("y", 0)), float(dir.get("z", 0))) changes.append("direction") elif dir is String: var expr := Expression.new() if expr.parse(dir) == OK: var parsed = expr.execute() if parsed is Vector3: mat.direction = parsed changes.append("direction") # Spread if params.has("spread"): mat.spread = float(params["spread"]) changes.append("spread") # Initial velocity if params.has("initial_velocity_min"): mat.initial_velocity_min = float(params["initial_velocity_min"]) changes.append("initial_velocity_min") if params.has("initial_velocity_max"): mat.initial_velocity_max = float(params["initial_velocity_max"]) changes.append("initial_velocity_max") # Gravity if params.has("gravity"): var grav = params["gravity"] if grav is Dictionary: mat.gravity = Vector3(float(grav.get("x", 0)), float(grav.get("y", 0)), float(grav.get("z", 0))) changes.append("gravity") elif grav is String: var expr := Expression.new() if expr.parse(grav) == OK: var parsed = expr.execute() if parsed is Vector3: mat.gravity = parsed changes.append("gravity") # Scale if params.has("scale_min"): mat.scale_min = float(params["scale_min"]) changes.append("scale_min") if params.has("scale_max"): mat.scale_max = float(params["scale_max"]) changes.append("scale_max") # Color if params.has("color"): mat.color = _parse_color(str(params["color"])) changes.append("color") # Emission shape if params.has("emission_shape"): var shape_str: String = str(params["emission_shape"]).to_lower() match shape_str: "point": mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_POINT "sphere": mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE if params.has("emission_sphere_radius"): mat.emission_sphere_radius = float(params["emission_sphere_radius"]) "sphere_surface": mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE if params.has("emission_sphere_radius"): mat.emission_sphere_radius = float(params["emission_sphere_radius"]) "box": mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX if params.has("emission_box_extents"): var ext = params["emission_box_extents"] if ext is Dictionary: mat.emission_box_extents = Vector3(float(ext.get("x", 1)), float(ext.get("y", 1)), float(ext.get("z", 1))) "ring": mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_RING if params.has("emission_ring_radius"): mat.emission_ring_radius = float(params["emission_ring_radius"]) if params.has("emission_ring_inner_radius"): mat.emission_ring_inner_radius = float(params["emission_ring_inner_radius"]) if params.has("emission_ring_height"): mat.emission_ring_height = float(params["emission_ring_height"]) changes.append("emission_shape") # Angular velocity if params.has("angular_velocity_min"): mat.angular_velocity_min = float(params["angular_velocity_min"]) changes.append("angular_velocity_min") if params.has("angular_velocity_max"): mat.angular_velocity_max = float(params["angular_velocity_max"]) changes.append("angular_velocity_max") # Orbit velocity if params.has("orbit_velocity_min"): mat.orbit_velocity_min = float(params["orbit_velocity_min"]) changes.append("orbit_velocity_min") if params.has("orbit_velocity_max"): mat.orbit_velocity_max = float(params["orbit_velocity_max"]) changes.append("orbit_velocity_max") # Damping if params.has("damping_min"): mat.damping_min = float(params["damping_min"]) changes.append("damping_min") if params.has("damping_max"): mat.damping_max = float(params["damping_max"]) changes.append("damping_max") # Attractor interaction if params.has("attractor_interaction_enabled"): mat.attractor_interaction_enabled = bool(params["attractor_interaction_enabled"]) changes.append("attractor_interaction_enabled") if not changes.is_empty(): set_property_with_undo(node, "process_material", mat, "MCP: Set particle material") return success({"node_path": node_path, "changes": changes}) func _set_particle_color_gradient(params: Dictionary) -> Dictionary: var result := require_string(params, "node_path") if result[1] != null: return result[1] var node_path: String = result[0] var node := _get_particles_node_any(node_path) if node == null: return error_not_found("GPUParticles2D/3D at '%s'" % node_path) var old_mat: ParticleProcessMaterial = node.get("process_material") var mat: ParticleProcessMaterial if old_mat != null: mat = old_mat.duplicate(true) as ParticleProcessMaterial else: mat = ParticleProcessMaterial.new() if not params.has("stops") or not params["stops"] is Array: return error_invalid_params("Missing required parameter: stops (array of {offset, color})") var stops: Array = params["stops"] if stops.is_empty(): return error_invalid_params("stops array must not be empty") var gradient := Gradient.new() # Remove default points while gradient.get_point_count() > 0: gradient.remove_point(0) for stop in stops: if stop is Dictionary: var offset: float = float(stop.get("offset", 0.0)) var color: Color = _parse_color(str(stop.get("color", "#ffffff"))) gradient.add_point(offset, color) var grad_tex := GradientTexture1D.new() grad_tex.gradient = gradient mat.color_ramp = grad_tex set_property_with_undo(node, "process_material", mat, "MCP: Set particle color gradient") return success({"node_path": node_path, "stops_count": gradient.get_point_count()}) func _apply_particle_preset(params: Dictionary) -> Dictionary: var result := require_string(params, "node_path") if result[1] != null: return result[1] var node_path: String = result[0] var result2 := require_string(params, "preset") if result2[1] != null: return result2[1] var preset: String = result2[0].to_lower() var node := _get_particles_node_any(node_path) if node == null: return error_not_found("GPUParticles2D/3D at '%s'" % node_path) var old_state := _capture_particle_state(node) var preset_state := {} var mat := ParticleProcessMaterial.new() var is_2d: bool = node is GPUParticles2D # Default gravity for 2D (Y-down) vs 3D (Y-down) var gravity_down := Vector3(0, 98.0 if is_2d else 9.8, 0) var _gravity_up := Vector3(0, -98.0 if is_2d else -9.8, 0) var gravity_none := Vector3.ZERO match preset: "explosion": preset_state["amount"] = 32 preset_state["lifetime"] = 0.6 preset_state["one_shot"] = true preset_state["explosiveness"] = 1.0 mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0) mat.spread = 180.0 mat.initial_velocity_min = 100.0 if is_2d else 5.0 mat.initial_velocity_max = 200.0 if is_2d else 10.0 mat.gravity = gravity_down * 0.5 mat.damping_min = 2.0 mat.damping_max = 4.0 mat.scale_min = 0.5 mat.scale_max = 1.5 mat.color = Color(1.0, 0.6, 0.1) _apply_gradient(mat, [ {"offset": 0.0, "color": Color.WHITE}, {"offset": 0.3, "color": Color(1.0, 0.8, 0.2)}, {"offset": 0.7, "color": Color(1.0, 0.3, 0.0)}, {"offset": 1.0, "color": Color(0.2, 0.0, 0.0, 0.0)}, ]) "fire": preset_state["amount"] = 24 preset_state["lifetime"] = 1.2 preset_state["one_shot"] = false preset_state["explosiveness"] = 0.0 mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0) mat.spread = 15.0 mat.initial_velocity_min = 30.0 if is_2d else 1.5 mat.initial_velocity_max = 60.0 if is_2d else 3.0 mat.gravity = gravity_none mat.scale_min = 0.8 mat.scale_max = 1.5 _apply_gradient(mat, [ {"offset": 0.0, "color": Color(1.0, 1.0, 0.5)}, {"offset": 0.3, "color": Color(1.0, 0.6, 0.0)}, {"offset": 0.7, "color": Color(0.8, 0.2, 0.0)}, {"offset": 1.0, "color": Color(0.2, 0.0, 0.0, 0.0)}, ]) "smoke": preset_state["amount"] = 16 preset_state["lifetime"] = 3.0 preset_state["one_shot"] = false preset_state["explosiveness"] = 0.0 mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0) mat.spread = 25.0 mat.initial_velocity_min = 10.0 if is_2d else 0.5 mat.initial_velocity_max = 25.0 if is_2d else 1.2 mat.gravity = gravity_none mat.scale_min = 1.5 mat.scale_max = 3.0 mat.damping_min = 1.0 mat.damping_max = 2.0 _apply_gradient(mat, [ {"offset": 0.0, "color": Color(0.5, 0.5, 0.5, 0.6)}, {"offset": 0.5, "color": Color(0.6, 0.6, 0.6, 0.3)}, {"offset": 1.0, "color": Color(0.7, 0.7, 0.7, 0.0)}, ]) "sparks": preset_state["amount"] = 48 preset_state["lifetime"] = 0.4 preset_state["one_shot"] = true preset_state["explosiveness"] = 0.95 mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0) mat.spread = 180.0 mat.initial_velocity_min = 200.0 if is_2d else 8.0 mat.initial_velocity_max = 400.0 if is_2d else 16.0 mat.gravity = gravity_down mat.scale_min = 0.1 mat.scale_max = 0.3 mat.damping_min = 1.0 mat.damping_max = 3.0 _apply_gradient(mat, [ {"offset": 0.0, "color": Color(1.0, 1.0, 0.8)}, {"offset": 0.5, "color": Color(1.0, 0.7, 0.2)}, {"offset": 1.0, "color": Color(1.0, 0.3, 0.0, 0.0)}, ]) "rain": preset_state["amount"] = 64 preset_state["lifetime"] = 0.8 preset_state["one_shot"] = false preset_state["explosiveness"] = 0.0 mat.direction = Vector3(0, 1, 0) if is_2d else Vector3(0, -1, 0) mat.spread = 5.0 mat.initial_velocity_min = 300.0 if is_2d else 12.0 mat.initial_velocity_max = 400.0 if is_2d else 16.0 mat.gravity = gravity_down mat.scale_min = 0.1 mat.scale_max = 0.2 mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX mat.emission_box_extents = Vector3(200, 0, 0) if is_2d else Vector3(5, 0, 5) mat.color = Color(0.6, 0.7, 1.0, 0.7) "snow": preset_state["amount"] = 48 preset_state["lifetime"] = 4.0 preset_state["one_shot"] = false preset_state["explosiveness"] = 0.0 mat.direction = Vector3(0, 1, 0) if is_2d else Vector3(0, -1, 0) mat.spread = 20.0 mat.initial_velocity_min = 20.0 if is_2d else 0.8 mat.initial_velocity_max = 40.0 if is_2d else 1.5 mat.gravity = Vector3(0, 20, 0) if is_2d else Vector3(0, -0.5, 0) mat.scale_min = 0.3 mat.scale_max = 0.8 mat.angular_velocity_min = -45.0 mat.angular_velocity_max = 45.0 mat.damping_min = 0.5 mat.damping_max = 1.5 mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX mat.emission_box_extents = Vector3(200, 0, 0) if is_2d else Vector3(5, 0, 5) mat.color = Color(1.0, 1.0, 1.0, 0.9) "magic": preset_state["amount"] = 24 preset_state["lifetime"] = 2.0 preset_state["one_shot"] = false preset_state["explosiveness"] = 0.0 mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0) mat.spread = 180.0 mat.initial_velocity_min = 20.0 if is_2d else 1.0 mat.initial_velocity_max = 50.0 if is_2d else 2.5 mat.gravity = gravity_none mat.orbit_velocity_min = 0.5 mat.orbit_velocity_max = 1.5 mat.scale_min = 0.3 mat.scale_max = 0.8 mat.damping_min = 1.0 mat.damping_max = 2.0 _apply_gradient(mat, [ {"offset": 0.0, "color": Color(0.3, 0.5, 1.0)}, {"offset": 0.25, "color": Color(1.0, 0.3, 0.8)}, {"offset": 0.5, "color": Color(0.3, 1.0, 0.5)}, {"offset": 0.75, "color": Color(1.0, 0.8, 0.2)}, {"offset": 1.0, "color": Color(0.5, 0.3, 1.0, 0.0)}, ]) "dust": preset_state["amount"] = 12 preset_state["lifetime"] = 5.0 preset_state["one_shot"] = false preset_state["explosiveness"] = 0.0 mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0) mat.spread = 180.0 mat.initial_velocity_min = 3.0 if is_2d else 0.1 mat.initial_velocity_max = 8.0 if is_2d else 0.3 mat.gravity = gravity_none mat.scale_min = 0.2 mat.scale_max = 0.5 mat.damping_min = 0.5 mat.damping_max = 1.0 mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX mat.emission_box_extents = Vector3(100, 100, 0) if is_2d else Vector3(3, 3, 3) _apply_gradient(mat, [ {"offset": 0.0, "color": Color(0.8, 0.75, 0.65, 0.0)}, {"offset": 0.2, "color": Color(0.8, 0.75, 0.65, 0.3)}, {"offset": 0.8, "color": Color(0.8, 0.75, 0.65, 0.3)}, {"offset": 1.0, "color": Color(0.8, 0.75, 0.65, 0.0)}, ]) _: return error_invalid_params("Unknown preset: '%s'. Valid presets: explosion, fire, smoke, sparks, rain, snow, magic, dust" % preset) preset_state["process_material"] = mat _register_particle_state_undo(node, old_state, preset_state, "MCP: Apply particle preset") return success({"node_path": node_path, "preset": preset, "applied": true}) func _capture_particle_state(node: Node) -> Dictionary: var state := {} for property: String in ["amount", "lifetime", "one_shot", "explosiveness", "randomness", "emitting", "process_material"]: if property in node: state[property] = node.get(property) return state func _register_particle_state_undo(node: Node, old_state: Dictionary, new_state: Dictionary, action_name: String) -> void: var undo_redo := get_undo_redo() undo_redo.create_action(action_name) for property: String in new_state: undo_redo.add_do_property(node, property, new_state[property]) if new_state[property] is Resource: undo_redo.add_do_reference(new_state[property]) undo_redo.add_undo_property(node, property, old_state.get(property, null)) if old_state.get(property, null) is Resource: undo_redo.add_undo_reference(old_state[property]) undo_redo.commit_action() func _apply_gradient(mat: ParticleProcessMaterial, stops: Array) -> void: var gradient := Gradient.new() # Remove default points before adding custom stops for i in range(gradient.get_point_count() - 1, -1, -1): gradient.remove_point(i) for stop in stops: gradient.add_point(stop["offset"], stop["color"]) var grad_tex := GradientTexture1D.new() grad_tex.width = 64 # Smaller texture to avoid GPU issues in compatibility mode grad_tex.gradient = gradient # Defer color_ramp assignment to avoid editor crash during rendering mat.set_deferred("color_ramp", grad_tex) func _get_particle_info(params: Dictionary) -> Dictionary: var result := require_string(params, "node_path") if result[1] != null: return result[1] var node_path: String = result[0] var node := _get_particles_node_any(node_path) if node == null: return error_not_found("GPUParticles2D/3D at '%s'" % node_path) var info: Dictionary = { "node_path": node_path, "type": node.get_class(), "amount": node.get("amount"), "lifetime": node.get("lifetime"), "one_shot": node.get("one_shot"), "explosiveness": node.get("explosiveness"), "randomness": node.get("randomness"), "emitting": node.get("emitting"), } var mat: ParticleProcessMaterial = node.get("process_material") if mat != null and mat is ParticleProcessMaterial: var mat_info: Dictionary = { "direction": str(mat.direction), "spread": mat.spread, "initial_velocity_min": mat.initial_velocity_min, "initial_velocity_max": mat.initial_velocity_max, "gravity": str(mat.gravity), "scale_min": mat.scale_min, "scale_max": mat.scale_max, "color": str(mat.color), "angular_velocity_min": mat.angular_velocity_min, "angular_velocity_max": mat.angular_velocity_max, "orbit_velocity_min": mat.orbit_velocity_min, "orbit_velocity_max": mat.orbit_velocity_max, "damping_min": mat.damping_min, "damping_max": mat.damping_max, "attractor_interaction_enabled": mat.attractor_interaction_enabled, } # Emission shape var shape_name: String match mat.emission_shape: ParticleProcessMaterial.EMISSION_SHAPE_POINT: shape_name = "point" ParticleProcessMaterial.EMISSION_SHAPE_SPHERE: shape_name = "sphere" ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE: shape_name = "sphere_surface" ParticleProcessMaterial.EMISSION_SHAPE_BOX: shape_name = "box" ParticleProcessMaterial.EMISSION_SHAPE_RING: shape_name = "ring" _: shape_name = "unknown(%d)" % mat.emission_shape mat_info["emission_shape"] = shape_name match mat.emission_shape: ParticleProcessMaterial.EMISSION_SHAPE_SPHERE, ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE: mat_info["emission_sphere_radius"] = mat.emission_sphere_radius ParticleProcessMaterial.EMISSION_SHAPE_BOX: mat_info["emission_box_extents"] = str(mat.emission_box_extents) ParticleProcessMaterial.EMISSION_SHAPE_RING: mat_info["emission_ring_radius"] = mat.emission_ring_radius mat_info["emission_ring_inner_radius"] = mat.emission_ring_inner_radius mat_info["emission_ring_height"] = mat.emission_ring_height # Color gradient if mat.color_ramp != null and mat.color_ramp is GradientTexture1D: var grad_tex: GradientTexture1D = mat.color_ramp if grad_tex.gradient != null: var gradient_stops: Array = [] var grad: Gradient = grad_tex.gradient for i in grad.get_point_count(): gradient_stops.append({ "offset": grad.get_offset(i), "color": str(grad.get_color(i)), }) mat_info["color_ramp"] = gradient_stops info["material"] = mat_info else: info["material"] = null return success(info)