739 lines
25 KiB
GDScript
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
|