├── assets ├── cloud.png ├── glow1.png ├── cloud2.png ├── spark1.png ├── spark2.png ├── spark3.png └── splat1.png ├── images └── fireworks.gif ├── Cargo.toml ├── LICENSE-MIT ├── README.md ├── examples ├── greenvapor.omagari.ron ├── explode.omagari.ron ├── magicburst.omagari.ron └── fireworks.omagari.ron ├── src ├── complex.rs ├── helpers.rs ├── controller.rs ├── lib.rs ├── main.rs ├── effect.rs ├── expr.rs └── modifiers.rs └── LICENSE-APACHE2 /assets/cloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hexroll/omagari/HEAD/assets/cloud.png -------------------------------------------------------------------------------- /assets/glow1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hexroll/omagari/HEAD/assets/glow1.png -------------------------------------------------------------------------------- /assets/cloud2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hexroll/omagari/HEAD/assets/cloud2.png -------------------------------------------------------------------------------- /assets/spark1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hexroll/omagari/HEAD/assets/spark1.png -------------------------------------------------------------------------------- /assets/spark2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hexroll/omagari/HEAD/assets/spark2.png -------------------------------------------------------------------------------- /assets/spark3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hexroll/omagari/HEAD/assets/spark3.png -------------------------------------------------------------------------------- /assets/splat1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hexroll/omagari/HEAD/assets/splat1.png -------------------------------------------------------------------------------- /images/fireworks.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hexroll/omagari/HEAD/images/fireworks.gif -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "omagari" 3 | version = "0.16.1" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | rand = "0.9.1" 8 | serde = "1.0.228" 9 | ron = "0.10" 10 | bevy = { version = "0.17" } 11 | bevy_hanabi = { version = "0.17", features = ["3d", "serde"] } 12 | bevy_egui = { version = "0.38", optional = true } 13 | bevy_panorbit_camera = { version = "0.33", optional = true } 14 | regex = { version = "1.11.1", optional = true } 15 | 16 | [features] 17 | editor = ["bevy_egui", "bevy_panorbit_camera", "regex"] 18 | 19 | [[bin]] 20 | name = "omagari" 21 | path = "src/main.rs" 22 | required-features = ["editor"] 23 | 24 | [lib] 25 | name = "omagari" 26 | path = "src/lib.rs" 27 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Ithai Levi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OMAGARI 2 | Bevy-Hanabi 3D particle effects editor designed for the next version of https://hexroll.app. 3 | 4 | ![firework](https://raw.githubusercontent.com/hexroll/omagari/refs/heads/master/images/fireworks.gif) 5 | 6 | 7 | ## Background 8 | This editor was written as an internal tool for the development of hexroll3. Following requests by the Bevy community, it is now open-sourced as-is. 9 | 10 | ## Getting Started 11 | 12 | Clone this repository, and then 13 | 14 | ``` 15 | cd omagari/examples/ 16 | cargo run --release 17 | ``` 18 | 19 | - Omagari uses Hanabi's public API only, utilizing a set of serializable proxy editors that together compose a project file that you can save and load. 20 | 21 | - Omagari project files should be named `{project_name}.omagari.ron`. 22 | 23 | - Exporting an Omagari project will generate a custom `ron` file built from a set of serialized `EffectAssets` along with some additional metadata. 24 | 25 | - Exported files will be named `{project_name}.hanabi.ron`. 26 | 27 | - All delete ('`X`') buttons require right-click activation for safety. 28 | 29 | ## Compatibility 30 | 31 | | `Omagari` | `bevy_hanabi` | `bevy` | 32 | | :-- | :-- | :-- | 33 | | `0.16` | `0.16` | `0.16` | 34 | 35 | ## Contributions 36 | 37 | This code could use your care :) so contributions are more than welcome, and [showcasing](https://github.com/hexroll/omagari/discussions/1) your effects for others to learn from is highly encouraged. 38 | 39 | ## License 40 | 41 | Similar to Hanabi, Omagari is dual-licensed under either: 42 | 43 | - MIT License ([`LICENSE-MIT`](./LICENSE-MIT) 44 | - Apache License, Version 2.0 ([`LICENSE-APACHE2`](./LICENSE-APACHE2) 45 | 46 | at your option. 47 | 48 | `SPDX-License-Identifier: MIT OR Apache-2.0` 49 | -------------------------------------------------------------------------------- /examples/greenvapor.omagari.ron: -------------------------------------------------------------------------------- 1 | ( 2 | effects: [ 3 | ( 4 | name: "green vapor", 5 | parent: None, 6 | capacity: 500, 7 | spawner_settings: ( 8 | count: Single(500.0), 9 | spawn_duration: Single(386.0), 10 | period: Single(1.0), 11 | cycle_count: 1, 12 | starts_active: true, 13 | emit_on_start: true, 14 | ), 15 | texture_index: Some(1), 16 | init_modifiers: [ 17 | SetAttribute(( 18 | attr: "position", 19 | attr_expr: Operator(Subtract(RandVec3, Vec3((0.0, -20.0, 0.0)))), 20 | )), 21 | SetAttribute(( 22 | attr: "age", 23 | attr_expr: Float(0.0), 24 | )), 25 | SetAttribute(( 26 | attr: "lifetime", 27 | attr_expr: Float(8.0), 28 | )), 29 | SetAttribute(( 30 | attr: "velocity", 31 | attr_expr: Operator(Multiply(Operator(Normalized(Operator(Subtract(Operator(Multiply(RandVec3, Float(2.0))), Float(1.0))))), Float(0.41))), 32 | )), 33 | ], 34 | update_modifiers: [], 35 | render_modifiers: [ 36 | ColorOverLifetime(( 37 | gradient: ( 38 | g: [ 39 | (0.0, (0.2, 1.0, 0.0, 0.0)), 40 | (0.5, (0.2, 1.0, 0.0, 1.0)), 41 | (1.0, (0.2, 1.0, 0.0, 0.0)), 42 | ], 43 | ), 44 | blend: Some(Overwrite), 45 | mask: Some((15)), 46 | )), 47 | SizeOverLifetime(( 48 | gradient: ( 49 | g: [ 50 | (0.3, (5.0, 5.0, 5.0)), 51 | (1.0, (12.0, 12.0, 12.0)), 52 | ], 53 | ), 54 | )), 55 | ], 56 | ), 57 | ], 58 | ) -------------------------------------------------------------------------------- /examples/explode.omagari.ron: -------------------------------------------------------------------------------- 1 | ( 2 | effects: [ 3 | ( 4 | name: "Name your effect", 5 | parent: None, 6 | capacity: 2400, 7 | spawner_settings: ( 8 | count: Single(500.0), 9 | spawn_duration: Single(0.1), 10 | period: Single(1.0), 11 | cycle_count: 1, 12 | starts_active: true, 13 | emit_on_start: true, 14 | ), 15 | texture_index: Some(1), 16 | init_modifiers: [ 17 | SetPositionSphere(( 18 | center_expr: Vec3((0.0, 0.0, 0.0)), 19 | radius_expr: Float(0.28), 20 | dimension: Volume, 21 | )), 22 | SetAttribute(( 23 | attr: "velocity", 24 | attr_expr: Operator(Attr("position")), 25 | )), 26 | SetAttribute(( 27 | attr: "size", 28 | attr_expr: Float(0.45), 29 | )), 30 | SetAttribute(( 31 | attr: "age", 32 | attr_expr: Float(0.0), 33 | )), 34 | SetAttribute(( 35 | attr: "lifetime", 36 | attr_expr: Operator(Uniform(Float(2.1), Float(5.51))), 37 | )), 38 | ], 39 | update_modifiers: [], 40 | render_modifiers: [ 41 | ColorOverLifetime(( 42 | gradient: ( 43 | g: [ 44 | (0.0, (4.0, 4.0, 4.0, 0.0)), 45 | (0.05, (4.0, 4.0, 0.0, 1.0)), 46 | (0.11, (4.0, 0.0, 0.0, 0.5)), 47 | (0.3, (0.1, 0.1, 0.1, 0.2)), 48 | (1.0, (0.0, 0.0, 0.0, 0.0)), 49 | ], 50 | ), 51 | blend: Some(Overwrite), 52 | mask: Some((15)), 53 | )), 54 | SizeOverLifetime(( 55 | gradient: ( 56 | g: [ 57 | (1.0, (0.1, 0.1, 0.1)), 58 | (0.3, (0.45, 0.45, 0.45)), 59 | ], 60 | ), 61 | )), 62 | ], 63 | ), 64 | ], 65 | ) -------------------------------------------------------------------------------- /src/complex.rs: -------------------------------------------------------------------------------- 1 | use bevy::{platform::collections::HashMap, prelude::*}; 2 | use bevy_hanabi::prelude::*; 3 | use ron::from_str; 4 | use std::fs::File; 5 | use std::io; 6 | use std::io::Read; 7 | 8 | use crate::controller::ExportedProject; 9 | 10 | struct PreparedEffect { 11 | name: String, 12 | parent: Option, 13 | texture_index: Option, 14 | effect_handle: Handle, 15 | } 16 | 17 | struct EffectComplex { 18 | omagari_project: ExportedProject, 19 | prepared_effects: Vec, 20 | } 21 | 22 | impl EffectComplex { 23 | pub fn setup( 24 | filename: &str, 25 | mut effects: ResMut>, 26 | ) -> Result { 27 | let mut file = File::open(filename)?; 28 | let mut ron_string = String::new(); 29 | file.read_to_string(&mut ron_string)?; 30 | let omagari_project: ExportedProject = 31 | from_str(&ron_string).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; 32 | 33 | let mut prepared_effects: Vec = Vec::new(); 34 | for effect in omagari_project.effects.iter() { 35 | let h = effects.add(effect.effect_asset.clone()); 36 | prepared_effects.push(PreparedEffect { 37 | name: effect.name.to_string(), 38 | parent: effect.parent.clone(), 39 | texture_index: effect.texture_index.clone(), 40 | effect_handle: h.clone(), 41 | }); 42 | } 43 | Ok(Self { 44 | omagari_project, 45 | prepared_effects, 46 | }) 47 | } 48 | 49 | pub fn spawn(&self, commands: &mut Commands, textures: Vec>) { 50 | let mut refs: HashMap = HashMap::new(); 51 | for prepared_effect in self.prepared_effects.iter() { 52 | let mut e = commands.spawn(( 53 | Name::new(prepared_effect.name.clone()), 54 | ParticleEffect::new(prepared_effect.effect_handle.clone()), 55 | Transform::from_translation(Vec3::new(0.0, 0.0, 0.0)), 56 | )); 57 | 58 | if let Some(texture_index) = prepared_effect.texture_index { 59 | e.insert(EffectMaterial { 60 | images: vec![textures[texture_index].clone()], 61 | }); 62 | } 63 | 64 | refs.insert(prepared_effect.name.clone(), e.id()); 65 | 66 | if let Some(parent) = &prepared_effect.parent { 67 | if let Some(entity) = refs.get(parent) { 68 | e.insert(EffectParent::new(*entity)); 69 | } else { 70 | // Error 71 | } 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /examples/magicburst.omagari.ron: -------------------------------------------------------------------------------- 1 | ( 2 | effects: [ 3 | ( 4 | name: "Magic", 5 | capacity: 16384, 6 | spawner_settings: ( 7 | count: Single(2500.0), 8 | spawn_duration: Single(0.05), 9 | period: Single(0.0), 10 | cycle_count: 1, 11 | starts_active: true, 12 | emit_on_start: true, 13 | ), 14 | texture_index: Some(4), 15 | init_modifiers: [ 16 | SetAttribute(( 17 | id: 1, 18 | attr: "age", 19 | attr_expr: Float(0.0), 20 | )), 21 | SetAttribute(( 22 | id: 2, 23 | attr: "lifetime", 24 | attr_expr: Operator(Uniform(Float(0.6), Float(4.3))), 25 | )), 26 | SetPositionCircle(( 27 | center_expr: Vec3((0.0, 0.0, 0.0)), 28 | axis_expr: Vec3((0.0, 1.0, 0.0)), 29 | radius_expr: Float(0.2), 30 | dimension: Surface, 31 | )), 32 | SetVelocityCircle(( 33 | center_expr: Vec3((0.0, 0.0, 0.0)), 34 | axis_expr: Vec3((0.0, 1.0, 0.0)), 35 | speed_expr: Float(0.5), 36 | )), 37 | SetVelocityTangent(( 38 | origin_expr: Vec3((0.0, 0.0, 0.0)), 39 | axis_expr: Vec3((0.0, 1.0, 0.0)), 40 | speed_expr: Operator(Uniform(Float(0.2), Float(1.0))), 41 | )), 42 | ], 43 | update_modifiers: [ 44 | AccelModifier(( 45 | accel_expr: Operator(Subtract(Operator(Multiply(RandVec3, Vec3((2.0, 2.0, 2.0)))), Vec3((1.0, 1.0, 1.0)))), 46 | )), 47 | ], 48 | render_modifiers: [ 49 | SizeOverLifetime(( 50 | gradient: ( 51 | g: [ 52 | (0.3, (0.1, 0.1, 0.1)), 53 | (1.0, (1.0, 1.0, 1.0)), 54 | ], 55 | ), 56 | )), 57 | ColorOverLifetime(( 58 | gradient: ( 59 | g: [ 60 | (0.0, (0.0, 4.0, 4.0, 0.0)), 61 | (0.1, (0.0, 4.0, 4.0, 1.0)), 62 | (0.3, (4.0, 0.0, 0.0, 0.22)), 63 | (0.6, (4.0, 2.2, 4.15, 0.0)), 64 | (1.0, (4.0, 6.57, 4.35, 0.0)), 65 | ], 66 | ), 67 | )), 68 | ], 69 | ), 70 | ], 71 | ) 72 | -------------------------------------------------------------------------------- /src/helpers.rs: -------------------------------------------------------------------------------- 1 | use bevy::prelude::*; 2 | use bevy_egui::*; 3 | 4 | use crate::AppContext; 5 | 6 | pub trait UiProvider { 7 | fn draw_ui(&mut self, app: &mut AppContext, ui: &mut egui::Ui, index: u64); 8 | } 9 | 10 | pub fn ui_tools_for_expr_writer(label: &str, ui: &mut egui::Ui) -> ExprControl { 11 | ui.horizontal(|ui| { 12 | let delete_button = ui.button("❌").on_hover_text("Right-click to delete"); 13 | if delete_button.secondary_clicked() { 14 | return ExprControl::Delete; 15 | } 16 | 17 | if ui.button("🗐").clicked() { 18 | return ExprControl::Copy; 19 | } 20 | 21 | if !label.is_empty() { 22 | ui.add_space(5.0); // Optional spacing 23 | ui.label(label); 24 | } 25 | 26 | ExprControl::Noop 27 | }) 28 | .inner 29 | } 30 | 31 | pub fn ui_for_f32(ui: &mut egui::Ui, v: f32) -> f32 { 32 | let mut v = v; 33 | ui.add( 34 | egui::DragValue::new(&mut v) 35 | .speed(0.01) 36 | .range(-1000.0..=1000.0), 37 | ); 38 | v 39 | } 40 | 41 | pub fn ui_for_f32_ex(ui: &mut egui::Ui, v: f32, min: f32, max: f32, speed: f32) -> f32 { 42 | let mut v = v; 43 | ui.add(egui::DragValue::new(&mut v).speed(speed).range(min..=max)); 44 | v 45 | } 46 | 47 | pub fn ui_for_u32_ex(ui: &mut egui::Ui, v: u32, min: u32, max: u32, speed: u32) -> u32 { 48 | let mut v = v; 49 | ui.add(egui::DragValue::new(&mut v).speed(speed).range(min..=max)); 50 | v 51 | } 52 | 53 | pub fn _ui_for_num_ex(ui: &mut egui::Ui, v: T, min: T, max: T, speed: f32) -> T 54 | where 55 | T: egui::emath::Numeric, 56 | { 57 | let mut v = v; 58 | ui.add(egui::DragValue::new(&mut v).speed(speed).range(min..=max)); 59 | v 60 | } 61 | 62 | pub fn ui_for_vec3(ui: &mut egui::Ui, mut v: Vec3) -> Vec3 { 63 | ui.horizontal(|col_ui| { 64 | for i in 0..3 { 65 | col_ui.add_space(5.0); 66 | col_ui.add( 67 | egui::DragValue::new(&mut v[i]) 68 | .speed(0.01) 69 | .range(-1000.0..=1000.0), 70 | ); 71 | } 72 | col_ui.menu_button("xyz", |ui| { 73 | if ui.button("Vec3::X").clicked() { 74 | v = Vec3::X; 75 | } 76 | if ui.button("Vec3::Y").clicked() { 77 | v = Vec3::Y; 78 | } 79 | if ui.button("Vec3::Z").clicked() { 80 | v = Vec3::Z; 81 | } 82 | if ui.button("Vec3::ZERO").clicked() { 83 | v = Vec3::ZERO; 84 | } 85 | if ui.button("Vec3::ONE").clicked() { 86 | v = Vec3::ONE; 87 | } 88 | }); 89 | }); 90 | 91 | v 92 | } 93 | 94 | pub fn ui_for_vec4(ui: &mut egui::Ui, mut v: Vec4) -> Vec4 { 95 | ui.horizontal(|col_ui| { 96 | for i in 0..4 { 97 | col_ui.add_space(5.0); // Optional spacing 98 | 99 | col_ui.add( 100 | egui::DragValue::new(&mut v[i]) 101 | .speed(0.01) 102 | .range(0.0..=100.0), 103 | ); 104 | } 105 | }); 106 | v 107 | } 108 | 109 | pub fn ui_for_list_item(ui: &mut egui::Ui, index: usize, len: usize) -> Option { 110 | if ui 111 | .button("❌") 112 | .on_hover_text("Right-click to delete") 113 | .secondary_clicked() 114 | { 115 | return Some(ListCommand::Remove(index)); 116 | } 117 | 118 | if let Some(ret) = ui 119 | .add_enabled_ui(index > 0, |ui| { 120 | if ui.button("⬆").clicked() { 121 | Some(ListCommand::Swap((index, index - 1))) 122 | } else { 123 | None 124 | } 125 | }) 126 | .inner 127 | { 128 | return Some(ret); 129 | } 130 | 131 | if let Some(ret) = ui 132 | .add_enabled_ui(index < len - 1, |ui| { 133 | if ui.button("⬇").clicked() { 134 | Some(ListCommand::Swap((index, index + 1))) 135 | } else { 136 | None 137 | } 138 | }) 139 | .inner 140 | { 141 | return Some(ret); 142 | } 143 | 144 | None 145 | } 146 | 147 | pub fn unique_collapsing(salt_id: u64, text: &str, ui: &mut egui::Ui) -> egui::CollapsingHeader { 148 | let blend = format!("{}{}", text, salt_id); 149 | egui::CollapsingHeader::new(text).id_salt(ui.make_persistent_id(blend)) 150 | } 151 | 152 | pub enum ExprControl { 153 | Noop, 154 | Delete, 155 | Copy, 156 | } 157 | 158 | pub enum ListCommand { 159 | Remove(usize), 160 | Swap((usize, usize)), 161 | } 162 | 163 | impl ListCommand { 164 | pub fn apply(&self, list: &mut Vec) { 165 | match self { 166 | ListCommand::Remove(i) => { 167 | list.remove(*i); 168 | } 169 | ListCommand::Swap((a, b)) => { 170 | list.swap(*a, *b); 171 | } 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/controller.rs: -------------------------------------------------------------------------------- 1 | use bevy::{platform::collections::HashMap, prelude::*}; 2 | use bevy_hanabi::prelude::*; 3 | use ron::{de::from_str, ser::PrettyConfig}; 4 | use serde::{Deserialize, Serialize}; 5 | use std::{ 6 | cell::RefCell, 7 | fs::File, 8 | io::{self, Read, Write}, 9 | rc::Rc, 10 | }; 11 | use std::fs::create_dir; 12 | use std::path::PathBuf; 13 | 14 | use omagari::*; 15 | 16 | use crate::{effect::*, AppContext}; 17 | 18 | #[derive(Resource, Serialize, Deserialize, Default)] 19 | pub struct OmagariProject { 20 | pub effects: Vec, 21 | } 22 | 23 | #[derive(Resource)] 24 | pub struct EffectResource { 25 | pub effect_handles: Vec>, 26 | pub textures: Vec>, 27 | pub context: AppContext, 28 | } 29 | 30 | pub fn spawn_particle_effects( 31 | commands: &mut Commands, 32 | res: &mut EffectResource, 33 | clone: Rc>, 34 | mut effects: ResMut>, 35 | curr: Query>, 36 | ) { 37 | for h in res.effect_handles.iter() { 38 | effects.remove(h); 39 | } 40 | for e in curr.iter() { 41 | commands.entity(e).despawn(); 42 | } 43 | let mut refs: HashMap = HashMap::new(); 44 | for effect in clone.borrow().effects.iter() { 45 | let h = effects.add(effect.produce()); 46 | res.effect_handles.push(h.clone()); 47 | let mut e = commands.spawn(( 48 | ParticleEffect::new(h.clone()), 49 | EffectMaterial { 50 | images: vec![res.textures[effect.texture_index().unwrap_or(0)].clone()], 51 | }, 52 | Transform::from_translation(Vec3::new(0.0, 0.0, 0.0)), 53 | )); 54 | refs.insert(effect.name().to_string(), e.id()); 55 | 56 | if let Some(parent) = &effect.parent() { 57 | if let Some(entity) = refs.get(parent) { 58 | e.insert(EffectParent::new(*entity)); 59 | } else { 60 | // Error 61 | } 62 | } 63 | } 64 | } 65 | 66 | pub fn despawn_all_particle_effects( 67 | ongoing_effects: &Query>, 68 | commands: &mut Commands 69 | ){ 70 | for effect_entity in ongoing_effects{ 71 | commands.entity(effect_entity).try_despawn(); 72 | } 73 | } 74 | 75 | pub fn export_effects_to_files(filename: &str, clone: Rc>) { 76 | let base = filename.split('.').next().unwrap(); 77 | let other_filename = format!("{}.hanabi.ron", base); 78 | let mut to_export = ExportedProject::default(); 79 | for effect in clone.borrow().effects.iter() { 80 | to_export.effects.push(ExportedEffect { 81 | name: effect.name().to_string(), 82 | parent: effect.parent().clone(), 83 | texture_index: effect.texture_index(), 84 | effect_asset: effect.produce(), 85 | }); 86 | } 87 | let ron_string = 88 | ron::ser::to_string_pretty(&to_export, PrettyConfig::new().new_line("\n".to_string())) 89 | .unwrap(); 90 | let file_path = Folder::ExportedEffects.full_file_path(other_filename); 91 | let mut file = File::create(file_path).unwrap(); 92 | file.write_all(ron_string.as_bytes()).unwrap(); 93 | } 94 | 95 | pub fn projects_list() -> Vec { 96 | let mut files = Vec::new(); 97 | Folder::SavedEffects.make_folder(); 98 | let saved_effects_path = String::from(Folder::SavedEffects.to_path()); 99 | let entries = std::fs::read_dir(saved_effects_path).unwrap(); 100 | for entry in entries { 101 | let entry = entry.unwrap(); 102 | let filename = entry.file_name(); 103 | if filename.to_string_lossy().ends_with(".omagari.ron") { 104 | files.push(filename.to_string_lossy().into_owned()); 105 | } 106 | } 107 | files 108 | } 109 | 110 | pub fn load_project(filename: &str) -> Result { 111 | let file_path = Folder::SavedEffects.full_file_path(String::from(filename)); 112 | let mut file = File::open(file_path)?; 113 | let mut ron_string = String::new(); 114 | file.read_to_string(&mut ron_string)?; 115 | let graph: OmagariProject = 116 | from_str(&ron_string).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; 117 | Ok(graph) 118 | } 119 | 120 | pub fn validate_project_filename(filename: &str) -> bool { 121 | regex::Regex::new(r".*\.omagari\.ron") 122 | .unwrap() 123 | .captures(&filename) 124 | .is_some() 125 | } 126 | 127 | 128 | type FolderPath = &'static str; 129 | pub enum Folder{ 130 | SavedEffects, 131 | ExportedEffects, 132 | } 133 | 134 | impl Folder{ 135 | pub fn make_folder(&self) -> FolderPath{ 136 | let folder_path = self.to_path(); 137 | let _ = create_dir(String::from(folder_path)); 138 | folder_path 139 | } 140 | 141 | pub fn full_file_path(&self, file_name: String) -> PathBuf{ 142 | let folder_path = self.make_folder(); 143 | PathBuf::from(String::from(folder_path)).join(file_name) 144 | } 145 | 146 | pub fn to_path(&self) -> FolderPath { 147 | match self{ 148 | Folder::SavedEffects => "saved_effects/", 149 | Folder::ExportedEffects => "exported_effects/", 150 | } 151 | } 152 | } 153 | 154 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use bevy::{asset::AssetLoader, platform::collections::HashMap, prelude::*}; 2 | use bevy_hanabi::prelude::*; 3 | use ron::de::from_bytes; 4 | use serde::{Deserialize, Serialize}; 5 | use std::{ 6 | io::{self}, time::Duration 7 | }; 8 | 9 | /// OmagariPlugin is a Bevy plugin for managing and despawning exported Omagari effects from assets. 10 | pub struct OmagariPlugin; 11 | 12 | impl Plugin for OmagariPlugin { 13 | /// Configures the Bevy App to use the OmagariPlugin. 14 | /// It sets systems for effect despawning and initializes effect assets and loaders. 15 | fn build(&self, app: &mut App) { 16 | app.add_systems(Update, despawn_effects_on_timer) 17 | .init_asset::() 18 | .init_asset_loader::(); 19 | } 20 | } 21 | 22 | /// EffectComplex represents a collection of prepared effects in the application. 23 | #[derive(Asset, TypePath, Debug)] 24 | pub struct EffectComplex { 25 | prepared_effects: Vec, 26 | } 27 | 28 | impl EffectComplex { 29 | /// Spawns effects based on the configuration stored in EffectComplex. 30 | /// 31 | /// # Parameters 32 | /// - `commands`: Mutable reference to Commands used to spawn entities. 33 | /// - `textures`: Vector of image handles used to apply textures to the effects. 34 | /// - `pos`: Position to spawn the effects at. 35 | /// - `despawn_in`: Optional duration for when to despawn the effects. 36 | pub fn spawn( 37 | &self, 38 | commands: &mut Commands, 39 | textures: &Vec>, 40 | pos: Vec3, 41 | despawn_in: Option, 42 | ) { 43 | let mut refs: HashMap = HashMap::new(); 44 | for prepared_effect in self.prepared_effects.iter() { 45 | let mut e = commands.spawn(( 46 | Name::new(prepared_effect.name.clone()), 47 | ParticleEffect::new(prepared_effect.effect_handle.clone()), 48 | Transform::from_translation(pos), 49 | )); 50 | 51 | if let Some(texture_index) = prepared_effect.texture_index { 52 | e.insert(EffectMaterial { 53 | images: vec![textures[texture_index].clone()], 54 | }); 55 | } 56 | 57 | if let Some(despawn_in) = despawn_in { 58 | e.insert(EffectDespawnTimer(Timer::from_seconds( 59 | despawn_in.as_secs_f32(), 60 | TimerMode::Once, 61 | ))); 62 | } 63 | 64 | refs.insert(prepared_effect.name.clone(), e.id()); 65 | 66 | if let Some(parent) = &prepared_effect.parent { 67 | if let Some(entity) = refs.get(parent) { 68 | e.insert(EffectParent::new(*entity)); 69 | } else { 70 | // TODO: Raise an error 71 | } 72 | } 73 | } 74 | } 75 | 76 | /// Creates an EffectComplex from raw byte data. 77 | /// 78 | /// # Parameters 79 | /// - `bytes`: Vector of bytes representing the effect configuration. 80 | /// - `load_context`: Mutable reference to LoadContext for managing assets during loading. 81 | /// 82 | /// # Returns 83 | /// - Result containing either the EffectComplex or an IO error. 84 | fn from_bytes(bytes: Vec, load_context: &mut bevy::asset::LoadContext<'_>) -> Result { 85 | let omagari_project: ExportedProject = 86 | from_bytes(&bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; 87 | 88 | let mut prepared_effects: Vec = Vec::new(); 89 | for effect in omagari_project.effects.iter() { 90 | let h = load_context.add_labeled_asset("EffectAsset".to_string(), effect.effect_asset.clone()); 91 | prepared_effects.push(PreparedEffect { 92 | name: effect.name.to_string(), 93 | parent: effect.parent.clone(), 94 | texture_index: effect.texture_index.clone(), 95 | effect_handle: h.clone(), 96 | }); 97 | } 98 | Ok(Self { prepared_effects }) 99 | } 100 | } 101 | 102 | /// System function that despawns effects whose timers have expired. 103 | /// 104 | /// # Parameters 105 | /// - `commands`: Commands used to remove entities. 106 | /// - `effects_with_timer`: Query for entities with an EffectDespawnTimer component. 107 | /// - `time`: A reference to the current time resource. 108 | fn despawn_effects_on_timer( 109 | mut commands: Commands, 110 | mut effects_with_timer: Query<(Entity, &mut EffectDespawnTimer)>, 111 | time: Res