2026-10-09 12:49:07 +02:00

739 lines
25 KiB
GDScript

@tool
extends RefCounted
## particles.* command handlers — GPU particle system creation with presets.
const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd")
const Coerce = Modules.Coerce
const Helpers = Modules.CommandHelpers
const _VALID_PRESETS := ["fire", "smoke", "sparks", "rain", "snow", "explosion", "magic", "dust"]
static func _class_has_prop(cls: String, prop: String) -> bool:
for p in ClassDB.class_get_property_list(cls):
if p.get("name", "") == prop:
return true
return false
const _EMISSION_SHAPES := {
"point": ParticleProcessMaterial.EMISSION_SHAPE_POINT,
"sphere": ParticleProcessMaterial.EMISSION_SHAPE_SPHERE,
"sphere_surface": ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE,
"box": ParticleProcessMaterial.EMISSION_SHAPE_BOX,
"ring": ParticleProcessMaterial.EMISSION_SHAPE_RING,
}
# User param name → material property prefix for {prefix}_min / {prefix}_max pairs.
const _RANGE_PARAMS := {
"initial_velocity": "initial_velocity",
"damping": "damping",
"orbit_velocity": "orbit_velocity",
"scale_range": "scale",
"angle": "angle",
"angular_velocity": "angular_velocity",
"hue_variation": "hue_variation",
}
# Coercion modes shared by the two data-driven passes (_apply_props pass 7 and
# _merge_overrides pass 6). Each value mirrors the EXACT cast the old hand-written
# ladder used so both the emitted property value AND the merged eff value stay
# byte-identical:
# INT/FLOAT/BOOL — wrap the source in int()/float()/bool() before storing.
# VEC3/COLOR — coerce a raw user param via _vec3()/_color() (the merge ladder
# converted the {x,y,z}/{r,g,b,a} request dict into a Vector3/Color).
# RAW — assign the source directly (the apply ladder stored the value
# untouched; by then the preset/merge had already produced the
# right Vector3/Color, so it was assigned as-is).
# _apply_props uses INT/FLOAT/BOOL/RAW (its inputs are already typed); _merge_overrides
# uses INT/FLOAT/BOOL/VEC3/COLOR (its inputs are raw user params). The VEC3/COLOR rows
# are exactly the keys the apply pass treats as RAW — the coercion happens at merge.
enum { _COERCE_INT, _COERCE_FLOAT, _COERCE_BOOL, _COERCE_RAW, _COERCE_VEC3, _COERCE_COLOR }
# Targets for the applier: the node vs the ParticleProcessMaterial.
enum { _TARGET_NODE, _TARGET_MATERIAL }
# Data-driven map of the uniform "if eff.has(k): target.set(prop, cast(eff[k]));
# properties_set += 1" apply rows.
# Each row is [eff_key, target, coerce] and contributes weight 1. The eff_key ==
# the target property name in every case, so a single `key` field doubles as both.
# Rows stay in apply ORDER (node group, then material group); the genuine
# specials — range pairs (weight 2), the emission-shape name→enum lookup,
# sub-resources, version-gated props, the 2D/3D forks — are NOT in this table and
# remain explicit. Property writes here are mutually independent, so the integer
# count and the final object state are invariant under their relative order.
const _PROP_SPEC := [
# Node group.
["amount", _TARGET_NODE, _COERCE_INT],
["lifetime", _TARGET_NODE, _COERCE_FLOAT],
["explosiveness", _TARGET_NODE, _COERCE_FLOAT],
["speed_scale", _TARGET_NODE, _COERCE_FLOAT],
["one_shot", _TARGET_NODE, _COERCE_BOOL],
["local_coords", _TARGET_NODE, _COERCE_BOOL],
# Material group (the range pairs + emission-shape arm stay explicit below).
["direction", _TARGET_MATERIAL, _COERCE_RAW],
["spread", _TARGET_MATERIAL, _COERCE_FLOAT],
["gravity", _TARGET_MATERIAL, _COERCE_RAW],
["color", _TARGET_MATERIAL, _COERCE_RAW],
["particle_flag_align_y", _TARGET_MATERIAL, _COERCE_BOOL],
["emission_sphere_radius", _TARGET_MATERIAL, _COERCE_FLOAT],
["emission_box_extents", _TARGET_MATERIAL, _COERCE_RAW],
["turbulence_enabled", _TARGET_MATERIAL, _COERCE_BOOL],
["turbulence_noise_strength", _TARGET_MATERIAL, _COERCE_FLOAT],
]
# Data-driven map of the uniform simple-override rows of the merge that layers
# user params onto the preset-seeded eff dict.
# Each row is [param_key, coerce] and writes eff[param_key] = coerce(parameters[key]).
# Row order is load-bearing: every shadow append into overrides_applied happens in
# this order and the array's element order is part of the particles.create response
# contract — so keep the rows in exactly this sequence (unlike _PROP_SPEC, whose
# writes are independent and order-invariant). The coercion column uses the merge
# modes (VEC3/COLOR convert the request dict), NOT the apply modes. The genuine
# specials — range pairs and the emission-shape override — are NOT in this table;
# they keep their dedicated blocks in _merge_overrides (different shape: a _min/_max
# pair, and a name-validated string) and append AFTER this table.
const _OVERRIDE_SPEC := [
["amount", _COERCE_INT],
["lifetime", _COERCE_FLOAT],
["one_shot", _COERCE_BOOL],
["explosiveness", _COERCE_FLOAT],
["speed_scale", _COERCE_FLOAT],
["local_coords", _COERCE_BOOL],
["direction", _COERCE_VEC3],
["spread", _COERCE_FLOAT],
["gravity", _COERCE_VEC3],
["color", _COERCE_COLOR],
["particle_flag_align_y", _COERCE_BOOL],
["emission_sphere_radius", _COERCE_FLOAT],
["emission_box_extents", _COERCE_VEC3],
["turbulence_enabled", _COERCE_BOOL],
["turbulence_noise_strength", _COERCE_FLOAT],
]
# Presets defined in 2D units. When type=="3d", _adjust_for_3d() is applied.
const _PRESETS := {
"fire": {
"direction": Vector3(0, -1, 0),
"spread": 15.0,
"initial_velocity_min": 30.0,
"initial_velocity_max": 60.0,
"gravity": Vector3(0, 0, 0),
"lifetime": 1.2,
"amount": 24,
"scale_min": 0.8,
"scale_max": 1.5,
"color_ramp": [
{"offset": 0.0, "color": Color(1, 1, 0, 1)},
{"offset": 0.35, "color": Color(1, 0.5, 0, 1)},
{"offset": 0.7, "color": Color(0.6, 0.1, 0, 0.6)},
{"offset": 1.0, "color": Color(0.2, 0, 0, 0)},
],
"alpha_curve": [
{"x": 0.0, "y": 0.0},
{"x": 0.1, "y": 1.0},
{"x": 0.8, "y": 0.6},
{"x": 1.0, "y": 0.0},
],
"scale_curve": [
{"x": 0.0, "y": 0.5},
{"x": 0.3, "y": 1.0},
{"x": 1.0, "y": 0.0},
],
},
"smoke": {
"direction": Vector3(0, -1, 0),
"spread": 25.0,
"initial_velocity_min": 10.0,
"initial_velocity_max": 25.0,
"gravity": Vector3(0, 0, 0),
"damping_min": 1.0,
"damping_max": 2.0,
"lifetime": 3.0,
"amount": 16,
"scale_min": 1.5,
"scale_max": 3.0,
"color_ramp": [
{"offset": 0.0, "color": Color(0.6, 0.6, 0.6, 0.6)},
{"offset": 0.5, "color": Color(0.4, 0.4, 0.4, 0.3)},
{"offset": 1.0, "color": Color(0.2, 0.2, 0.2, 0.0)},
],
"alpha_curve": [
{"x": 0.0, "y": 0.0},
{"x": 0.15, "y": 0.8},
{"x": 0.7, "y": 0.4},
{"x": 1.0, "y": 0.0},
],
"scale_curve": [
{"x": 0.0, "y": 0.3},
{"x": 0.5, "y": 0.7},
{"x": 1.0, "y": 1.0},
],
},
"sparks": {
"direction": Vector3(0, -1, 0),
"spread": 180.0,
"initial_velocity_min": 200.0,
"initial_velocity_max": 400.0,
"gravity": Vector3(0, 98, 0),
"damping_min": 1.0,
"damping_max": 3.0,
"lifetime": 0.5,
"amount": 48,
"one_shot": true,
"explosiveness": 1.0,
"scale_min": 0.1,
"scale_max": 0.3,
"particle_flag_align_y": true,
"color_ramp": [
{"offset": 0.0, "color": Color(1, 1, 0.8, 1)},
{"offset": 0.3, "color": Color(1, 0.7, 0.2, 1)},
{"offset": 0.7, "color": Color(1, 0.3, 0, 0.8)},
{"offset": 1.0, "color": Color(0.5, 0.1, 0, 0)},
],
"alpha_curve": [
{"x": 0.0, "y": 1.0},
{"x": 0.7, "y": 0.8},
{"x": 1.0, "y": 0.0},
],
"scale_curve": [
{"x": 0.0, "y": 1.0},
{"x": 1.0, "y": 0.3},
],
},
"rain": {
"direction": Vector3(0, 1, 0),
"spread": 5.0,
"initial_velocity_min": 300.0,
"initial_velocity_max": 400.0,
"gravity": Vector3(0, 98, 0),
"lifetime": 0.8,
"amount": 64,
"emission_shape": "box",
"emission_box_extents": Vector3(300, 0, 0),
"particle_flag_align_y": true,
"color": Color(0.6, 0.7, 1.0, 0.7),
"scale_min": 0.1,
"scale_max": 0.2,
},
"snow": {
"direction": Vector3(0, 1, 0),
"spread": 20.0,
"initial_velocity_min": 20.0,
"initial_velocity_max": 40.0,
"gravity": Vector3(0, 20, 0),
"angular_velocity_min": -45.0,
"angular_velocity_max": 45.0,
"lifetime": 4.0,
"amount": 48,
"emission_shape": "box",
"emission_box_extents": Vector3(300, 0, 0),
"color": Color(1, 1, 1, 0.9),
"scale_min": 0.3,
"scale_max": 0.8,
},
"explosion": {
"direction": Vector3(0, -1, 0),
"spread": 180.0,
"initial_velocity_min": 100.0,
"initial_velocity_max": 200.0,
"gravity": Vector3(0, 49, 0),
"damping_min": 2.0,
"damping_max": 4.0,
"lifetime": 0.6,
"amount": 32,
"one_shot": true,
"explosiveness": 1.0,
"scale_min": 0.5,
"scale_max": 1.5,
"color_ramp": [
{"offset": 0.0, "color": Color(1, 1, 0.8, 1)},
{"offset": 0.2, "color": Color(1, 0.6, 0, 1)},
{"offset": 0.5, "color": Color(0.8, 0.2, 0, 0.7)},
{"offset": 0.8, "color": Color(0.4, 0.1, 0, 0.3)},
{"offset": 1.0, "color": Color(0.2, 0.05, 0, 0)},
],
"alpha_curve": [
{"x": 0.0, "y": 1.0},
{"x": 0.3, "y": 0.9},
{"x": 0.7, "y": 0.4},
{"x": 1.0, "y": 0.0},
],
"scale_curve": [
{"x": 0.0, "y": 0.2},
{"x": 0.15, "y": 1.0},
{"x": 0.5, "y": 0.7},
{"x": 1.0, "y": 0.3},
],
},
"magic": {
"direction": Vector3(0, -1, 0),
"spread": 180.0,
"initial_velocity_min": 20.0,
"initial_velocity_max": 50.0,
"gravity": Vector3(0, 0, 0),
"orbit_velocity_min": 0.5,
"orbit_velocity_max": 1.5,
"damping_min": 1.0,
"damping_max": 2.0,
"lifetime": 2.0,
"amount": 24,
"scale_min": 0.3,
"scale_max": 0.8,
"hue_variation_min": -0.15,
"hue_variation_max": 0.15,
"color_ramp": [
{"offset": 0.0, "color": Color(0.5, 0.2, 1, 1)},
{"offset": 0.4, "color": Color(0.2, 0.6, 1, 0.8)},
{"offset": 0.7, "color": Color(0.8, 0.3, 1, 0.5)},
{"offset": 1.0, "color": Color(0.4, 0.1, 0.8, 0)},
],
"alpha_curve": [
{"x": 0.0, "y": 0.0},
{"x": 0.1, "y": 1.0},
{"x": 0.6, "y": 0.7},
{"x": 1.0, "y": 0.0},
],
"scale_curve": [
{"x": 0.0, "y": 0.5},
{"x": 0.3, "y": 1.0},
{"x": 0.7, "y": 0.8},
{"x": 1.0, "y": 0.2},
],
},
"dust": {
"direction": Vector3(0, -1, 0),
"spread": 180.0,
"initial_velocity_min": 3.0,
"initial_velocity_max": 8.0,
"gravity": Vector3(0, 0, 0),
"damping_min": 0.5,
"damping_max": 1.0,
"lifetime": 5.0,
"amount": 12,
"emission_shape": "box",
"emission_box_extents": Vector3(100, 100, 0),
"color": Color(0.8, 0.75, 0.65, 0.4),
"scale_min": 0.2,
"scale_max": 0.5,
"color_ramp": [
{"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.4)},
{"offset": 0.8, "color": Color(0.7, 0.65, 0.55, 0.3)},
{"offset": 1.0, "color": Color(0.6, 0.55, 0.45, 0.0)},
],
"alpha_curve": [
{"x": 0.0, "y": 0.0},
{"x": 0.2, "y": 0.6},
{"x": 0.6, "y": 0.5},
{"x": 1.0, "y": 0.0},
],
"scale_curve": [
{"x": 0.0, "y": 0.5},
{"x": 0.5, "y": 1.0},
{"x": 1.0, "y": 0.8},
],
},
}
static func register(registry: MCPToolkitCommandRegistry, _server: Node) -> void:
registry.add("particles.create", func(p: Dictionary) -> Dictionary:
return _cmd_particles_create(p)
, MCPToolkitCommandOptions.new())
# -- Helpers ------------------------------------------------------------------
static func _vec3(d: Variant) -> Vector3:
if typeof(d) != TYPE_DICTIONARY:
return Vector3.ZERO
return Vector3(float(d.get("x", 0.0)), float(d.get("y", 0.0)), float(d.get("z", 0.0)))
static func _color(d: Variant) -> Color:
return Coerce.color_from_dict(d)
static func _expand_range(v: Variant) -> Array:
## Returns [min_val, max_val] as floats.
if typeof(v) == TYPE_DICTIONARY:
return [float(v.get("min", 0.0)), float(v.get("max", 0.0))]
var f := float(v)
return [f, f]
static func _adjust_for_3d(data: Dictionary) -> void:
## Mutate preset data in-place: 2D → 3D coordinate scaling.
if data.has("direction"):
var d: Vector3 = data["direction"]
data["direction"] = Vector3(d.x, -d.y, d.z)
if data.has("gravity"):
data["gravity"] = Vector3(data["gravity"]) / 10.0
for key in ["initial_velocity_min", "initial_velocity_max"]:
if data.has(key):
data[key] = float(data[key]) / 20.0
if data.has("emission_box_extents"):
data["emission_box_extents"] = Vector3(data["emission_box_extents"]) / 20.0
static func _build_gradient_texture(points: Array, interp_mode: int) -> Array:
## Create GradientTexture1D from [{offset, color}].
## Returns [GradientTexture1D, Gradient].
var gradient := Gradient.new()
var offsets := PackedFloat32Array()
var colors := PackedColorArray()
for point in points:
offsets.append(float(point["offset"]))
if point["color"] is Color:
colors.append(point["color"])
else:
colors.append(_color(point["color"]))
gradient.offsets = offsets
gradient.colors = colors
gradient.interpolation_mode = interp_mode
var texture := GradientTexture1D.new()
texture.gradient = gradient
return [texture, gradient]
static func _build_curve_texture(points: Array) -> Array:
## Create CurveTexture from [{x, y}].
## Returns [CurveTexture, Curve].
var curve := Curve.new()
for point in points:
curve.add_point(Vector2(float(point["x"]), float(point["y"])))
var texture := CurveTexture.new()
texture.curve = curve
return [texture, curve]
static func _apply_props(node: Node, material: ParticleProcessMaterial, eff: Dictionary) -> int:
## Apply every uniform _PROP_SPEC entry present in eff onto its target,
## returning the properties_set delta (the data-driven replacement for the
## simple-property half of pass 7). The range-pair loop and the emission-shape
## name→enum lookup are NOT covered here and stay explicit in the handler.
var count := 0
for spec in _PROP_SPEC:
var key: String = spec[0]
if not eff.has(key):
continue
var target: Object = node if spec[1] == _TARGET_NODE else material
var raw: Variant = eff[key]
match spec[2]:
_COERCE_INT:
target.set(key, int(raw))
_COERCE_FLOAT:
target.set(key, float(raw))
_COERCE_BOOL:
target.set(key, bool(raw))
_: # _COERCE_RAW — assign untouched (already the right Vector3/Color).
target.set(key, raw)
count += 1
return count
static func _merge_overrides(eff: Dictionary, parameters: Dictionary) -> Array:
## Layer the request's user params onto the preset-seeded eff dict (pass 6),
## returning the overrides_applied array — the names of params that SHADOWED a
## preset value, in contract order. eff is mutated in place. Three sub-passes,
## appended strictly in the original order so the returned array is byte-identical:
## 1. the uniform simple props (_OVERRIDE_SPEC, in order);
## 2. the range params (_RANGE_PARAMS → {prefix}_min/_max);
## 3. the emission-shape name override.
## A param shadows the preset only when eff already holds the key (range: the
## _min half) — matching the old `if eff.has(...): overrides.append(...)` guards.
var overrides: Array = []
# 1. Simple user overrides (table-driven; was the :464-483 match-ladder).
for spec in _OVERRIDE_SPEC:
var key: String = spec[0]
if not parameters.has(key):
continue
if eff.has(key):
overrides.append(key)
var raw: Variant = parameters[key]
match spec[1]:
_COERCE_INT:
eff[key] = int(raw)
_COERCE_BOOL:
eff[key] = bool(raw)
_COERCE_VEC3:
eff[key] = _vec3(raw)
_COERCE_COLOR:
eff[key] = _color(raw)
_: # _COERCE_FLOAT — the old ladder's default arm.
eff[key] = float(raw)
# 2. Range params: user float or {min,max} → internal _min/_max (was :485-494).
for user_key in _RANGE_PARAMS:
if not parameters.has(user_key):
continue
var prefix: String = _RANGE_PARAMS[user_key]
var r := _expand_range(parameters[user_key])
if eff.has(prefix + "_min"):
overrides.append(user_key)
eff[prefix + "_min"] = r[0]
eff[prefix + "_max"] = r[1]
# 3. Emission shape override (was :496-500). The name was already validated
# against _EMISSION_SHAPES in the handler before this merge runs.
var emission_shape_str = parameters.get("emission_shape")
if emission_shape_str != null:
if eff.has("emission_shape"):
overrides.append("emission_shape")
eff["emission_shape"] = str(emission_shape_str)
return overrides
# -- Command ------------------------------------------------------------------
static func _cmd_particles_create(parameters: Dictionary) -> Dictionary:
# --- Validate required ---
var err = MCPToolkitError.require(parameters, ["parent_path", "type"])
if err != null:
return err
var type_str := str(parameters.get("type", ""))
if type_str != "2d" and type_str != "3d":
return MCPToolkitError.fail("INVALID_PARAMS",
"type must be '2d' or '3d', got '%s'" % type_str)
var is_3d := type_str == "3d"
# --- Preset ---
var preset_name = parameters.get("preset")
var preset := {}
if preset_name != null:
var pn := str(preset_name)
if pn not in _VALID_PRESETS:
return MCPToolkitError.fail("INVALID_PARAMS",
"invalid preset '%s'; valid presets: %s" % [pn, ", ".join(_VALID_PRESETS)])
preset = _PRESETS[pn].duplicate(true)
if is_3d:
_adjust_for_3d(preset)
# --- Validate emission_shape ---
var emission_shape_str = parameters.get("emission_shape")
if emission_shape_str != null and str(emission_shape_str) not in _EMISSION_SHAPES:
return MCPToolkitError.fail("INVALID_PARAMS",
"invalid emission_shape '%s'; valid shapes: %s" % [
str(emission_shape_str), ", ".join(_EMISSION_SHAPES.keys())])
# --- Resolve parent ---
var root := Helpers.get_edited_root()
if root == null:
return MCPToolkitError.fail("NO_SCENE", "no edited scene")
var parent_path := str(parameters.get("parent_path", "."))
parent_path = Helpers.normalize_editor_path(parent_path)
var parent := Helpers.resolve_scene_node(parent_path)
if parent == null:
return MCPToolkitError.fail("NOT_FOUND",
"parent not found: %s" % parent_path, MCPToolkitError.HINT_NODE_PATH)
# --- Create node + material ---
var node: Node
var default_name := "GPUParticles3D" if is_3d else "GPUParticles2D"
if is_3d:
node = GPUParticles3D.new()
else:
node = GPUParticles2D.new()
node.name = str(parameters.get("name", default_name))
var material := ParticleProcessMaterial.new()
var sub_resources: Array = [material]
var properties_set := 0
var version_notes: Array = []
# Build effective config: preset base, user overrides on top.
var eff := preset.duplicate(true)
# --- Merge user overrides (data-driven; see _OVERRIDE_SPEC) ---
# overrides holds the names of params that shadowed a preset value, in the order
# the response contract expects (simple props, then range params, then emission
# shape). The sub-resource pass appends to it below; the result reads it last.
var overrides: Array = _merge_overrides(eff, parameters)
# --- Apply node + material properties (data-driven; see _PROP_SPEC) ---
# The uniform single-property rows of pass 7 are applied by _apply_props.
# The range-pair loop and the emission-shape name→enum lookup are NOT uniform
# (weight 2 / value is an enum lookup) and stay explicit below. These writes
# are mutually independent, so the count and final state are order-invariant.
properties_set += _apply_props(node, material, eff)
# Range params on material (weight 2 each — kept explicit).
for prefix in ["initial_velocity", "damping", "orbit_velocity", "scale",
"angle", "angular_velocity", "hue_variation"]:
if eff.has(prefix + "_min"):
material.set(prefix + "_min", float(eff[prefix + "_min"]))
material.set(prefix + "_max", float(eff[prefix + "_max"]))
properties_set += 2
# Emission shape (name → enum lookup — kept explicit).
if eff.has("emission_shape"):
var shape_name := str(eff["emission_shape"])
if shape_name in _EMISSION_SHAPES:
material.emission_shape = _EMISSION_SHAPES[shape_name]
properties_set += 1
# --- Sub-resources: color_ramp, alpha_curve, scale_curve ---
var sr_err = _apply_sub_resource(parameters, eff, preset, material,
"color_ramp", sub_resources, overrides, node)
if sr_err != null:
return sr_err
if material.color_ramp != null:
properties_set += 1
sr_err = _apply_sub_resource(parameters, eff, preset, material,
"alpha_curve", sub_resources, overrides, node)
if sr_err != null:
return sr_err
if material.alpha_curve != null:
properties_set += 1
sr_err = _apply_sub_resource(parameters, eff, preset, material,
"scale_curve", sub_resources, overrides, node)
if sr_err != null:
return sr_err
if material.scale_curve != null:
properties_set += 1
# --- Assign material ---
node.process_material = material
properties_set += 1
# --- 3D mesh ---
if is_3d and parameters.has("mesh"):
var mesh_type := str(parameters["mesh"])
var mesh: Mesh
match mesh_type:
"quad": mesh = QuadMesh.new()
"box": mesh = BoxMesh.new()
"sphere": mesh = SphereMesh.new()
_: mesh = QuadMesh.new()
(node as GPUParticles3D).draw_pass_1 = mesh
sub_resources.append(mesh)
properties_set += 1
# --- 2D texture ---
if not is_3d and parameters.has("texture_path"):
var tex_path := str(parameters["texture_path"])
if ResourceLoader.exists(tex_path):
(node as GPUParticles2D).texture = ResourceLoader.load(tex_path)
properties_set += 1
# --- Position ---
if parameters.has("position"):
var pos = parameters["position"]
if is_3d:
(node as Node3D).position = _vec3(pos)
else:
var p = pos if typeof(pos) == TYPE_DICTIONARY else {}
(node as Node2D).position = Vector2(
float(p.get("x", 0.0)), float(p.get("y", 0.0)))
properties_set += 1
# --- Version-gated properties ---
var node_class := "GPUParticles3D" if is_3d else "GPUParticles2D"
if parameters.has("use_fixed_seed"):
if _class_has_prop(node_class, "use_fixed_seed"):
node.set("use_fixed_seed", bool(parameters["use_fixed_seed"]))
properties_set += 1
else:
version_notes.append("use_fixed_seed unavailable in this Godot version")
if parameters.has("seed"):
if _class_has_prop(node_class, "seed"):
node.set("seed", int(parameters["seed"]))
properties_set += 1
else:
version_notes.append("seed unavailable in this Godot version")
if parameters.has("emission_ring_cone_angle"):
if _class_has_prop("ParticleProcessMaterial", "emission_ring_cone_angle"):
material.set("emission_ring_cone_angle", float(parameters["emission_ring_cone_angle"]))
properties_set += 1
else:
version_notes.append("emission_ring_cone_angle unavailable in this Godot version")
# --- Add to scene via UndoRedo ---
parent.add_child(node)
node.set_owner(root)
var _undo := MCPToolkitUndoRedoAction.begin("create %s" % node.name, parent) \
.do_method(parent.add_child.bind(node)) \
.do_method(node.set_owner.bind(root)) \
.do_reference(node)
for res in sub_resources:
_undo.undo_reference(res)
_undo.undo_method(parent.remove_child.bind(node)) \
.commit_recorded()
# --- Result ---
var result := {
"node_path": str(root.get_path_to(node)),
"type": "GPUParticles3D" if is_3d else "GPUParticles2D",
"properties_set": properties_set,
"version_notes": version_notes,
}
if preset_name != null:
result["preset_applied"] = str(preset_name)
if not overrides.is_empty():
result["overrides_applied"] = overrides
return MCPToolkitSuccess.ok(result)
static func _apply_sub_resource(
parameters: Dictionary, eff: Dictionary, preset: Dictionary,
material: ParticleProcessMaterial, prop_name: String,
sub_resources: Array, overrides_out: Array, node: Node) -> Variant:
## Handle color_ramp / alpha_curve / scale_curve. Returns error dict or null.
var source = null
var is_override := false
if parameters.has(prop_name):
source = parameters[prop_name]
is_override = preset.has(prop_name)
elif eff.has(prop_name):
source = eff[prop_name]
if source == null:
return null
if source is String:
var path := str(source)
if not ResourceLoader.exists(path):
node.free()
return MCPToolkitError.fail("NOT_FOUND",
"%s resource not found: %s" % [prop_name, path])
material.set(prop_name, ResourceLoader.load(path))
elif source is Array:
# Preset format: array of point dicts.
if prop_name == "color_ramp":
var result := _build_gradient_texture(source, Gradient.GRADIENT_INTERPOLATE_LINEAR)
material.color_ramp = result[0]
sub_resources.append(result[0])
sub_resources.append(result[1])
else:
var result := _build_curve_texture(source)
material.set(prop_name, result[0])
sub_resources.append(result[0])
sub_resources.append(result[1])
elif source is Dictionary and source.has("points"):
# User format: {points: [...], interpolation?}.
if prop_name == "color_ramp":
var interp := Gradient.GRADIENT_INTERPOLATE_LINEAR
var interp_str = source.get("interpolation", "linear")
match str(interp_str):
"cubic": interp = Gradient.GRADIENT_INTERPOLATE_CUBIC
"constant": interp = Gradient.GRADIENT_INTERPOLATE_CONSTANT
var result := _build_gradient_texture(source["points"], interp)
material.color_ramp = result[0]
sub_resources.append(result[0])
sub_resources.append(result[1])
else:
var result := _build_curve_texture(source["points"])
material.set(prop_name, result[0])
sub_resources.append(result[0])
sub_resources.append(result[1])
if is_override:
overrides_out.append(prop_name)
return null