@tool extends RefCounted ## Curated particle effect blueprints. ## ## Each preset returns {main, process, draw}. The handler applies them ## through the normal write path (one undo action wraps all spawns). ## Each preset has {main, process, draw}. `draw` configures the StandardMaterial3D ## attached to the auto-created QuadMesh in draw_pass_1 (GPU 3D only); if ## omitted, the handler falls back to a sensible billboard-particles default. ## `blend_mode: "add"` is what makes fire/magic/explosion glow — without ## additive blending, additively-layered particles just stack to gray. const _PRESETS := { "fire": { "main": { "amount": 80, "lifetime": 1.2, "one_shot": false, "explosiveness": 0.0, "preprocess": 0.5, "local_coords": false, }, "process": { "emission_shape": "sphere", "emission_sphere_radius": 0.3, "direction": {"x": 0.0, "y": 1.0, "z": 0.0}, "spread": 15.0, "initial_velocity_min": 2.0, "initial_velocity_max": 4.0, "gravity": {"x": 0.0, "y": 1.0, "z": 0.0}, # buoyancy "scale_min": 0.4, "scale_max": 0.8, "color_ramp": { "stops": [ {"time": 0.0, "color": [1.0, 1.0, 0.9, 1.0]}, {"time": 0.3, "color": [1.0, 0.6, 0.1, 1.0]}, {"time": 0.7, "color": [0.8, 0.1, 0.05, 0.7]}, {"time": 1.0, "color": [0.2, 0.05, 0.05, 0.0]}, ] }, }, "draw": {"blend_mode": "add"}, }, "smoke": { "main": { "amount": 40, "lifetime": 3.0, "one_shot": false, "explosiveness": 0.0, "local_coords": false, }, "process": { "emission_shape": "sphere", "emission_sphere_radius": 0.4, "direction": {"x": 0.0, "y": 1.0, "z": 0.0}, "spread": 20.0, "initial_velocity_min": 0.5, "initial_velocity_max": 1.5, "gravity": {"x": 0.0, "y": 0.2, "z": 0.0}, "scale_min": 0.6, "scale_max": 1.4, "color_ramp": { "stops": [ {"time": 0.0, "color": [0.3, 0.3, 0.3, 0.0]}, {"time": 0.25, "color": [0.35, 0.35, 0.35, 0.7]}, {"time": 0.75, "color": [0.2, 0.2, 0.2, 0.5]}, {"time": 1.0, "color": [0.1, 0.1, 0.1, 0.0]}, ] }, }, # Smoke uses regular alpha blending so it darkens the background. "draw": {"blend_mode": "mix"}, }, "spark_burst": { "main": { "amount": 60, "lifetime": 0.8, "one_shot": true, "explosiveness": 1.0, "local_coords": false, }, "process": { "emission_shape": "point", "direction": {"x": 0.0, "y": 1.0, "z": 0.0}, "spread": 180.0, "initial_velocity_min": 5.0, "initial_velocity_max": 12.0, "gravity": {"x": 0.0, "y": -9.8, "z": 0.0}, "scale_min": 0.05, "scale_max": 0.12, "color": {"r": 1.0, "g": 0.9, "b": 0.2, "a": 1.0}, }, "draw": { "blend_mode": "add", "emission_enabled": true, "emission": {"r": 1.0, "g": 0.8, "b": 0.2, "a": 1.0}, "emission_energy_multiplier": 2.0, }, }, "magic_swirl": { "main": { "amount": 120, "lifetime": 2.0, "one_shot": false, "explosiveness": 0.0, "local_coords": false, }, "process": { "emission_shape": "ring", "emission_ring_radius": 0.8, "emission_ring_inner_radius": 0.6, "emission_ring_height": 0.0, "direction": {"x": 0.0, "y": 1.0, "z": 0.0}, "spread": 30.0, "initial_velocity_min": 1.0, "initial_velocity_max": 2.0, "gravity": {"x": 0.0, "y": 0.0, "z": 0.0}, "angular_velocity_min": 90.0, "angular_velocity_max": 180.0, "scale_min": 0.1, "scale_max": 0.2, "color_ramp": { "stops": [ {"time": 0.0, "color": [0.4, 0.9, 1.0, 0.0]}, {"time": 0.3, "color": [0.5, 0.7, 1.0, 1.0]}, {"time": 0.7, "color": [1.0, 0.4, 0.9, 1.0]}, {"time": 1.0, "color": [0.8, 0.2, 0.7, 0.0]}, ] }, }, "draw": {"blend_mode": "add"}, }, "rain": { "main": { "amount": 500, "lifetime": 1.5, "one_shot": false, "explosiveness": 0.0, "local_coords": false, }, "process": { "emission_shape": "box", "emission_box_extents": {"x": 10.0, "y": 0.1, "z": 10.0}, "direction": {"x": 0.0, "y": -1.0, "z": 0.0}, "spread": 2.0, "initial_velocity_min": 15.0, "initial_velocity_max": 18.0, "gravity": {"x": 0.0, "y": -2.0, "z": 0.0}, "scale_min": 0.02, "scale_max": 0.04, "color": {"r": 0.7, "g": 0.85, "b": 1.0, "a": 0.5}, }, # Rain drops render as streaks; fixed_y aligns them vertically. "draw": {"billboard_mode": "fixed_y", "blend_mode": "mix"}, }, "explosion": { "main": { "amount": 200, "lifetime": 1.5, "one_shot": true, "explosiveness": 1.0, "local_coords": false, }, "process": { "emission_shape": "sphere", "emission_sphere_radius": 0.1, "direction": {"x": 0.0, "y": 1.0, "z": 0.0}, "spread": 180.0, "initial_velocity_min": 6.0, "initial_velocity_max": 10.0, "gravity": {"x": 0.0, "y": -4.0, "z": 0.0}, "scale_min": 0.3, "scale_max": 0.7, "color_ramp": { "stops": [ {"time": 0.0, "color": [1.0, 0.95, 0.5, 1.0]}, {"time": 0.2, "color": [1.0, 0.4, 0.1, 1.0]}, {"time": 0.7, "color": [0.3, 0.15, 0.1, 0.7]}, {"time": 1.0, "color": [0.1, 0.1, 0.1, 0.0]}, ] }, }, "draw": { "blend_mode": "add", "emission_enabled": true, "emission": {"r": 1.0, "g": 0.5, "b": 0.1, "a": 1.0}, "emission_energy_multiplier": 1.5, }, }, "lightning": { # Short, bright, electric-blue spark burst. One-shot — call # particle_restart to re-trigger. Pairs well with a scene-wide flash. "main": { "amount": 40, "lifetime": 0.35, "one_shot": true, "explosiveness": 1.0, "local_coords": false, }, "process": { "emission_shape": "box", "emission_box_extents": {"x": 0.1, "y": 1.5, "z": 0.1}, "direction": {"x": 0.0, "y": -1.0, "z": 0.0}, "spread": 8.0, "initial_velocity_min": 18.0, "initial_velocity_max": 28.0, "gravity": {"x": 0.0, "y": 0.0, "z": 0.0}, "scale_min": 0.08, "scale_max": 0.18, "color_ramp": { "stops": [ {"time": 0.0, "color": [1.0, 1.0, 1.0, 1.0]}, {"time": 0.2, "color": [0.6, 0.85, 1.0, 1.0]}, {"time": 0.6, "color": [0.3, 0.5, 1.0, 0.9]}, {"time": 1.0, "color": [0.1, 0.2, 0.7, 0.0]}, ] }, }, "draw": { "blend_mode": "add", "emission_enabled": true, "emission": {"r": 0.5, "g": 0.8, "b": 1.0, "a": 1.0}, "emission_energy_multiplier": 4.0, }, }, } static func list() -> Array: return _PRESETS.keys() static func has(preset_name: String) -> bool: return _PRESETS.has(preset_name) ## Return deep-copied {main, process, draw} blueprint with overrides merged in. ## Overrides may include top-level "main" / "process" / "draw" dicts, or bare ## keys that get routed based on which group they belong to. static func build(preset_name: String, overrides: Dictionary) -> Variant: if not _PRESETS.has(preset_name): return null var entry: Dictionary = _PRESETS[preset_name].duplicate(true) var main: Dictionary = entry.get("main", {}) var process: Dictionary = entry.get("process", {}) var draw: Dictionary = entry.get("draw", {}) for key in overrides: var val = overrides[key] if key == "main" and val is Dictionary: for k in val: main[k] = val[k] elif key == "process" and val is Dictionary: for k in val: process[k] = val[k] elif key == "draw" and val is Dictionary: for k in val: draw[k] = val[k] elif _MAIN_KEYS.has(key): main[key] = val else: process[key] = val entry["main"] = main entry["process"] = process entry["draw"] = draw return entry const _MAIN_KEYS := { "amount": true, "lifetime": true, "one_shot": true, "explosiveness": true, "preprocess": true, "speed_scale": true, "randomness": true, "fixed_fps": true, "emitting": true, "local_coords": true, "interp_to_end": true, }