diff --git a/godot/autoloads/EventBus.gd b/godot/autoloads/EventBus.gd index 8ec471c..8da0b24 100644 --- a/godot/autoloads/EventBus.gd +++ b/godot/autoloads/EventBus.gd @@ -31,6 +31,10 @@ signal unit_died(unit_id: StringName, owner_id: StringName, hex: Vector2i) ## Emitted when a unit receives a promotion or upgrade. signal unit_promoted(unit_id: StringName, promotion_id: StringName, bonuses: Dictionary) +## unit_xp_gained(unit_id: StringName, amount: int, hex: Vector2i) +## Emitted when a unit earns experience from combat, exploration, or objectives. +signal unit_xp_gained(unit_id: StringName, amount: int, hex: Vector2i) + # City events @@ -50,6 +54,10 @@ signal city_captured( ## Emitted when a city's production queue completes an item. signal production_complete(city_id: StringName, production_id: StringName, result: Dictionary) +## resource_discovered(resource_id: StringName, hex: Vector2i, metadata: Dictionary) +## Emitted when a hidden resource or discovery tile is revealed. +signal resource_discovered(resource_id: StringName, hex: Vector2i, metadata: Dictionary) + # Turn events @@ -137,6 +145,15 @@ func emit_unit_promoted( unit_promoted.emit(unit_id, promotion_id, bonuses) +func emit_unit_xp_gained( + unit_id: StringName, + amount: int, + hex: Vector2i = Vector2i.ZERO +) -> void: + _log_event("unit_xp_gained", [unit_id, amount, hex]) + unit_xp_gained.emit(unit_id, amount, hex) + + func emit_city_built(city_id: StringName, owner_id: StringName, hex: Vector2i) -> void: _log_event("city_built", [city_id, owner_id, hex]) city_built.emit(city_id, owner_id, hex) @@ -160,6 +177,15 @@ func emit_production_complete( production_complete.emit(city_id, production_id, result) +func emit_resource_discovered( + resource_id: StringName, + hex: Vector2i, + metadata: Dictionary = {} +) -> void: + _log_event("resource_discovered", [resource_id, hex, metadata]) + resource_discovered.emit(resource_id, hex, metadata) + + func emit_turn_started(turn_number: int, player_id: StringName) -> void: _log_event("turn_started", [turn_number, player_id]) turn_started.emit(turn_number, player_id) diff --git a/godot/autoloads/ParticleManager.gd b/godot/autoloads/ParticleManager.gd new file mode 100644 index 0000000..d196c6a --- /dev/null +++ b/godot/autoloads/ParticleManager.gd @@ -0,0 +1,170 @@ +extends Node + +const EFFECT_COMBAT_EXPLOSION := &"combat_explosion" +const EFFECT_CITY_BUILD := &"city_build" +const EFFECT_XP_GAIN := &"xp_gain" +const EFFECT_LEVEL_UP := &"level_up" +const EFFECT_RESOURCE_DISCOVERY := &"resource_discovery" +const EFFECT_FLAG_CAPTURE := &"flag_capture" + +const DEFAULT_HEX_WIDTH := 72.0 +const DEFAULT_HEX_HEIGHT := 62.0 +const DEFAULT_HEX_ROW_OFFSET := 36.0 + +var _unit_positions: Dictionary = {} +var _city_positions: Dictionary = {} +var _hex_positions: Dictionary = {} + + +func _ready() -> void: + _connect_events() + + +func register_unit_position(unit_id: StringName, world_position: Vector2) -> void: + _unit_positions[unit_id] = world_position + + +func unregister_unit(unit_id: StringName) -> void: + _unit_positions.erase(unit_id) + + +func register_city_position(city_id: StringName, world_position: Vector2) -> void: + _city_positions[city_id] = world_position + + +func unregister_city(city_id: StringName) -> void: + _city_positions.erase(city_id) + + +func register_hex_position(hex: Vector2i, world_position: Vector2) -> void: + _hex_positions[hex] = world_position + + +func clear_registered_positions() -> void: + _unit_positions.clear() + _city_positions.clear() + _hex_positions.clear() + + +func _connect_events() -> void: + _connect_event(EventBus.unit_attacked, _on_unit_attacked) + _connect_event(EventBus.unit_died, _on_unit_died) + _connect_event(EventBus.unit_promoted, _on_unit_promoted) + _connect_event(EventBus.unit_xp_gained, _on_unit_xp_gained) + _connect_event(EventBus.city_built, _on_city_built) + _connect_event(EventBus.city_captured, _on_city_captured) + _connect_event(EventBus.production_complete, _on_production_complete) + _connect_event(EventBus.resource_discovered, _on_resource_discovered) + + +func _connect_event(event_signal: Signal, callback: Callable) -> void: + if not event_signal.is_connected(callback): + event_signal.connect(callback) + + +func _on_unit_attacked( + _attacker_id: StringName, + defender_id: StringName, + _damage: int, + _remaining_health: int +) -> void: + _play(EFFECT_COMBAT_EXPLOSION, _position_for_unit(defender_id)) + + +func _on_unit_died(_unit_id: StringName, _owner_id: StringName, hex: Vector2i) -> void: + _play(EFFECT_COMBAT_EXPLOSION, _position_for_hex(hex)) + + +func _on_unit_promoted( + unit_id: StringName, + _promotion_id: StringName, + _bonuses: Dictionary +) -> void: + _play(EFFECT_LEVEL_UP, _position_for_unit(unit_id)) + + +func _on_unit_xp_gained(unit_id: StringName, _amount: int, hex: Vector2i) -> void: + _play(EFFECT_XP_GAIN, _position_for_unit_or_hex(unit_id, hex)) + + +func _on_city_built(city_id: StringName, _owner_id: StringName, hex: Vector2i) -> void: + var position := _position_for_hex(hex) + register_city_position(city_id, position) + _play(EFFECT_CITY_BUILD, position) + + +func _on_city_captured( + city_id: StringName, + _previous_owner_id: StringName, + _new_owner_id: StringName +) -> void: + _play(EFFECT_FLAG_CAPTURE, _position_for_city(city_id)) + + +func _on_production_complete( + city_id: StringName, + _production_id: StringName, + result: Dictionary +) -> void: + _play(EFFECT_CITY_BUILD, _position_from_result(result, _position_for_city(city_id))) + + +func _on_resource_discovered( + _resource_id: StringName, + hex: Vector2i, + _metadata: Dictionary +) -> void: + _play(EFFECT_RESOURCE_DISCOVERY, _position_for_hex(hex)) + + +func _play(effect_type: StringName, world_position: Vector2) -> void: + ParticlePool.play(effect_type, world_position) + + +func _position_for_unit(unit_id: StringName) -> Vector2: + if _unit_positions.has(unit_id): + return _unit_positions[unit_id] + return Vector2.ZERO + + +func _position_for_city(city_id: StringName) -> Vector2: + if _city_positions.has(city_id): + return _city_positions[city_id] + return Vector2.ZERO + + +func _position_for_unit_or_hex(unit_id: StringName, hex: Vector2i) -> Vector2: + if _unit_positions.has(unit_id): + return _unit_positions[unit_id] + return _position_for_hex(hex) + + +func _position_for_hex(hex: Vector2i) -> Vector2: + if _hex_positions.has(hex): + return _hex_positions[hex] + + var row_offset := DEFAULT_HEX_ROW_OFFSET if abs(hex.y) % 2 == 1 else 0.0 + return Vector2( + float(hex.x) * DEFAULT_HEX_WIDTH + row_offset, + float(hex.y) * DEFAULT_HEX_HEIGHT + ) + + +func _position_from_result(result: Dictionary, fallback: Vector2) -> Vector2: + if result.has("world_position"): + return _coerce_world_position(result["world_position"], fallback) + if result.has("position"): + return _coerce_world_position(result["position"], fallback) + if result.has("hex") and result["hex"] is Vector2i: + return _position_for_hex(result["hex"]) + return fallback + + +func _coerce_world_position(value: Variant, fallback: Vector2) -> Vector2: + if value is Vector2: + return value + if value is Vector2i: + return Vector2(value) + if typeof(value) == TYPE_DICTIONARY and value.has("x") and value.has("y"): + return Vector2(float(value["x"]), float(value["y"])) + return fallback diff --git a/godot/autoloads/ParticlePool.gd b/godot/autoloads/ParticlePool.gd new file mode 100644 index 0000000..90300d6 --- /dev/null +++ b/godot/autoloads/ParticlePool.gd @@ -0,0 +1,123 @@ +extends Node + +const POOL_SIZE := 8 + +const EFFECT_SCENES := { + &"combat_explosion": preload("res://scenes/fx_combat_explosion.tscn"), + &"city_build": preload("res://scenes/fx_city_build.tscn"), + &"xp_gain": preload("res://scenes/fx_xp_gain.tscn"), + &"level_up": preload("res://scenes/fx_level_up.tscn"), + &"resource_discovery": preload("res://scenes/fx_resource_discovery.tscn"), + &"flag_capture": preload("res://scenes/fx_flag_capture.tscn"), +} + +var _available: Dictionary = {} +var _active: Dictionary = {} + + +func _ready() -> void: + _warm_pools() + + +func get(effect_type: StringName) -> GPUParticles2D: + if not EFFECT_SCENES.has(effect_type): + push_warning("Unknown particle effect type: %s" % effect_type) + return null + + var available: Array = _available[effect_type] + if available.is_empty(): + _reclaim_oldest(effect_type) + + if available.is_empty(): + return null + + var effect: GPUParticles2D = available.pop_back() + _active[effect_type].append(effect) + return effect + + +func release(effect: GPUParticles2D) -> void: + if effect == null or not is_instance_valid(effect): + return + + var effect_type: StringName = effect.get_meta("particle_pool_type", &"") + if not EFFECT_SCENES.has(effect_type): + effect.queue_free() + return + + _active[effect_type].erase(effect) + if _available[effect_type].has(effect): + return + + effect.emitting = false + effect.visible = false + effect.position = Vector2.ZERO + + var parent := effect.get_parent() + if parent != null and parent != self: + parent.remove_child(effect) + if effect.get_parent() == null: + add_child(effect) + + _available[effect_type].append(effect) + + +func play( + effect_type: StringName, + world_position: Vector2, + target_parent: Node = null +) -> GPUParticles2D: + var effect := get(effect_type) + if effect == null: + return null + + var parent: Node = target_parent + if parent == null: + parent = get_tree().current_scene + if parent == null: + parent = get_tree().root + + var current_parent := effect.get_parent() + if current_parent != parent: + if current_parent != null: + current_parent.remove_child(effect) + parent.add_child(effect) + + effect.global_position = world_position + effect.visible = true + effect.restart() + effect.emitting = true + return effect + + +func _warm_pools() -> void: + for effect_type in EFFECT_SCENES.keys(): + _available[effect_type] = [] + _active[effect_type] = [] + for index in range(POOL_SIZE): + var effect := _create_effect(effect_type, index) + add_child(effect) + _available[effect_type].append(effect) + + +func _create_effect(effect_type: StringName, index: int) -> GPUParticles2D: + var effect: GPUParticles2D = EFFECT_SCENES[effect_type].instantiate() + effect.name = "%s_%02d" % [String(effect_type), index] + effect.visible = false + effect.emitting = false + effect.set_meta("particle_pool_type", effect_type) + effect.finished.connect(_on_effect_finished.bind(effect)) + return effect + + +func _reclaim_oldest(effect_type: StringName) -> void: + var active: Array = _active[effect_type] + if active.is_empty(): + return + + var effect: GPUParticles2D = active.pop_front() + release(effect) + + +func _on_effect_finished(effect: GPUParticles2D) -> void: + release(effect) diff --git a/godot/particle_fx_contract.test.mjs b/godot/particle_fx_contract.test.mjs new file mode 100644 index 0000000..17e701f --- /dev/null +++ b/godot/particle_fx_contract.test.mjs @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +function read(relativePath) { + return fs.readFileSync(path.join(__dirname, relativePath), "utf8"); +} + +function numberProperty(source, name) { + const match = source.match(new RegExp(`^${name} = ([0-9.]+)$`, "m")); + assert.ok(match, `expected ${name} in scene`); + return Number(match[1]); +} + +test("particle system is registered as Godot autoloads", () => { + const project = read("project.godot"); + + assert.match(project, /^ParticlePool="\*res:\/\/autoloads\/ParticlePool\.gd"$/m); + assert.match(project, /^ParticleManager="\*res:\/\/autoloads\/ParticleManager\.gd"$/m); +}); + +test("EventBus exposes gameplay events used by particle effects", () => { + const eventBus = read("autoloads/EventBus.gd"); + + assert.match(eventBus, /^signal unit_xp_gained\(/m); + assert.match(eventBus, /^signal resource_discovered\(/m); + assert.match(eventBus, /func emit_unit_xp_gained\(/); + assert.match(eventBus, /func emit_resource_discovered\(/); +}); + +test("particle pool exposes fixed-size get and release operations", () => { + const pool = read("autoloads/ParticlePool.gd"); + + assert.match(pool, /^const POOL_SIZE := 8$/m); + assert.match(pool, /func get\(effect_type: StringName\) -> GPUParticles2D:/); + assert.match(pool, /func release\(effect: GPUParticles2D\) -> void:/); + assert.match(pool, /func play\(/); +}); + +test("particle manager subscribes to EventBus instead of direct callers", () => { + const manager = read("autoloads/ParticleManager.gd"); + + for (const signalName of [ + "unit_attacked", + "unit_died", + "unit_promoted", + "unit_xp_gained", + "city_built", + "city_captured", + "production_complete", + "resource_discovered", + ]) { + assert.match(manager, new RegExp(`EventBus\\.${signalName}`)); + } + + for (const effectName of [ + "combat_explosion", + "city_build", + "xp_gain", + "level_up", + "resource_discovery", + "flag_capture", + ]) { + assert.match(manager, new RegExp(`&"${effectName}"`)); + } +}); + +test("all particle scenes stay inside the web performance budget", () => { + const scenes = [ + ["scenes/fx_combat_explosion.tscn", 150, 0.6], + ["scenes/fx_city_build.tscn", 100, 1.0], + ["scenes/fx_xp_gain.tscn", 50, 0.8], + ["scenes/fx_level_up.tscn", 200, 1.0], + ["scenes/fx_resource_discovery.tscn", 80, 0.9], + ["scenes/fx_flag_capture.tscn", 120, 0.9], + ]; + + for (const [scenePath, expectedAmount, expectedLifetime] of scenes) { + const scene = read(scenePath); + + assert.match(scene, /type="GPUParticles2D"/, `${scenePath} uses GPUParticles2D`); + assert.equal(numberProperty(scene, "amount"), expectedAmount, `${scenePath} amount`); + assert.equal(numberProperty(scene, "lifetime"), expectedLifetime, `${scenePath} lifetime`); + assert.ok(expectedAmount <= 200, `${scenePath} particle budget`); + assert.ok(expectedLifetime <= 1, `${scenePath} lifetime budget`); + assert.match(scene, /^one_shot = true$/m, `${scenePath} is one-shot`); + assert.match(scene, /^emitting = false$/m, `${scenePath} starts inactive`); + } +}); diff --git a/godot/project.godot b/godot/project.godot index c3782ce..3130860 100644 --- a/godot/project.godot +++ b/godot/project.godot @@ -19,6 +19,8 @@ GameState="*res://autoloads/GameState.gd" EconomyManager="*res://autoloads/EconomyManager.gd" TurnManager="*res://autoloads/TurnManager.gd" WebBridge="*res://autoloads/WebBridge.gd" +ParticlePool="*res://autoloads/ParticlePool.gd" +ParticleManager="*res://autoloads/ParticleManager.gd" [display] diff --git a/godot/scenes/fx_city_build.tscn b/godot/scenes/fx_city_build.tscn new file mode 100644 index 0000000..d4ef37e --- /dev/null +++ b/godot/scenes/fx_city_build.tscn @@ -0,0 +1,33 @@ +[gd_scene load_steps=4 format=3] + +[sub_resource type="Gradient" id="Gradient_city"] +offsets = PackedFloat32Array(0, 0.5, 1) +colors = PackedColorArray(Color(1, 0.95, 0.35, 1), Color(1, 0.63, 0.12, 0.75), Color(1, 0.9, 0.2, 0)) + +[sub_resource type="GradientTexture1D" id="GradientTexture1D_city"] +gradient = SubResource("Gradient_city") + +[sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_city"] +emission_shape = 1 +emission_sphere_radius = 18.0 +direction = Vector3(0, -1, 0) +spread = 65.0 +gravity = Vector3(0, -24, 0) +initial_velocity_min = 42.0 +initial_velocity_max = 92.0 +angular_velocity_min = -180.0 +angular_velocity_max = 180.0 +scale_min = 2.0 +scale_max = 4.0 +color = Color(1, 0.78, 0.18, 1) +color_ramp = SubResource("Gradient_city") + +[node name="FXCityBuild" type="GPUParticles2D"] +emitting = false +amount = 100 +lifetime = 1.0 +one_shot = true +explosiveness = 0.82 +randomness = 0.45 +process_material = SubResource("ParticleProcessMaterial_city") +texture = SubResource("GradientTexture1D_city") diff --git a/godot/scenes/fx_combat_explosion.tscn b/godot/scenes/fx_combat_explosion.tscn new file mode 100644 index 0000000..c24862e --- /dev/null +++ b/godot/scenes/fx_combat_explosion.tscn @@ -0,0 +1,31 @@ +[gd_scene load_steps=4 format=3] + +[sub_resource type="Gradient" id="Gradient_combat"] +offsets = PackedFloat32Array(0, 0.55, 1) +colors = PackedColorArray(Color(1, 0.9, 0.35, 1), Color(1, 0.22, 0.05, 0.85), Color(0.45, 0.02, 0, 0)) + +[sub_resource type="GradientTexture1D" id="GradientTexture1D_combat"] +gradient = SubResource("Gradient_combat") + +[sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_combat"] +emission_shape = 1 +emission_sphere_radius = 10.0 +direction = Vector3(0, -1, 0) +spread = 180.0 +gravity = Vector3(0, 60, 0) +initial_velocity_min = 110.0 +initial_velocity_max = 210.0 +scale_min = 2.0 +scale_max = 5.0 +color = Color(1, 0.34, 0.08, 1) +color_ramp = SubResource("Gradient_combat") + +[node name="FXCombatExplosion" type="GPUParticles2D"] +emitting = false +amount = 150 +lifetime = 0.6 +one_shot = true +explosiveness = 1.0 +randomness = 0.35 +process_material = SubResource("ParticleProcessMaterial_combat") +texture = SubResource("GradientTexture1D_combat") diff --git a/godot/scenes/fx_flag_capture.tscn b/godot/scenes/fx_flag_capture.tscn new file mode 100644 index 0000000..11351ac --- /dev/null +++ b/godot/scenes/fx_flag_capture.tscn @@ -0,0 +1,31 @@ +[gd_scene load_steps=4 format=3] + +[sub_resource type="Gradient" id="Gradient_capture"] +offsets = PackedFloat32Array(0, 0.6, 1) +colors = PackedColorArray(Color(1, 1, 1, 1), Color(0.95, 0.18, 0.22, 0.78), Color(0.3, 0.05, 0.08, 0)) + +[sub_resource type="GradientTexture1D" id="GradientTexture1D_capture"] +gradient = SubResource("Gradient_capture") + +[sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_capture"] +emission_shape = 1 +emission_sphere_radius = 14.0 +direction = Vector3(0, -1, 0) +spread = 115.0 +gravity = Vector3(0, 34, 0) +initial_velocity_min = 70.0 +initial_velocity_max = 130.0 +scale_min = 2.0 +scale_max = 4.8 +color = Color(1, 0.28, 0.32, 1) +color_ramp = SubResource("Gradient_capture") + +[node name="FXFlagCapture" type="GPUParticles2D"] +emitting = false +amount = 120 +lifetime = 0.9 +one_shot = true +explosiveness = 1.0 +randomness = 0.42 +process_material = SubResource("ParticleProcessMaterial_capture") +texture = SubResource("GradientTexture1D_capture") diff --git a/godot/scenes/fx_level_up.tscn b/godot/scenes/fx_level_up.tscn new file mode 100644 index 0000000..0b53baa --- /dev/null +++ b/godot/scenes/fx_level_up.tscn @@ -0,0 +1,31 @@ +[gd_scene load_steps=4 format=3] + +[sub_resource type="Gradient" id="Gradient_level"] +offsets = PackedFloat32Array(0, 0.45, 1) +colors = PackedColorArray(Color(1, 1, 0.72, 1), Color(1, 0.82, 0.05, 0.8), Color(1, 0.48, 0.05, 0)) + +[sub_resource type="GradientTexture1D" id="GradientTexture1D_level"] +gradient = SubResource("Gradient_level") + +[sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_level"] +emission_shape = 1 +emission_sphere_radius = 16.0 +direction = Vector3(0, -1, 0) +spread = 180.0 +gravity = Vector3(0, 22, 0) +initial_velocity_min = 95.0 +initial_velocity_max = 165.0 +scale_min = 2.5 +scale_max = 5.5 +color = Color(1, 0.92, 0.18, 1) +color_ramp = SubResource("Gradient_level") + +[node name="FXLevelUp" type="GPUParticles2D"] +emitting = false +amount = 200 +lifetime = 1.0 +one_shot = true +explosiveness = 1.0 +randomness = 0.32 +process_material = SubResource("ParticleProcessMaterial_level") +texture = SubResource("GradientTexture1D_level") diff --git a/godot/scenes/fx_resource_discovery.tscn b/godot/scenes/fx_resource_discovery.tscn new file mode 100644 index 0000000..cf3cd88 --- /dev/null +++ b/godot/scenes/fx_resource_discovery.tscn @@ -0,0 +1,31 @@ +[gd_scene load_steps=4 format=3] + +[sub_resource type="Gradient" id="Gradient_resource"] +offsets = PackedFloat32Array(0, 0.55, 1) +colors = PackedColorArray(Color(0.35, 0.85, 1, 1), Color(0.2, 0.55, 1, 0.75), Color(0.08, 0.2, 0.65, 0)) + +[sub_resource type="GradientTexture1D" id="GradientTexture1D_resource"] +gradient = SubResource("Gradient_resource") + +[sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_resource"] +emission_shape = 1 +emission_sphere_radius = 20.0 +direction = Vector3(0, -1, 0) +spread = 95.0 +gravity = Vector3(0, -12, 0) +initial_velocity_min = 48.0 +initial_velocity_max = 108.0 +scale_min = 1.6 +scale_max = 4.2 +color = Color(0.28, 0.68, 1, 1) +color_ramp = SubResource("Gradient_resource") + +[node name="FXResourceDiscovery" type="GPUParticles2D"] +emitting = false +amount = 80 +lifetime = 0.9 +one_shot = true +explosiveness = 0.85 +randomness = 0.5 +process_material = SubResource("ParticleProcessMaterial_resource") +texture = SubResource("GradientTexture1D_resource") diff --git a/godot/scenes/fx_xp_gain.tscn b/godot/scenes/fx_xp_gain.tscn new file mode 100644 index 0000000..b144ced --- /dev/null +++ b/godot/scenes/fx_xp_gain.tscn @@ -0,0 +1,31 @@ +[gd_scene load_steps=4 format=3] + +[sub_resource type="Gradient" id="Gradient_xp"] +offsets = PackedFloat32Array(0, 0.7, 1) +colors = PackedColorArray(Color(0.45, 1, 0.42, 1), Color(0.1, 0.85, 0.28, 0.75), Color(0.05, 0.45, 0.18, 0)) + +[sub_resource type="GradientTexture1D" id="GradientTexture1D_xp"] +gradient = SubResource("Gradient_xp") + +[sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_xp"] +emission_shape = 1 +emission_sphere_radius = 12.0 +direction = Vector3(0, -1, 0) +spread = 50.0 +gravity = Vector3(0, -18, 0) +initial_velocity_min = 38.0 +initial_velocity_max = 82.0 +scale_min = 1.8 +scale_max = 3.5 +color = Color(0.35, 1, 0.38, 1) +color_ramp = SubResource("Gradient_xp") + +[node name="FXXPGain" type="GPUParticles2D"] +emitting = false +amount = 50 +lifetime = 0.8 +one_shot = true +explosiveness = 0.9 +randomness = 0.35 +process_material = SubResource("ParticleProcessMaterial_xp") +texture = SubResource("GradientTexture1D_xp")