├── .gitignore ├── AUTHORS.md ├── LICENSE ├── README.md ├── demo ├── main.py └── media │ ├── drugs.pex │ ├── fire.pex │ ├── jellyfish.pex │ ├── particle.pex │ ├── particle.png │ └── sun.pex ├── kivyparticle ├── __init__.py ├── engine.py └── utils.py ├── run-tests.py └── test ├── __init__.py ├── config.pex ├── engine.py └── media ├── config.pex └── particle.png /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | TODO 3 | .vscode/launch.json 4 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | Kivy-Particle was originally written by Alexis Couronne and greatly inspired by [Starling Extension Particle System](https://github.com/PrimaryFeather/Starling-Extension-Particle-System) 2 | 3 | ##Development Lead 4 | 5 | * Alexis Couronne <[alexis.couronne@gmail](mailto:alexis.couronne@gmail)> 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Alexis Couronne 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #Kivy Particle 2 | 3 | Kivy Particle is an implementation of [Starling Extension Particle System](https://github.com/PrimaryFeather/Starling-Extension-Particle-System) for Kivy Python framework. 4 | 5 | (Work in Progress) 6 | 7 | * 2022-02-25 8 | * update to run by Python 3.9 9 | * maxint replaced by maxsize 10 | * minor fixes on App path and on media files path 11 | * gitignore updated: included vscode directory 12 | -------------------------------------------------------------------------------- /demo/main.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import sys 4 | from os.path import abspath, dirname, join 5 | 6 | DIR_APP = join( 7 | dirname(__file__), 8 | '..' 9 | ) 10 | 11 | sys.path.insert( 12 | 0, 13 | abspath(DIR_APP) 14 | ) 15 | 16 | 17 | import kivy 18 | kivy.require('1.5.1') 19 | 20 | from kivy.app import App 21 | from kivy.uix.boxlayout import BoxLayout 22 | from kivy.uix.gridlayout import GridLayout 23 | from kivy.uix.button import Button 24 | from kivy.uix.widget import Widget 25 | 26 | from kivyparticle import ParticleSystem 27 | 28 | 29 | class DemoParticle(Widget): 30 | def __init__(self, **kwargs): 31 | super(DemoParticle, self).__init__(**kwargs) 32 | self.sun = ParticleSystem(abspath(join(DIR_APP,'demo/media/sun.pex'))) 33 | self.drugs = ParticleSystem(abspath(join(DIR_APP,'demo/media/drugs.pex'))) 34 | self.jellyfish = ParticleSystem(abspath(join(DIR_APP,'demo/media/jellyfish.pex'))) 35 | self.fire = ParticleSystem(abspath(join(DIR_APP,'demo/media/fire.pex'))) 36 | 37 | self.current = None 38 | self._show(self.sun) 39 | 40 | def on_touch_down(self, touch): 41 | self.current.emitter_x = float(touch.x) 42 | self.current.emitter_y = float(touch.y) 43 | 44 | def on_touch_move(self, touch): 45 | self.current.emitter_x = float(touch.x) 46 | self.current.emitter_y = float(touch.y) 47 | 48 | def show_sun(self, b): 49 | self._show(self.sun) 50 | 51 | def show_drugs(self, b): 52 | self._show(self.drugs) 53 | 54 | def show_jellyfish(self, b): 55 | self._show(self.jellyfish) 56 | 57 | def show_fire(self, b): 58 | self._show(self.fire) 59 | 60 | def _show(self, system): 61 | if self.current: 62 | self.remove_widget(self.current) 63 | self.current.stop(True) 64 | self.current = system 65 | 66 | self.current.emitter_x = 300.0 67 | self.current.emitter_y = 300.0 68 | self.add_widget(self.current) 69 | self.current.start() 70 | 71 | 72 | class DemoParticleApp(App): 73 | def build(self): 74 | root = GridLayout(cols=2) 75 | paint = DemoParticle(size_hint_x=None, width=600) 76 | root.add_widget(paint) 77 | buttons = BoxLayout(orientation='vertical') 78 | root.add_widget(buttons) 79 | 80 | sun = Button(text='Sun') 81 | sun.bind(on_press=paint.show_sun) 82 | buttons.add_widget(sun) 83 | 84 | drugs = Button(text='Drugs') 85 | drugs.bind(on_press=paint.show_drugs) 86 | buttons.add_widget(drugs) 87 | 88 | jellyfish = Button(text='JellyFish') 89 | jellyfish.bind(on_press=paint.show_jellyfish) 90 | buttons.add_widget(jellyfish) 91 | 92 | fire = Button(text='Fire') 93 | fire.bind(on_press=paint.show_fire) 94 | buttons.add_widget(fire) 95 | 96 | return root 97 | 98 | 99 | if __name__ == '__main__': 100 | DemoParticleApp().run() 101 | -------------------------------------------------------------------------------- /demo/media/drugs.pex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /demo/media/fire.pex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /demo/media/jellyfish.pex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /demo/media/particle.pex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /demo/media/particle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skitoo/kivy-particle/0e287e93da86d48e00f59820f83b2a11a057bee4/demo/media/particle.png -------------------------------------------------------------------------------- /demo/media/sun.pex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /kivyparticle/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import kivy 4 | kivy.require('1.4.1') 5 | 6 | __title__ = 'kivyparticle' 7 | __version__ = '0.1' 8 | __author__ = 'Alexis Couronne' 9 | __all__ = ['ParticleSystem', 'EMITTER_TYPE_GRAVITY', 'EMITTER_TYPE_RADIAL'] 10 | 11 | from .engine import ParticleSystem, EMITTER_TYPE_GRAVITY, EMITTER_TYPE_RADIAL 12 | -------------------------------------------------------------------------------- /kivyparticle/engine.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # 2022-02-25 - Python 3 remove maxint - replaced by maxsize - https://stackoverflow.com/a/13795777 4 | 5 | from kivy.uix.widget import Widget 6 | from kivy.clock import Clock 7 | from kivy.graphics import Color, Callback, Rotate, PushMatrix, PopMatrix, Translate, Quad 8 | from kivy.graphics.opengl import glBlendFunc, GL_SRC_ALPHA, GL_ONE, GL_ZERO, GL_SRC_COLOR, GL_ONE_MINUS_SRC_COLOR, GL_ONE_MINUS_SRC_ALPHA, GL_DST_ALPHA, GL_ONE_MINUS_DST_ALPHA, GL_DST_COLOR, GL_ONE_MINUS_DST_COLOR 9 | from kivy.core.image import Image 10 | from kivy.logger import Logger 11 | from xml.dom.minidom import parse as parse_xml 12 | from .utils import random_variance, random_color_variance 13 | from kivy.properties import NumericProperty, BooleanProperty, ListProperty, StringProperty, ObjectProperty 14 | 15 | import sys 16 | import os 17 | import math 18 | 19 | __all__ = ['EMITTER_TYPE_GRAVITY', 'EMITTER_TYPE_RADIAL', 'Particle', 'ParticleSystem'] 20 | 21 | 22 | EMITTER_TYPE_GRAVITY = 0 23 | EMITTER_TYPE_RADIAL = 1 24 | 25 | BLEND_FUNC = {0: GL_ZERO, 26 | 1: GL_ONE, 27 | 0x300: GL_SRC_COLOR, 28 | 0x301: GL_ONE_MINUS_SRC_COLOR, 29 | 0x302: GL_SRC_ALPHA, 30 | 0x303: GL_ONE_MINUS_SRC_ALPHA, 31 | 0x304: GL_DST_ALPHA, 32 | 0x305: GL_ONE_MINUS_DST_ALPHA, 33 | 0x306: GL_DST_COLOR, 34 | 0x307: GL_ONE_MINUS_DST_COLOR 35 | } 36 | 37 | 38 | class Particle(object): 39 | x, y, rotation, current_time = -256, -256, 0, 0 40 | scale, total_time = 1.0, 0. 41 | color = [1.0, 1.0, 1.0, 1.0] 42 | color_delta = [0.0, 0.0, 0.0, 0.0] 43 | start_x, start_y, velocity_x, velocity_y = 0, 0, 0, 0 44 | radial_acceleration, tangent_acceleration = 0, 0 45 | emit_radius, emit_radius_delta = 0, 0 46 | emit_rotation, emit_rotation_delta = 0, 0 47 | rotation_delta, scale_delta = 0, 0 48 | 49 | 50 | class ParticleSystem(Widget): 51 | max_num_particles = NumericProperty(200) 52 | life_span = NumericProperty(2) 53 | texture = ObjectProperty(None) 54 | texture_path = StringProperty(None) 55 | life_span_variance = NumericProperty(0) 56 | start_size = NumericProperty(16) 57 | start_size_variance = NumericProperty(0) 58 | end_size = NumericProperty(16) 59 | end_size_variance = NumericProperty(0) 60 | emit_angle = NumericProperty(0) 61 | emit_angle_variance = NumericProperty(0) 62 | start_rotation = NumericProperty(0) 63 | start_rotation_variance = NumericProperty(0) 64 | end_rotation = NumericProperty(0) 65 | end_rotation_variance = NumericProperty(0) 66 | emitter_x_variance = NumericProperty(100) 67 | emitter_y_variance = NumericProperty(100) 68 | gravity_x = NumericProperty(0) 69 | gravity_y = NumericProperty(0) 70 | speed = NumericProperty(0) 71 | speed_variance = NumericProperty(0) 72 | radial_acceleration = NumericProperty(100) 73 | radial_acceleration_variance = NumericProperty(0) 74 | tangential_acceleration = NumericProperty(0) 75 | tangential_acceleration_variance = NumericProperty(0) 76 | max_radius = NumericProperty(100) 77 | max_radius_variance = NumericProperty(0) 78 | min_radius = NumericProperty(50) 79 | rotate_per_second = NumericProperty(0) 80 | rotate_per_second_variance = NumericProperty(0) 81 | start_color = ListProperty([1., 1., 1., 1.]) 82 | start_color_variance = ListProperty([1., 1., 1., 1.]) 83 | end_color = ListProperty([1., 1., 1., 1.]) 84 | end_color_variance = ListProperty([1., 1., 1., 1.]) 85 | blend_factor_source = NumericProperty(770) 86 | blend_factor_dest = NumericProperty(1) 87 | emitter_type = NumericProperty(0) 88 | 89 | update_interval = NumericProperty(1. / 30.) 90 | _is_paused = BooleanProperty(False) 91 | 92 | def __init__(self, config, **kwargs): 93 | super(ParticleSystem, self).__init__(**kwargs) 94 | self.capacity = 0 95 | self.particles = list() 96 | self.particles_dict = dict() 97 | self.emission_time = 0.0 98 | self.frame_time = 0.0 99 | self.num_particles = 0 100 | 101 | if config is not None: 102 | self._parse_config(config) 103 | self.emission_rate = self.max_num_particles / self.life_span 104 | self.initial_capacity = self.max_num_particles 105 | self.max_capacity = self.max_num_particles 106 | self._raise_capacity(self.initial_capacity) 107 | 108 | with self.canvas.before: 109 | Callback(self._set_blend_func) 110 | with self.canvas.after: 111 | Callback(self._reset_blend_func) 112 | 113 | Clock.schedule_once(self._update, self.update_interval) 114 | 115 | def start(self, duration=sys.maxsize): 116 | if self.emission_rate != 0: 117 | self.emission_time = duration 118 | 119 | def stop(self, clear=False): 120 | self.emission_time = 0.0 121 | if clear: 122 | self.num_particles = 0 123 | self.particles_dict = dict() 124 | self.canvas.clear() 125 | 126 | def on_max_num_particles(self, instance, value): 127 | self.max_capacity = value 128 | if self.capacity < value: 129 | self._raise_capacity(self.max_capacity - self.capacity) 130 | elif self.capacity > value: 131 | self._lower_capacity(self.capacity - self.max_capacity) 132 | self.emission_rate = self.max_num_particles / self.life_span 133 | 134 | def on_texture(self, instance, value): 135 | for p in self.particles: 136 | try: 137 | self.particles_dict[p]['rect'].texture = self.texture 138 | except KeyError: 139 | # if particle isn't initialized yet, you can't change its texture. 140 | pass 141 | 142 | def on_life_span(self, instance, value): 143 | self.emission_rate = self.max_num_particles / value 144 | 145 | def _set_blend_func(self, instruction): 146 | #glBlendFunc(self.blend_factor_source, self.blend_factor_dest) 147 | #glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) 148 | glBlendFunc(GL_SRC_ALPHA, GL_ONE) 149 | 150 | def _reset_blend_func(self, instruction): 151 | glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) 152 | 153 | def _parse_config(self, config): 154 | self._config = parse_xml(config) 155 | 156 | texture_path = self._parse_data('texture', 'name') 157 | config_dir_path = os.path.dirname(os.path.abspath(config)) 158 | path = os.path.join(config_dir_path, texture_path) 159 | if os.path.exists(path): 160 | self.texture_path = path 161 | else: 162 | self.texture_path = texture_path 163 | 164 | self.texture = Image(self.texture_path).texture 165 | self.emitter_x = float(self._parse_data('sourcePosition', 'x')) 166 | self.emitter_y = float(self._parse_data('sourcePosition', 'y')) 167 | self.emitter_x_variance = float(self._parse_data('sourcePositionVariance', 'x')) 168 | self.emitter_y_variance = float(self._parse_data('sourcePositionVariance', 'y')) 169 | self.gravity_x = float(self._parse_data('gravity', 'x')) 170 | self.gravity_y = float(self._parse_data('gravity', 'y')) 171 | self.emitter_type = int(self._parse_data('emitterType')) 172 | self.max_num_particles = int(self._parse_data('maxParticles')) 173 | self.life_span = max(0.01, float(self._parse_data('particleLifeSpan'))) 174 | self.life_span_variance = float(self._parse_data('particleLifespanVariance')) 175 | self.start_size = float(self._parse_data('startParticleSize')) 176 | self.start_size_variance = float(self._parse_data('startParticleSizeVariance')) 177 | self.end_size = float(self._parse_data('finishParticleSize')) 178 | self.end_size_variance = float(self._parse_data('FinishParticleSizeVariance')) 179 | self.emit_angle = math.radians(float(self._parse_data('angle'))) 180 | self.emit_angle_variance = math.radians(float(self._parse_data('angleVariance'))) 181 | self.start_rotation = math.radians(float(self._parse_data('rotationStart'))) 182 | self.start_rotation_variance = math.radians(float(self._parse_data('rotationStartVariance'))) 183 | self.end_rotation = math.radians(float(self._parse_data('rotationEnd'))) 184 | self.end_rotation_variance = math.radians(float(self._parse_data('rotationEndVariance'))) 185 | self.speed = float(self._parse_data('speed')) 186 | self.speed_variance = float(self._parse_data('speedVariance')) 187 | self.radial_acceleration = float(self._parse_data('radialAcceleration')) 188 | self.radial_acceleration_variance = float(self._parse_data('radialAccelVariance')) 189 | self.tangential_acceleration = float(self._parse_data('tangentialAcceleration')) 190 | self.tangential_acceleration_variance = float(self._parse_data('tangentialAccelVariance')) 191 | self.max_radius = float(self._parse_data('maxRadius')) 192 | self.max_radius_variance = float(self._parse_data('maxRadiusVariance')) 193 | self.min_radius = float(self._parse_data('minRadius')) 194 | self.rotate_per_second = math.radians(float(self._parse_data('rotatePerSecond'))) 195 | self.rotate_per_second_variance = math.radians(float(self._parse_data('rotatePerSecondVariance'))) 196 | self.start_color = self._parse_color('startColor') 197 | self.start_color_variance = self._parse_color('startColorVariance') 198 | self.end_color = self._parse_color('finishColor') 199 | self.end_color_variance = self._parse_color('finishColorVariance') 200 | self.blend_factor_source = self._parse_blend('blendFuncSource') 201 | self.blend_factor_dest = self._parse_blend('blendFuncDestination') 202 | 203 | def _parse_data(self, name, attribute='value'): 204 | return self._config.getElementsByTagName(name)[0].getAttribute(attribute) 205 | 206 | def _parse_color(self, name): 207 | return [float(self._parse_data(name, 'red')), float(self._parse_data(name, 'green')), float(self._parse_data(name, 'blue')), float(self._parse_data(name, 'alpha'))] 208 | 209 | def _parse_blend(self, name): 210 | value = int(self._parse_data(name)) 211 | return BLEND_FUNC[value] 212 | 213 | def pause(self): 214 | self._is_paused = True 215 | 216 | def resume(self): 217 | self._is_paused = False 218 | Clock.schedule_once(self._update, self.update_interval) 219 | 220 | def _update(self, dt): 221 | self._advance_time(dt) 222 | self._render() 223 | if not self._is_paused: 224 | Clock.schedule_once(self._update, self.update_interval) 225 | 226 | def _create_particle(self): 227 | return Particle() 228 | 229 | def _init_particle(self, particle): 230 | life_span = random_variance(self.life_span, self.life_span_variance) 231 | if life_span <= 0.0: 232 | return 233 | 234 | particle.current_time = 0.0 235 | particle.total_time = life_span 236 | 237 | particle.x = random_variance(self.emitter_x, self.emitter_x_variance) 238 | particle.y = random_variance(self.emitter_y, self.emitter_y_variance) 239 | particle.start_x = self.emitter_x 240 | particle.start_y = self.emitter_y 241 | 242 | angle = random_variance(self.emit_angle, self.emit_angle_variance) 243 | speed = random_variance(self.speed, self.speed_variance) 244 | particle.velocity_x = speed * math.cos(angle) 245 | particle.velocity_y = speed * math.sin(angle) 246 | 247 | particle.emit_radius = random_variance(self.max_radius, self.max_radius_variance) 248 | particle.emit_radius_delta = (self.max_radius - self.min_radius) / life_span 249 | 250 | particle.emit_rotation = random_variance(self.emit_angle, self.emit_angle_variance) 251 | particle.emit_rotation_delta = random_variance(self.rotate_per_second, self.rotate_per_second_variance) 252 | 253 | particle.radial_acceleration = random_variance(self.radial_acceleration, self.radial_acceleration_variance) 254 | particle.tangent_acceleration = random_variance(self.tangential_acceleration, self.tangential_acceleration_variance) 255 | 256 | start_size = random_variance(self.start_size, self.start_size_variance) 257 | end_size = random_variance(self.end_size, self.end_size_variance) 258 | 259 | start_size = max(0.1, start_size) 260 | end_size = max(0.1, end_size) 261 | 262 | particle.scale = start_size / self.texture.width 263 | particle.scale_delta = ((end_size - start_size) / life_span) / self.texture.width 264 | 265 | # colors 266 | start_color = random_color_variance(self.start_color, self.start_color_variance) 267 | end_color = random_color_variance(self.end_color, self.end_color_variance) 268 | 269 | particle.color_delta = [(end_color[i] - start_color[i]) / life_span for i in range(4)] 270 | particle.color = start_color 271 | 272 | # rotation 273 | start_rotation = random_variance(self.start_rotation, self.start_rotation_variance) 274 | end_rotation = random_variance(self.end_rotation, self.end_rotation_variance) 275 | particle.rotation = start_rotation 276 | particle.rotation_delta = (end_rotation - start_rotation) / life_span 277 | 278 | def _advance_particle(self, particle, passed_time): 279 | passed_time = min(passed_time, particle.total_time - particle.current_time) 280 | particle.current_time += passed_time 281 | 282 | if self.emitter_type == EMITTER_TYPE_RADIAL: 283 | particle.emit_rotation += particle.emit_rotation_delta * passed_time 284 | particle.emit_radius -= particle.emit_radius_delta * passed_time 285 | particle.x = self.emitter_x - math.cos(particle.emit_rotation) * particle.emit_radius 286 | particle.y = self.emitter_y - math.sin(particle.emit_rotation) * particle.emit_radius 287 | 288 | if particle.emit_radius < self.min_radius: 289 | particle.current_time = particle.total_time 290 | 291 | else: 292 | distance_x = particle.x - particle.start_x 293 | distance_y = particle.y - particle.start_y 294 | distance_scalar = math.sqrt(distance_x * distance_x + distance_y * distance_y) 295 | if distance_scalar < 0.01: 296 | distance_scalar = 0.01 297 | 298 | radial_x = distance_x / distance_scalar 299 | radial_y = distance_y / distance_scalar 300 | tangential_x = radial_x 301 | tangential_y = radial_y 302 | 303 | radial_x *= particle.radial_acceleration 304 | radial_y *= particle.radial_acceleration 305 | 306 | new_y = tangential_x 307 | tangential_x = -tangential_y * particle.tangent_acceleration 308 | tangential_y = new_y * particle.tangent_acceleration 309 | 310 | particle.velocity_x += passed_time * (self.gravity_x + radial_x + tangential_x) 311 | particle.velocity_y += passed_time * (self.gravity_y + radial_y + tangential_y) 312 | 313 | particle.x += particle.velocity_x * passed_time 314 | particle.y += particle.velocity_y * passed_time 315 | 316 | particle.scale += particle.scale_delta * passed_time 317 | particle.rotation += particle.rotation_delta * passed_time 318 | 319 | particle.color = [particle.color[i] + particle.color_delta[i] * passed_time for i in range(4)] 320 | 321 | def _raise_capacity(self, by_amount): 322 | old_capacity = self.capacity 323 | new_capacity = min(self.max_capacity, self.capacity + by_amount) 324 | 325 | for i in range(int(new_capacity - old_capacity)): 326 | self.particles.append(self._create_particle()) 327 | 328 | self.num_particles = int(new_capacity) 329 | self.capacity = new_capacity 330 | 331 | def _lower_capacity(self, by_amount): 332 | old_capacity = self.capacity 333 | new_capacity = max(0, self.capacity - by_amount) 334 | 335 | for i in range(int(old_capacity - new_capacity)): 336 | try: 337 | self.canvas.remove(self.particles_dict[self.particles.pop()]['rect']) 338 | except: 339 | pass 340 | 341 | self.num_particles = int(new_capacity) 342 | self.capacity = new_capacity 343 | 344 | def _advance_time(self, passed_time): 345 | particle_index = 0 346 | 347 | # advance existing particles 348 | while particle_index < self.num_particles: 349 | particle = self.particles[particle_index] 350 | if particle.current_time < particle.total_time: 351 | self._advance_particle(particle, passed_time) 352 | particle_index += 1 353 | else: 354 | if particle_index != self.num_particles - 1: 355 | next_particle = self.particles[self.num_particles - 1] 356 | self.particles[self.num_particles - 1] = particle 357 | self.particles[particle_index] = next_particle 358 | self.num_particles -= 1 359 | if self.num_particles == 0: 360 | Logger.debug('Particle: COMPLETE') 361 | 362 | # create and advance new particles 363 | if self.emission_time > 0: 364 | time_between_particles = 1.0 / self.emission_rate 365 | self.frame_time += passed_time 366 | 367 | while self.frame_time > 0: 368 | if self.num_particles < self.max_capacity: 369 | if self.num_particles == self.capacity: 370 | self._raise_capacity(self.capacity) 371 | 372 | particle = self.particles[self.num_particles] 373 | self.num_particles += 1 374 | self._init_particle(particle) 375 | self._advance_particle(particle, self.frame_time) 376 | 377 | self.frame_time -= time_between_particles 378 | 379 | if self.emission_time != sys.maxsize: 380 | self.emission_time = max(0.0, self.emission_time - passed_time) 381 | 382 | def _render(self): 383 | if self.num_particles == 0: 384 | return 385 | for i in range(self.num_particles): 386 | particle = self.particles[i] 387 | size = (self.texture.size[0] * particle.scale, self.texture.size[1] * particle.scale) 388 | if particle not in self.particles_dict: 389 | self.particles_dict[particle] = dict() 390 | color = particle.color[:] 391 | with self.canvas: 392 | self.particles_dict[particle]['color'] = Color(color[0], color[1], color[2], color[3]) 393 | PushMatrix() 394 | self.particles_dict[particle]['translate'] = Translate() 395 | self.particles_dict[particle]['rotate'] = Rotate() 396 | self.particles_dict[particle]['rotate'].set(particle.rotation, 0, 0, 1) 397 | self.particles_dict[particle]['rect'] = Quad(texture=self.texture, points=(-size[0] * 0.5, -size[1] * 0.5, size[0] * 0.5, -size[1] * 0.5, size[0] * 0.5, size[1] * 0.5, -size[0] * 0.5, size[1] * 0.5)) 398 | self.particles_dict[particle]['translate'].xy = (particle.x, particle.y) 399 | PopMatrix() 400 | else: 401 | self.particles_dict[particle]['rotate'].angle = particle.rotation 402 | self.particles_dict[particle]['translate'].xy = (particle.x, particle.y) 403 | self.particles_dict[particle]['color'].rgba = particle.color 404 | self.particles_dict[particle]['rect'].points = (-size[0] * 0.5, -size[1] * 0.5, size[0] * 0.5, -size[1] * 0.5, size[0] * 0.5, size[1] * 0.5, -size[0] * 0.5, size[1] * 0.5) 405 | -------------------------------------------------------------------------------- /kivyparticle/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import random 4 | 5 | __all__ = ['random_variance', 'random_color_variance'] 6 | 7 | 8 | def random_variance(base, variance): 9 | return base + variance * (random.random() * 2.0 - 1.0) 10 | 11 | 12 | def random_color_variance(base, variance): 13 | return [min(max(0.0, (random_variance(base[i], variance[i]))), 1.0) for i in range(4)] 14 | -------------------------------------------------------------------------------- /run-tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from test import main 5 | import sys 6 | import os 7 | 8 | sys.path.insert(0, os.path.abspath(os.path.dirname(__file__))) 9 | 10 | 11 | if __name__ == '__main__': 12 | main() 13 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import unittest 4 | from .engine import TestParticleSystem 5 | 6 | __all__ = ['main'] 7 | 8 | 9 | def main(): 10 | suite = unittest.TestSuite() 11 | suite.addTest(unittest.makeSuite(TestParticleSystem)) 12 | unittest.TextTestRunner(verbosity=2).run(suite) 13 | -------------------------------------------------------------------------------- /test/config.pex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /test/engine.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from kivyparticle import ParticleSystem 4 | from kivy.graphics.opengl import GL_ONE, GL_SRC_ALPHA 5 | import unittest 6 | import math 7 | 8 | 9 | class TestParticleSystem(unittest.TestCase): 10 | def setUp(self): 11 | self.s = ParticleSystem('test/media/config.pex') 12 | 13 | def test_config(self): 14 | self.assertEquals((32, 32), self.s.texture.size) 15 | self.assertEquals(160.55, self.s.emitter_x) 16 | self.assertEquals(428.95, self.s.emitter_y) 17 | self.assertEquals(104.41, self.s.emitter_x_variance) 18 | self.assertEquals(0.00, self.s.emitter_y_variance) 19 | self.assertEquals(0.00, self.s.gravity_x) 20 | self.assertEquals(0.00, self.s.gravity_y) 21 | self.assertEquals(0, self.s.emitter_type) 22 | self.assertEquals(300, self.s.max_num_particles) 23 | self.assertEquals(2.0, self.s.life_span) 24 | self.assertEquals(1.9, self.s.life_span_variance) 25 | self.assertEquals(70.0, self.s.start_size) 26 | self.assertEquals(49.53, self.s.start_size_variance) 27 | self.assertEquals(10.0, self.s.end_size) 28 | self.assertEquals(0.0, self.s.end_size_variance) 29 | self.assertEquals(math.radians(270.37), self.s.emit_angle) 30 | self.assertEquals(math.radians(15.0), self.s.emit_angle_variance) 31 | self.assertEquals(0.0, self.s.start_rotation) 32 | self.assertEquals(0.0, self.s.start_rotation_variance) 33 | self.assertEquals(0.0, self.s.end_rotation) 34 | self.assertEquals(0.0, self.s.end_rotation_variance) 35 | self.assertEquals(90.0, self.s.speed) 36 | self.assertEquals(30.0, self.s.speed_variance) 37 | self.assertEquals(0.0, self.s.radial_acceleration) 38 | self.assertEquals(0.0, self.s.radial_acceleration_variance) 39 | self.assertEquals(0.0, self.s.tangential_acceleration) 40 | self.assertEquals(0.0, self.s.tangential_acceleration_variance) 41 | self.assertEquals(100.0, self.s.max_radius) 42 | self.assertEquals(0.0, self.s.max_radius_variance) 43 | self.assertEquals(0.0, self.s.min_radius) 44 | self.assertEquals(0.0, self.s.rotate_per_second) 45 | self.assertEquals(0.0, self.s.rotate_per_second_variance) 46 | self.assertEquals([1.0, 0.31, 0.0, 0.62], self.s.start_color) 47 | self.assertEquals([0.0, 0.0, 0.0, 0.0], self.s.start_color_variance) 48 | self.assertEquals([1.0, 0.31, 0.0, 0.0], self.s.end_color) 49 | self.assertEquals([0.0, 0.0, 0.0, 0.0], self.s.end_color_variance) 50 | self.assertEquals(GL_SRC_ALPHA, self.s.blend_factor_source) 51 | self.assertEquals(GL_ONE, self.s.blend_factor_dest) 52 | -------------------------------------------------------------------------------- /test/media/config.pex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /test/media/particle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skitoo/kivy-particle/0e287e93da86d48e00f59820f83b2a11a057bee4/test/media/particle.png --------------------------------------------------------------------------------