littlejsengine 1.21.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,760 @@
1
+ /**
2
+ * LittleJS Particle Effects Plugin
3
+ * - Ready made particle effects in one line, particleEffect('fire', pos), and particleEffect3D for 3D
4
+ * - Every built-in effect is tuned around a one unit emitter; options.scale grows it to fit, options.hue and
5
+ * options.saturation recolor it
6
+ * - Effects are plain data, {name, settings, behaviors}, the format the particle designer saves: load a library the
7
+ * designer saved with particleEffectsLoad and play its effects by name
8
+ * - The built-in effects draw with a sheet of shapes the plugin draws itself, so they need no image
9
+ * @namespace ParticleEffects
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ ///////////////////////////////////////////////////////////////////////////////
15
+ // settings, what an effect sets on an emitter
16
+
17
+ /** The groups the settings are in, in order
18
+ * @type {Array<string>}
19
+ * @memberof ParticleEffects */
20
+ const particleEffectGroups = ['Emitter', 'Particles', 'Color', 'Motion', 'Collision', 'Texture', 'Behaviors'];
21
+
22
+ /** Every setting an effect has: its name, kind (number, checkbox, color or shape), default, the range the designer's
23
+ * slider covers (min, max, step), the hard limits a typed value is clamped to, and whether it is set after the
24
+ * emitter is made rather than passed to its constructor (extra)
25
+ * @type {Array<{group:string, name:string, kind:string, value:any, min:number, max:number, step:number,
26
+ * description:string, hardMin:number, hardMax:number, extra:boolean}>}
27
+ * @memberof ParticleEffects */
28
+ const particleEffectSettings = [];
29
+ let particleEffectSettingGroup; // the group settings are being added to
30
+
31
+ // the slider covers min to max, typed values may go out to hardMin and hardMax
32
+ function particleEffectAddSetting(name, kind, value, min, max, step, description, hardMin=min, hardMax=max)
33
+ {
34
+ // extras are not constructor arguments, the code export assigns them after
35
+ const extra = ['trailScale', 'velocityInheritance', 'localSpace', 'restitution', 'friction', 'gravity'].includes(name);
36
+ particleEffectSettings.push({group:particleEffectSettingGroup, name, kind, value, min, max, step, description,
37
+ hardMin, hardMax, extra});
38
+ }
39
+
40
+ particleEffectSettingGroup = 'Emitter';
41
+ particleEffectAddSetting('emitRate', 'number', 100, 0, 500, .1,
42
+ 'Particles per second, 0 emits none', 0, 1e4);
43
+ particleEffectAddSetting('emitTime', 'number', 0, 0, 5, .05,
44
+ 'Seconds to emit for, 0 is forever', 0, 1e9);
45
+ particleEffectAddSetting('emitSize', 'number', 0, 0, 10, .05,
46
+ 'Diameter, or width when rectangular', 0, 1e9);
47
+ particleEffectAddSetting('emitRect', 'checkbox', false, 0, 0, 0,
48
+ 'Rectangle emitter, off is a circle');
49
+ particleEffectAddSetting('emitHeight', 'number', 0, 0, 10, .05,
50
+ 'Rectangle height', 0, 1e9);
51
+ particleEffectAddSetting('emitConeAngle', 'number', PI, 0, PI, .01,
52
+ 'Half angle particles move in, 3.14 is all ways');
53
+ particleEffectAddSetting('angle', 'number', 0, -3.14, 3.14, .01,
54
+ 'Emitter angle, 0 points up', -PI, PI); // the slider steps from its min
55
+
56
+ particleEffectSettingGroup = 'Particles';
57
+ particleEffectAddSetting('particleTime', 'number', .5, 0, 5, .05,
58
+ 'Seconds each particle lives', 0, 1e9);
59
+ particleEffectAddSetting('sizeStart', 'number', .1, 0, 5, .01,
60
+ 'Size at the start of life', 0, 1e9);
61
+ particleEffectAddSetting('sizeEnd', 'number', 1, 0, 5, .01,
62
+ 'Size at the end of life', 0, 1e9);
63
+ particleEffectAddSetting('fadeRate', 'number', .1, 0, 1, .01,
64
+ 'Share of life spent fading in and out');
65
+ particleEffectAddSetting('randomness', 'number', .2, 0, 1, .01,
66
+ 'How much each particle varies');
67
+ particleEffectAddSetting('particleConeAngle', 'number', PI, 0, PI, .01,
68
+ 'Half angle of start rotation, 3.14 is any');
69
+ particleEffectAddSetting('trailScale', 'number', 0, 0, 20, .1,
70
+ 'Stretch along the motion, 0 is off', 0, 1e9);
71
+ particleEffectAddSetting('additive', 'checkbox', false, 0, 0, 0,
72
+ 'Glow with additive blending');
73
+
74
+ particleEffectSettingGroup = 'Color';
75
+ particleEffectAddSetting('colorStartA', 'color', [1,1,1,1], 0, 1, .01,
76
+ 'Start color, each particle picks between A and B');
77
+ particleEffectAddSetting('colorStartB', 'color', [1,1,1,1], 0, 1, .01,
78
+ 'Second start color');
79
+ particleEffectAddSetting('colorEndA', 'color', [1,1,1,0], 0, 1, .01,
80
+ 'End color, each particle picks between A and B');
81
+ particleEffectAddSetting('colorEndB', 'color', [1,1,1,0], 0, 1, .01,
82
+ 'Second end color');
83
+ particleEffectAddSetting('randomColorLinear', 'checkbox', true, 0, 0, 0,
84
+ 'Pick along A to B, off picks each channel');
85
+
86
+ particleEffectSettingGroup = 'Motion';
87
+ particleEffectAddSetting('speed', 'number', .1, 0, .5, .005,
88
+ 'Start speed, world units per frame', 0, 1e9);
89
+ particleEffectAddSetting('angleSpeed', 'number', .05, 0, .5, .005,
90
+ 'Spin speed, radians per frame', 0, 1e9);
91
+ particleEffectAddSetting('damping', 'number', 1, .8, 1, .001,
92
+ 'Speed kept each frame, 1 keeps it all', 0, 1);
93
+ particleEffectAddSetting('angleDamping', 'number', 1, .8, 1, .001,
94
+ 'Spin kept each frame', 0, 1);
95
+ particleEffectAddSetting('gravityScale', 'number', 0, -2, 2, .05,
96
+ 'How much gravity pulls', -1e9, 1e9);
97
+ particleEffectAddSetting('gravity', 'number', 0, -.02, .02, .0005,
98
+ 'Its own fall each frame, the same in any game', -1, 1);
99
+ particleEffectAddSetting('velocityInheritance', 'number', 0, 0, 1, .01,
100
+ 'Share of emitter motion passed on');
101
+ particleEffectAddSetting('localSpace', 'checkbox', false, 0, 0, 0,
102
+ 'Particles move with the emitter');
103
+
104
+ particleEffectSettingGroup = 'Collision';
105
+ particleEffectAddSetting('collideLevel', 'checkbox', false, 0, 0, 0,
106
+ 'Hit tiles, the preview adds a floor');
107
+ particleEffectAddSetting('restitution', 'number', 0, 0, 1, .01,
108
+ 'Bounce when hitting tiles');
109
+ particleEffectAddSetting('friction', 'number', .8, 0, 1, .01,
110
+ 'Speed kept sliding along tiles');
111
+
112
+ particleEffectSettingGroup = 'Texture';
113
+ particleEffectAddSetting('shape', 'shape', 'soft', 0, 0, 0,
114
+ 'Built-in shape, none uses the tile');
115
+ particleEffectAddSetting('tileIndex', 'number', -1, -1, 63, 1,
116
+ 'Tile in texture 0 when no shape, -1 is untextured', -1, 1e4);
117
+ particleEffectAddSetting('tileSize', 'number', 16, 1, 128, 1,
118
+ 'Tile size in texture pixels', 1, 4096);
119
+ particleEffectAddSetting('tilePadding', 'number', 1, 0, 8, 1,
120
+ 'Pixels of padding around each tile', 0, 64);
121
+
122
+ /** The behaviors an effect can use, each a push applied to every particle every update, with a 2D and a 3D version
123
+ * @type {Array<{name:string, update:(Function|undefined), update3D:(Function|undefined), min:number, max:number,
124
+ * value:number, description:string}>}
125
+ * @memberof ParticleEffects */
126
+ const particleEffectBehaviors =
127
+ [
128
+ {name:'wobble', min:0, max:5, value:1, description:'Sway from side to side'},
129
+ {name:'swirl', min:-2, max:2, value:1, description:'Turn the direction of travel'},
130
+ {name:'turbulence', min:0, max:5, value:1, description:'Random pushes every frame'},
131
+ {name:'attract', min:-2, max:2, value:1, description:'Pull toward the emitter, negative pushes away'},
132
+ {name:'orbit', min:-2, max:2, value:1, description:'Push around the emitter, add attract to circle it'},
133
+ {name:'wind', min:-2, max:2, value:1, description:'A sideways push that grows with age'},
134
+ {name:'stick', min:0, max:1, value:1, description:'Grip on landing, pair with collideLevel'},
135
+ ].map(b=> ({...b, update: undefined, update3D: undefined}));
136
+ const particleEffectBehavior = (name)=> particleEffectBehaviors.find(b=> b.name === name);
137
+
138
+ /** Names of the shapes the plugin draws, for an effect's shape setting
139
+ * @type {Array<string>}
140
+ * @memberof ParticleEffects */
141
+ const particleEffectShapes = ['dot', 'soft', 'glow', 'smoke', 'spark', 'square', 'triangle', 'ring', 'star', 'plus'];
142
+
143
+ ///////////////////////////////////////////////////////////////////////////////
144
+ // effects
145
+
146
+ // a color from an [r,g,b,a] array or a Color, undefined if it is neither
147
+ function particleEffectColor(value)
148
+ {
149
+ if (value instanceof Color)
150
+ value = [value.r, value.g, value.b, value.a];
151
+ if (isArray(value) && value.length === 4 && value.every(isNumber))
152
+ return value.map(c=> clamp(c));
153
+ }
154
+
155
+ /** Any input into a whole effect: missing fields take their defaults, bad ones are dropped or clamped
156
+ * @param {Object} [raw] - {name, settings, behaviors}, from a library file, the designer or code
157
+ * @return {{name:string, settings:Object, behaviors:Array<{name:string, strength:number}>}}
158
+ * @memberof ParticleEffects */
159
+ function particleEffectSanitize(raw)
160
+ {
161
+ // one line of text, line breaks and control characters made spaces
162
+ const text = typeof raw?.name === 'string' ? raw.name.replace(/[\x00-\x1f\x7f]+/g, ' ').trim() : '';
163
+ const name = text ? text.slice(0, 60) : 'Effect';
164
+ const input = raw?.settings && typeof raw.settings === 'object' ? {...raw.settings} : {};
165
+ input.collideLevel ??= input.collideTiles; // its name before 1.20
166
+ // a library saved before shapes names its tile and no shape, it keeps its tile
167
+ if (input.shape === undefined && isNumber(input.tileIndex))
168
+ input.shape = '';
169
+ const settings = {};
170
+ for (const setting of particleEffectSettings)
171
+ {
172
+ const value = input[setting.name], fallback = setting.value;
173
+ if (setting.kind === 'checkbox')
174
+ settings[setting.name] = typeof value === 'boolean' ? value : value === 0 || value === 1 ? !!value : fallback;
175
+ else if (setting.kind === 'color')
176
+ settings[setting.name] = particleEffectColor(value) || fallback.slice();
177
+ else if (setting.kind === 'shape')
178
+ settings[setting.name] = value === '' || particleEffectShapes.includes(value) ? value : fallback;
179
+ else if (isNumber(value))
180
+ {
181
+ // an angle past half a turn comes around, a clamp would point it the other way
182
+ const wraps = setting.name === 'angle' && isFinite(value) && abs(value) > PI;
183
+ const clamped = clamp(wraps ? mod(value + PI, 2*PI) - PI : value, setting.hardMin, setting.hardMax);
184
+ settings[setting.name] = setting.step >= 1 ? round(clamped) : clamped;
185
+ }
186
+ else
187
+ settings[setting.name] = fallback;
188
+ }
189
+
190
+ // local space particles are placed relative to the emitter, the tile collision is in the world
191
+ if (settings.localSpace)
192
+ settings.collideLevel = false;
193
+
194
+ // known behaviors once each, in table order
195
+ const behaviors = [];
196
+ const list = isArray(raw?.behaviors) ? raw.behaviors : [];
197
+ for (const b of particleEffectBehaviors)
198
+ {
199
+ const found = list.find(x=> x?.name === b.name);
200
+ if (!found)
201
+ continue;
202
+ const strength = isNumber(found.strength) ? clamp(found.strength, b.min, b.max) : b.value;
203
+ behaviors.push({name:b.name, strength});
204
+ }
205
+
206
+ // behaviors not known yet stay, a game may add them after the effect is loaded
207
+ for (const x of list)
208
+ if (typeof x?.name === 'string' && x.name && !particleEffectBehavior(x.name) &&
209
+ !behaviors.some(b=> b.name === x.name))
210
+ behaviors.push({name:x.name, strength: isNumber(x.strength) ? x.strength : 1});
211
+ return {name, settings, behaviors};
212
+ }
213
+
214
+ /** A copy of an effect with its four colors turned around the color wheel and their saturation scaled; lightness and
215
+ * alpha stay, and grey and white have no hue to turn
216
+ * @param {Object} effect
217
+ * @param {number} [hue] - How far around the wheel, 1 is all the way
218
+ * @param {number} [saturation] - Multiplies the saturation, 0 is grey, clamped to 1
219
+ * @return {Object}
220
+ * @memberof ParticleEffects */
221
+ function particleEffectRecolor(effect, hue=0, saturation=1)
222
+ {
223
+ effect = particleEffectSanitize(effect); // colors as channels, whatever it was given
224
+ const settings = {...effect.settings};
225
+ for (const name of ['colorStartA', 'colorStartB', 'colorEndA', 'colorEndB'])
226
+ {
227
+ const [h, s, l, a] = new Color(...settings[name]).HSLA();
228
+ const c = new Color().setHSLA(mod(h + hue), clamp(s * saturation), l, a);
229
+ settings[name] = [c.r, c.g, c.b, c.a];
230
+ }
231
+ return {...effect, settings, behaviors: effect.behaviors.map(b=> ({...b}))};
232
+ }
233
+
234
+ ///////////////////////////////////////////////////////////////////////////////
235
+ // libraries
236
+
237
+ // the effects by lower case name, the built-ins and any a game adds or loads
238
+ const particleEffectsByName = new Map;
239
+
240
+ /** Add effects to play by name, sanitized; one with the name of an effect already there replaces it
241
+ * @param {Array<Object>|Object} effects
242
+ * @memberof ParticleEffects */
243
+ function particleEffectsAdd(effects)
244
+ {
245
+ for (const effect of isArray(effects) ? effects : [effects])
246
+ {
247
+ const clean = particleEffectSanitize(effect);
248
+ particleEffectsByName.set(clean.name.toLowerCase(), clean);
249
+ }
250
+ }
251
+
252
+ /** The effect with a name, any case, undefined if there is none
253
+ * @param {string} name
254
+ * @return {Object|undefined}
255
+ * @memberof ParticleEffects */
256
+ function particleEffectsGet(name) { return particleEffectsByName.get(String(name).toLowerCase()); }
257
+
258
+ /** A library file, or one effect, into sanitized effects; throws on bad input
259
+ * @param {string} text
260
+ * @return {Array<Object>}
261
+ * @memberof ParticleEffects */
262
+ function particleEffectsParse(text) { return particleEffectsFromData(JSON.parse(text)); }
263
+
264
+ // a library file's data, or one effect's, into sanitized effects
265
+ function particleEffectsFromData(data)
266
+ {
267
+ const list = isArray(data?.effects) ? data.effects : data?.settings ? [data] : undefined;
268
+ if (!list || !list.length)
269
+ throw new Error('no effects in this file');
270
+ return list.map(particleEffectSanitize);
271
+ }
272
+
273
+ /** The text of a library file with these effects, what particleEffectsLoad and the designer read
274
+ * @param {Array<Object>} effects
275
+ * @return {string}
276
+ * @memberof ParticleEffects */
277
+ function particleEffectsText(effects) { return JSON.stringify({version:1, effects}, undefined, 1); }
278
+
279
+ /** Load a library file the particle designer saved and add its effects
280
+ * @param {string} url
281
+ * @return {Promise<Array<Object>>} - The effects it had
282
+ * @memberof ParticleEffects */
283
+ async function particleEffectsLoad(url)
284
+ {
285
+ const effects = particleEffectsFromData(await fetchJSON(url));
286
+ particleEffectsAdd(effects);
287
+ return effects;
288
+ }
289
+
290
+ ///////////////////////////////////////////////////////////////////////////////
291
+ // the shape sheet: every shape white on clear in a cell of one canvas, drawn the first time one is asked for
292
+
293
+ let particleEffectShapeTiles;
294
+
295
+ /** The tile of a built-in shape, on a sheet the plugin draws once; undefined headless, without a canvas or without
296
+ * WebGL, where an effect draws untextured squares
297
+ * @param {string} name - One of particleEffectShapes
298
+ * @return {TileInfo|undefined}
299
+ * @memberof ParticleEffects */
300
+ function particleEffectShapeTile(name)
301
+ {
302
+ if (!particleEffectShapeTiles)
303
+ {
304
+ if (headlessMode || !glContext || typeof OffscreenCanvas == 'undefined') return;
305
+ // drawn at 4 times 32 pixel cells, so a big soft shape stays smooth when textures are pixelated
306
+ const cell = 32, r = 15, count = particleEffectShapes.length, res = 4;
307
+ const context = createCanvasContext(cell * count * res, cell * res);
308
+ context.scale(res, res);
309
+ context.fillStyle = context.strokeStyle = '#fff';
310
+ const soft = (x, y, radius, alpha=(t)=> 1 - t)=>
311
+ {
312
+ // a round falloff from the middle out
313
+ const g = context.createRadialGradient(x, y, 0, x, y, radius);
314
+ for (let i = 0; i <= 8; ++i)
315
+ g.addColorStop(i/8, 'rgba(255,255,255,' + alpha(i/8).toFixed(3) + ')');
316
+ context.fillStyle = g;
317
+ context.beginPath(); context.arc(x, y, radius, 0, 2*PI); context.fill();
318
+ context.fillStyle = '#fff';
319
+ };
320
+ const draw =
321
+ {
322
+ dot: (x, y)=> { context.beginPath(); context.arc(x, y, r, 0, 2*PI); context.fill(); },
323
+ soft: (x, y)=> soft(x, y, r),
324
+ glow: (x, y)=> soft(x, y, r, (t)=> engineGlowAlpha(t, 1)),
325
+ smoke: (x, y)=>
326
+ {
327
+ // a lumpy puff of soft circles
328
+ for (const [dx, dy, dr] of [[-4,-3,9], [4,-2,8], [0,4,9], [-5,5,6], [6,5,6]])
329
+ soft(x + dx, y + dy, dr, (t)=> (1 - t) * .7);
330
+ },
331
+ spark: (x, y)=>
332
+ {
333
+ // a glow squeezed thin, a streak along the tile's up
334
+ context.save(); context.translate(x, y); context.scale(.3, 1);
335
+ soft(0, 0, r, (t)=> engineGlowAlpha(t, .6));
336
+ context.restore();
337
+ },
338
+ square: (x, y)=> context.fillRect(x - r, y - r, 2*r, 2*r),
339
+ triangle: (x, y)=>
340
+ {
341
+ context.beginPath(); context.moveTo(x, y - r); context.lineTo(x + r, y + r); context.lineTo(x - r, y + r);
342
+ context.fill();
343
+ },
344
+ ring: (x, y)=>
345
+ {
346
+ context.lineWidth = 3;
347
+ context.beginPath(); context.arc(x, y, r - 2, 0, 2*PI); context.stroke();
348
+ },
349
+ star: (x, y)=>
350
+ {
351
+ context.beginPath();
352
+ for (let i = 0; i < 10; ++i)
353
+ {
354
+ const a = i * PI / 5 - PI/2, d = i % 2 ? r * .45 : r;
355
+ context.lineTo(x + cos(a) * d, y + sin(a) * d);
356
+ }
357
+ context.fill();
358
+ },
359
+ plus: (x, y)=> { context.fillRect(x - r, y - 4, 2*r, 8); context.fillRect(x - 4, y - r, 8, 2*r); },
360
+ };
361
+ particleEffectShapes.forEach((name, i)=> draw[name](i * cell + cell/2, cell/2));
362
+ const texture = new TextureInfo(context.canvas, true, false, false); // smooth even in a pixel art game
363
+ particleEffectShapeTiles = new Map(particleEffectShapes.map((name, i)=>
364
+ [name, new TileInfo(vec2((i * cell + 1) * res, res), vec2((cell - 2) * res), texture)]));
365
+ }
366
+ return particleEffectShapeTiles.get(name);
367
+ }
368
+
369
+ ///////////////////////////////////////////////////////////////////////////////
370
+ // behaviors, the 2D pushes: a distance moved grows with the particle's scale, so a bigger effect moves the same way
371
+
372
+ {
373
+ const b = particleEffectBehavior;
374
+ b('wobble').update = (p, s)=>
375
+ {
376
+ // each particle on its own beat, from its randomized life, so nothing is added to it
377
+ p.velocity.x += s * .003 * p.scale * sin(time*6 + p.lifeTime * 97 % 9);
378
+ };
379
+ b('swirl').update = (p, s)=>
380
+ {
381
+ // turn the direction of travel a little each frame
382
+ const a = s * .05, c = cos(a), n = sin(a), v = p.velocity;
383
+ v.set(v.x*c - v.y*n, v.x*n + v.y*c);
384
+ };
385
+ b('turbulence').update = (p, s)=>
386
+ {
387
+ p.velocity.x += rand(-1, 1) * s * .005 * p.scale;
388
+ p.velocity.y += rand(-1, 1) * s * .005 * p.scale;
389
+ };
390
+ b('attract').update = (p, s)=>
391
+ {
392
+ // pull toward the emitter by the distance, which grows with the scale already
393
+ const e = p.emitter, x = e.localSpace ? 0 : e.pos.x, y = e.localSpace ? 0 : e.pos.y;
394
+ p.velocity.x += (x - p.pos.x) * s * .002;
395
+ p.velocity.y += (y - p.pos.y) * s * .002;
396
+ };
397
+ b('orbit').update = (p, s)=>
398
+ {
399
+ const e = p.emitter, x = e.localSpace ? 0 : e.pos.x, y = e.localSpace ? 0 : e.pos.y;
400
+ p.velocity.x -= (p.pos.y - y) * s * .002;
401
+ p.velocity.y += (p.pos.x - x) * s * .002;
402
+ };
403
+ b('wind').update = (p, s)=>
404
+ p.velocity.x += s * .004 * p.scale * min((time - p.spawnTime) / p.lifeTime, 1);
405
+ b('stick').update = (p, s)=>
406
+ {
407
+ // grip the ground on landing, pair with collideLevel
408
+ if (!p.groundObject) return;
409
+ p.velocity.x *= 1 - s;
410
+ p.angleVelocity *= 1 - s;
411
+ };
412
+ }
413
+
414
+ /** Add a behavior effects can use by name, or replace one
415
+ * @param {string} name
416
+ * @param {function(Particle, number): void} update - Pushes a 2D particle, given the strength
417
+ * @param {function(Particle3D, number): void} [update3D] - The same for a 3D particle, none leaves 3D alone
418
+ * @param {number} [min] - Strength range the designer offers
419
+ * @param {number} [max]
420
+ * @param {number} [value] - Strength when first added
421
+ * @param {string} [description]
422
+ * @memberof ParticleEffects */
423
+ function particleEffectsAddBehavior(name, update, update3D, min=-2, max=2, value=1, description='')
424
+ {
425
+ const old = particleEffectBehavior(name);
426
+ old && particleEffectBehaviors.splice(particleEffectBehaviors.indexOf(old), 1);
427
+ particleEffectBehaviors.push({name, update, update3D, min, max, value, description});
428
+ }
429
+
430
+ // the update callback that runs an effect's behaviors, in 2D or 3D, undefined when it has none for that
431
+ function particleEffectUpdateCallback(behaviors, is3D)
432
+ {
433
+ const calls = behaviors.map(b=> [particleEffectBehavior(b.name)?.[is3D ? 'update3D' : 'update'], b.strength])
434
+ .filter(c=> c[0]);
435
+ if (calls.length)
436
+ return (p)=> { for (const [update, strength] of calls) update(p, strength); };
437
+ }
438
+
439
+ ///////////////////////////////////////////////////////////////////////////////
440
+ // 2D effects
441
+
442
+ // the settings the builders set themselves instead of copying across
443
+ const particleEffectIndirect = ['emitSize', 'emitRect', 'emitHeight', 'tileIndex', 'tileSize', 'tilePadding', 'shape'];
444
+
445
+ // the tile an effect draws with: its shape, else its tile in texture 0 when that fits, else none
446
+ function particleEffectTileInfo(s)
447
+ {
448
+ if (s.shape)
449
+ return particleEffectShapeTile(s.shape);
450
+ if (s.tileIndex < 0) return;
451
+ const texture = textureInfos[0], cell = s.tileSize + s.tilePadding*2;
452
+ if (!texture || !texture.size.x) return;
453
+ if (s.tileIndex < floor(texture.size.x / cell) * floor(texture.size.y / cell))
454
+ return tile(s.tileIndex, s.tileSize, 0, s.tilePadding);
455
+ }
456
+
457
+ /** Set a 2D emitter to an effect, live, so a running one keeps its particles
458
+ * @param {ParticleEmitter} emitter
459
+ * @param {Object} effect
460
+ * @memberof ParticleEffects */
461
+ function particleEffectApply(emitter, effect)
462
+ {
463
+ // an effect written by hand or changed since it was added
464
+ particleEffectSet(emitter, particleEffectSanitize(effect));
465
+ }
466
+
467
+ // set a 2D emitter to an effect that is sanitized already, as a play's is
468
+ function particleEffectSet(emitter, effect)
469
+ {
470
+ const s = effect.settings, ended = emitter.emitTime < 0; // destroyed, its last particles going
471
+ for (const setting of particleEffectSettings)
472
+ if (!particleEffectIndirect.includes(setting.name))
473
+ emitter[setting.name] = setting.kind === 'color' ? new Color(...s[setting.name]) : s[setting.name];
474
+ ended && (emitter.emitTime = -1);
475
+ emitter.emitCircle = !s.emitRect;
476
+ emitter.emitSize = vec2(s.emitSize, s.emitRect ? s.emitHeight : s.emitSize);
477
+ emitter.tileInfo = particleEffectTileInfo(s);
478
+ emitter.renderOrder = s.additive ? 1e9 : 0;
479
+ emitter.particleUpdateCallback = particleEffectUpdateCallback(effect.behaviors);
480
+ }
481
+
482
+ // an effect from a name or an effect, recolored by the options
483
+ function particleEffectResolve(nameOrEffect, options)
484
+ {
485
+ const found = typeof nameOrEffect == 'string' ? particleEffectsGet(nameOrEffect) : nameOrEffect;
486
+ ASSERT(!!found, 'no particle effect named ' + nameOrEffect);
487
+ if (!found) return;
488
+ // any setting in the options replaces the effect's own for this play, emitTime for a burst or a loop
489
+ const settings = {...found.settings};
490
+ for (const setting of particleEffectSettings)
491
+ if (options[setting.name] !== undefined)
492
+ settings[setting.name] = options[setting.name];
493
+ // sanitized each time, an effect may be written by hand or changed after it was added
494
+ const effect = particleEffectSanitize({...found, settings}), {hue=0, saturation=1} = options;
495
+ return hue || saturation != 1 ? particleEffectRecolor(effect, hue, saturation) : effect;
496
+ }
497
+
498
+ /** Play an effect: a 2D emitter set to it, placed, scaled and recolored
499
+ * - A continuous effect (fire, a torch) goes until destroyed or given an emitTime, a one-shot ends itself
500
+ * - Any setting in the options replaces the effect's own for this play: {emitTime: .5} for a burst of a continuous
501
+ * effect, {emitTime: 0, emitRate: 30} to keep a one-shot going, or speed, particleTime and the rest
502
+ * - Attach it to an object with addChild to follow it
503
+ * @param {string|Object} nameOrEffect - A built-in or added effect's name, or an effect
504
+ * @param {Vector2} [pos]
505
+ * @param {Object} [options] - What to change for this play, each left out when not wanted:
506
+ * scale grows the whole effect, the built-ins fit a one unit object at 1;
507
+ * hue turns its colors around the color wheel, 1 is all the way;
508
+ * saturation multiplies its saturation, 0 is grey;
509
+ * angle is its direction, 0 is up, the effect's own angle when not given;
510
+ * tileInfo, a TileInfo or a TextureInfo, is the game's own art to draw with in place of the effect's shape,
511
+ * tinted by its colors, a whole texture drawn as one tile;
512
+ * and any effect setting by its name, emitTime, emitRate, speed and the rest, replaces the effect's own
513
+ * @return {ParticleEmitter|undefined} - undefined when there is no such effect
514
+ * @memberof ParticleEffects */
515
+ function particleEffect(nameOrEffect, pos=vec2(), options={})
516
+ {
517
+ const effect = particleEffectResolve(nameOrEffect, options);
518
+ if (!effect) return;
519
+ const emitter = new ParticleEmitter(pos.copy());
520
+ particleEffectSet(emitter, effect);
521
+ emitter.scale = options.scale ?? 1;
522
+ if (options.angle !== undefined)
523
+ emitter.angle = options.angle;
524
+ const tileInfo = options.tileInfo; // the game's own art in place of the shape, a whole texture as one tile
525
+ if (tileInfo)
526
+ emitter.tileInfo = tileInfo instanceof TextureInfo ? new TileInfo(vec2(), tileInfo.size, tileInfo) : tileInfo;
527
+ return emitter;
528
+ }
529
+
530
+ /** An effect with a 2D emitter's settings, to save, build again, or build in 3D with particleEffect3D; its tile is
531
+ * left out, since a hand made emitter's tile is its own texture and not one an effect can name, and so is its
532
+ * scale, which an effect does not keep: pass it again with options.scale
533
+ * @param {ParticleEmitter} emitter
534
+ * @param {string} [name]
535
+ * @return {Object}
536
+ * @memberof ParticleEffects */
537
+ function particleEffectFromEmitter(emitter, name='Effect')
538
+ {
539
+ const settings = {};
540
+ for (const setting of particleEffectSettings)
541
+ if (!particleEffectIndirect.includes(setting.name) && emitter[setting.name] !== undefined)
542
+ settings[setting.name] = emitter[setting.name]; // a Color is taken as its channels by the sanitizer
543
+ settings.emitRect = !emitter.emitCircle;
544
+ settings.emitSize = emitter.emitSize.x;
545
+ settings.emitHeight = emitter.emitSize.y;
546
+ settings.shape = '';
547
+ settings.tileIndex = -1;
548
+ return particleEffectSanitize({name, settings});
549
+ }
550
+
551
+ ///////////////////////////////////////////////////////////////////////////////
552
+ // 3D effects
553
+
554
+ // the 3D pushes: a 3D particle is one view object the emitter reuses, so what differs per particle comes from its
555
+ // randomized lifetime, and distances grow with the emitter's scale
556
+ {
557
+ const b = particleEffectBehavior;
558
+ const center = (p)=> p.emitter.worldPos3D || p.emitter.pos3D; // where it was this update
559
+ b('wobble').update3D = (p, s)=> p.velocity.x += s * .003 * p.scale * sin(time*6 + p.lifeTime * 97 % 9);
560
+ b('swirl').update3D = (p, s)=>
561
+ {
562
+ const a = s * .05, c = cos(a), n = sin(a), v = p.velocity;
563
+ v.set(v.x*c - v.y*n, v.x*n + v.y*c, v.z);
564
+ };
565
+ b('turbulence').update3D = (p, s)=>
566
+ {
567
+ const k = s * .005 * p.scale;
568
+ p.velocity.x += rand(-1, 1) * k, p.velocity.y += rand(-1, 1) * k, p.velocity.z += rand(-1, 1) * k;
569
+ };
570
+ b('attract').update3D = (p, s)=>
571
+ {
572
+ const c = center(p);
573
+ p.velocity.x += (c.x - p.pos.x) * s * .002;
574
+ p.velocity.y += (c.y - p.pos.y) * s * .002;
575
+ p.velocity.z += (c.z - p.pos.z) * s * .002;
576
+ };
577
+ b('orbit').update3D = (p, s)=>
578
+ {
579
+ const c = center(p);
580
+ p.velocity.x -= (p.pos.y - c.y) * s * .002;
581
+ p.velocity.y += (p.pos.x - c.x) * s * .002;
582
+ };
583
+ b('wind').update3D = (p, s)=> p.velocity.x += s * .004 * p.scale * min(p.age / p.lifeTime, 1);
584
+ // stick has no 3D push: particleEffectApply3D makes it the emitter's stick, its grip on landing
585
+ }
586
+
587
+ /** Set a 3D emitter to an effect, live, so a running one keeps its particles; its place, scale and flatten stay
588
+ * @param {ParticleEmitter3D} emitter
589
+ * @param {Object} effect
590
+ * @memberof ParticleEffects */
591
+ function particleEffectApply3D(emitter, effect)
592
+ {
593
+ // an effect written by hand or changed since it was added
594
+ particleEffectSet3D(emitter, particleEffectSanitize(effect));
595
+ }
596
+
597
+ // set a 3D emitter to an effect that is sanitized already, as a play's is
598
+ function particleEffectSet3D(emitter, effect)
599
+ {
600
+ const s = effect.settings, e = emitter;
601
+ // a circle is a sphere and a rectangle a box as deep as it is wide
602
+ e.emitSize = s.emitRect ? vec3(s.emitSize, s.emitHeight, s.emitSize) : s.emitSize;
603
+ e.emitTime = e.emitTime < 0 ? -1 : s.emitTime; // destroyed, its last particles going, stays so
604
+ e.emitRate = s.emitRate, e.emitConeAngle = s.emitConeAngle;
605
+ e.tileInfo = particleEffectTileInfo(s);
606
+ for (const name of ['colorStartA', 'colorStartB', 'colorEndA', 'colorEndB'])
607
+ e[name] = new Color(...s[name]);
608
+ for (const name of ['particleTime', 'sizeStart', 'sizeEnd', 'speed', 'damping', 'gravity', 'fadeRate',
609
+ 'randomness', 'additive', 'gravityScale', 'angleSpeed', 'angleDamping', 'collideLevel', 'restitution'])
610
+ e[name] = s[name];
611
+ e.trailTime = s.trailScale / 60; // a stretch of speed times trailScale is a streak of that many frames
612
+ e.friction = s.friction;
613
+ e.stick = effect.behaviors.find(b=> b.name == 'stick')?.strength ?? 0; // in 3D the emitter grips on landing
614
+ // a 2D emitter at angle a shoots along (sin a, cos a), a z turn r takes up to (-sin r, cos r), so r is -a
615
+ e.rotation3D = vec3(0, 0, -s.angle);
616
+ e.particleUpdateCallback = particleEffectUpdateCallback(effect.behaviors, true);
617
+ }
618
+
619
+ /** Play an effect in 3D: a ParticleEmitter3D set to it, placed, scaled and recolored
620
+ * - The same effect data as particleEffect, so the look carries across: a circle spawn area becomes a sphere and a
621
+ * rectangle a box as deep as it is wide, both flat across the way it emits with options.flatten, a disc or a
622
+ * sheet; a trail becomes a streak of the same length, and the settings the 3D emitter lacks (particleConeAngle,
623
+ * randomColorLinear, velocityInheritance, localSpace) are left out; the stick behavior becomes the emitter's
624
+ * stick, its grip where a particle lands
625
+ * @param {string|Object} nameOrEffect - A built-in or added effect's name, or an effect
626
+ * @param {Vector3} [pos3D]
627
+ * @param {Object} [options] - scale, hue, saturation, angle, tileInfo and any setting as particleEffect; angle turns it
628
+ * about z, so 0 is up; flatten makes the spawn area flat across the way it emits
629
+ * @return {ParticleEmitter3D|undefined} - undefined when there is no such effect
630
+ * @memberof ParticleEffects */
631
+ function particleEffect3D(nameOrEffect, pos3D=vec3(), options={})
632
+ {
633
+ const effect = particleEffectResolve(nameOrEffect, options);
634
+ if (!effect) return;
635
+ const e = new ParticleEmitter3D(pos3D.copy());
636
+ particleEffectSet3D(e, effect);
637
+ if (options.angle !== undefined)
638
+ e.rotation3D = vec3(0, 0, -options.angle);
639
+ e.scale3D = vec3(options.scale ?? 1);
640
+ e.emitFlat = !!options.flatten; // a sphere a disc, a box a sheet, across the way it emits
641
+ if (options.tileInfo)
642
+ e.tileInfo = options.tileInfo;
643
+ return e;
644
+ }
645
+
646
+ ///////////////////////////////////////////////////////////////////////////////
647
+ // the built-in effects, each tuned around a one unit emitter; one-shots have an emitTime and end themselves
648
+
649
+ /** The names of the built-in effects, in the order a gallery shows them
650
+ * @type {Array<string>}
651
+ * @memberof ParticleEffects */
652
+ const particleEffectsBuiltIn = [];
653
+ {
654
+ const add = (name, settings, behaviors=[])=>
655
+ {
656
+ particleEffectsBuiltIn.push(name);
657
+ particleEffectsAdd({name, settings, behaviors});
658
+ };
659
+ const clear = (h, s, l)=> hsl(h, s, l, 0);
660
+
661
+ // fire
662
+ add('fire', {emitRate:120, emitSize:.6, emitConeAngle:.3, shape:'glow', particleTime:.8, sizeStart:.7,
663
+ sizeEnd:.15, speed:.02, angleSpeed:.02, damping:.95, gravity:.003, fadeRate:.3, randomness:.3, additive:true,
664
+ colorStartA:hsl(.08,1,.6), colorStartB:hsl(.14,1,.65), colorEndA:clear(.02,1,.5), colorEndB:clear(0,1,.3)});
665
+ add('torch', {emitRate:60, emitSize:.25, emitConeAngle:.2, shape:'glow', particleTime:.6, sizeStart:.45,
666
+ sizeEnd:.1, speed:.015, angleSpeed:.02, damping:.95, gravity:.003, fadeRate:.3, randomness:.3, additive:true,
667
+ colorStartA:hsl(.09,1,.6), colorStartB:hsl(.13,1,.7), colorEndA:clear(.03,1,.5), colorEndB:clear(0,1,.35)});
668
+ add('smoke', {emitRate:25, emitSize:.5, emitConeAngle:.3, shape:'smoke', particleTime:3, sizeStart:.5,
669
+ sizeEnd:2, speed:.01, angleSpeed:.01, damping:.99, gravity:.0004, fadeRate:.5, randomness:.3,
670
+ colorStartA:hsl(0,0,.5,.5), colorStartB:hsl(0,0,.3,.5), colorEndA:clear(0,0,.2), colorEndB:clear(0,0,.4)},
671
+ [{name:'wobble', strength:.5}]);
672
+ add('steam', {emitRate:30, emitSize:.4, emitConeAngle:.2, shape:'smoke', particleTime:1.5, sizeStart:.3,
673
+ sizeEnd:1.5, speed:.02, damping:.98, gravity:.0008, fadeRate:.6, randomness:.3,
674
+ colorStartA:hsl(0,0,1,.4), colorStartB:hsl(.6,.2,.9,.3), colorEndA:clear(0,0,1), colorEndB:clear(.6,.2,.9)},
675
+ [{name:'wobble', strength:.3}]);
676
+
677
+ // impacts
678
+ add('explosion', {emitRate:600, emitTime:.1, emitSize:.5, shape:'glow', particleTime:.6, sizeStart:1,
679
+ sizeEnd:.1, speed:.1, damping:.9, fadeRate:.2, randomness:.5, additive:true,
680
+ colorStartA:hsl(.12,1,.65), colorStartB:hsl(.06,1,.55), colorEndA:clear(0,1,.5), colorEndB:clear(0,0,.3)});
681
+ add('sparks', {emitRate:100, emitSize:.2, shape:'soft', particleTime:.6, sizeStart:.08, sizeEnd:.04,
682
+ speed:.07, damping:.96, gravity:-.006, fadeRate:.1, randomness:.4, additive:true, trailScale:3,
683
+ colorStartA:hsl(.17,1,.9), colorStartB:hsl(.12,1,.65), colorEndA:clear(.07,1,.5), colorEndB:clear(.03,1,.5)});
684
+ add('hit', {emitRate:400, emitTime:.05, emitSize:.2, shape:'spark', particleTime:.35, sizeStart:.5,
685
+ sizeEnd:.1, speed:.08, damping:.85, fadeRate:.1, randomness:.4, additive:true, trailScale:2,
686
+ colorStartA:hsl(0,0,1), colorStartB:hsl(0,1,.7), colorEndA:clear(0,1,.5), colorEndB:clear(0,1,.4)});
687
+ add('dust', {emitRate:300, emitTime:.05, emitSize:1, emitRect:true, emitHeight:.1, emitConeAngle:1.2,
688
+ shape:'smoke', particleTime:.8, sizeStart:.3, sizeEnd:.8, speed:.04, damping:.9, gravity:.0005,
689
+ fadeRate:.5, randomness:.4, colorStartA:hsl(.1,.3,.6,.5), colorStartB:hsl(.08,.2,.5,.5),
690
+ colorEndA:clear(.1,.3,.6), colorEndB:clear(.08,.2,.5)});
691
+ add('debris', {emitRate:200, emitTime:.05, emitSize:.5, emitConeAngle:1, shape:'square', particleTime:1.5,
692
+ sizeStart:.15, sizeEnd:.1, speed:.07, angleSpeed:.2, damping:.99, gravity:-.008, fadeRate:.1,
693
+ randomness:.5, colorStartA:hsl(.08,.4,.35), colorStartB:hsl(0,0,.4), colorEndA:hsl(.08,.4,.3),
694
+ colorEndB:hsl(0,0,.35)});
695
+
696
+ // pickups and magic
697
+ add('sparkle', {emitRate:30, emitSize:1, shape:'star', particleTime:.8, sizeStart:.3, sizeEnd:0,
698
+ speed:.005, angleSpeed:.05, fadeRate:.5, randomness:.3, additive:true,
699
+ colorStartA:hsl(.14,1,.7), colorStartB:hsl(0,0,1), colorEndA:clear(.14,1,.7), colorEndB:clear(0,0,1)});
700
+ add('magic', {emitRate:60, emitSize:1, shape:'soft', particleTime:1.5, sizeStart:.25, sizeEnd:0,
701
+ speed:.015, fadeRate:.3, randomness:.3, additive:true, colorStartA:hsl(.55,1,.65),
702
+ colorStartB:hsl(.79,1,.65), colorEndA:clear(0,0,1), colorEndB:clear(0,0,1)}, [{name:'swirl', strength:.5}]);
703
+ add('heal', {emitRate:30, emitSize:.8, emitConeAngle:.2, shape:'plus', particleTime:1.2, sizeStart:.25,
704
+ sizeEnd:.1, speed:.015, angleSpeed:0, particleConeAngle:0, gravity:.0005, fadeRate:.4, randomness:.3,
705
+ additive:true, colorStartA:hsl(.33,1,.6), colorStartB:hsl(.3,1,.75), colorEndA:clear(.33,1,.6),
706
+ colorEndB:clear(.3,1,.75)});
707
+ add('poison', {emitRate:20, emitSize:.8, shape:'smoke', particleTime:2, sizeStart:.3, sizeEnd:1,
708
+ speed:.006, gravity:.0002, fadeRate:.6, randomness:.4, colorStartA:hsl(.3,.8,.45,.6),
709
+ colorStartB:hsl(.8,.6,.45,.6), colorEndA:clear(.3,.8,.4), colorEndB:clear(.8,.6,.4)},
710
+ [{name:'wobble', strength:.5}]);
711
+ add('portal', {emitRate:80, emitSize:1, shape:'soft', particleTime:1, sizeStart:.15, sizeEnd:.05,
712
+ speed:.02, fadeRate:.3, randomness:.3, additive:true, colorStartA:hsl(.75,1,.6),
713
+ colorStartB:hsl(.6,1,.6), colorEndA:clear(.75,1,.6), colorEndB:clear(.6,1,.6)},
714
+ [{name:'orbit', strength:1}, {name:'attract', strength:.5}]);
715
+
716
+ // weather and ambience, one unit across at scale 1, so scale them to the view
717
+ add('rain', {emitRate:30, emitSize:1, emitRect:true, emitHeight:.6, angle:PI, emitConeAngle:0,
718
+ shape:'soft', particleTime:.8, sizeStart:.02, sizeEnd:.02, speed:.03, gravity:-.001, fadeRate:.1,
719
+ randomness:.2, trailScale:5, colorStartA:hsl(.63,1,.8,.6), colorStartB:hsl(.63,1,.8,.4),
720
+ colorEndA:hsl(.63,1,.8,.6), colorEndB:hsl(.63,1,.8,.4)});
721
+ add('snow', {emitRate:6, emitSize:1, emitRect:true, emitHeight:.6, angle:PI, emitConeAngle:.3,
722
+ shape:'soft', particleTime:4, sizeStart:.05, sizeEnd:.05, speed:.004, damping:.98, gravity:-.0002,
723
+ fadeRate:.2, randomness:.3, colorStartA:hsl(0,0,1), colorStartB:hsl(.58,1,.9), colorEndA:hsl(0,0,1),
724
+ colorEndB:hsl(.58,1,.9)}, [{name:'wobble', strength:.15}]);
725
+ add('leaves', {emitRate:3, emitSize:1, emitRect:true, emitHeight:.2, angle:PI, emitConeAngle:.5,
726
+ shape:'triangle', particleTime:4, sizeStart:.12, sizeEnd:.12, speed:.003, angleSpeed:.05, damping:.98,
727
+ gravity:-.0001, fadeRate:.2, randomness:.4, randomColorLinear:false, colorStartA:hsl(.25,.7,.4),
728
+ colorStartB:hsl(.07,.8,.5), colorEndA:hsl(.08,.6,.35), colorEndB:hsl(.05,.7,.4)},
729
+ [{name:'wobble', strength:.15}, {name:'wind', strength:.05}]);
730
+ add('bubbles', {emitRate:8, emitSize:1, emitRect:true, emitHeight:.1, emitConeAngle:.2, shape:'ring',
731
+ particleTime:2, sizeStart:.1, sizeEnd:.2, speed:.01, gravity:.0003, fadeRate:.2, randomness:.4,
732
+ colorStartA:hsl(.55,.8,.8,.8), colorStartB:hsl(.5,.6,.9,.6), colorEndA:hsl(.55,.8,.8,.3),
733
+ colorEndB:hsl(.5,.6,.9,.2)}, [{name:'wobble', strength:.3}]);
734
+ add('fireflies', {emitRate:3, emitSize:1, shape:'glow', particleTime:3, sizeStart:.15, sizeEnd:.15,
735
+ speed:.003, damping:.99, fadeRate:.8, randomness:.3, additive:true, colorStartA:hsl(.2,1,.6),
736
+ colorStartB:hsl(.15,1,.7), colorEndA:hsl(.2,1,.6), colorEndB:hsl(.15,1,.7)},
737
+ [{name:'turbulence', strength:.3}]);
738
+
739
+ // trails and shots
740
+ add('trail', {emitRate:60, shape:'soft', particleTime:.5, sizeStart:.4, sizeEnd:0, speed:0, angleSpeed:0,
741
+ fadeRate:.1, randomness:.2, additive:true, colorStartA:hsl(.08,1,.6), colorStartB:hsl(.12,1,.65),
742
+ colorEndA:clear(.03,1,.5), colorEndB:clear(0,1,.4)});
743
+ add('muzzle', {emitRate:300, emitTime:.05, emitConeAngle:.3, shape:'glow', particleTime:.2,
744
+ sizeStart:.5, sizeEnd:.1, speed:.1, damping:.8, fadeRate:.1, randomness:.3, additive:true,
745
+ colorStartA:hsl(.14,1,.8), colorStartB:hsl(.1,1,.6), colorEndA:clear(.08,1,.5), colorEndB:clear(.05,1,.4)});
746
+
747
+ // other
748
+ add('blood', {emitRate:300, emitTime:.1, emitConeAngle:.8, shape:'dot', particleTime:3, sizeStart:.15,
749
+ sizeEnd:.1, speed:.09, gravity:-.01, fadeRate:.1, randomness:.4, collideLevel:true, restitution:.1,
750
+ colorStartA:hsl(0,1,.35), colorStartB:hsl(0,1,.2), colorEndA:hsl(0,1,.25), colorEndB:hsl(0,1,.15)},
751
+ [{name:'stick', strength:1}]);
752
+ add('confetti', {emitRate:200, emitTime:.1, emitSize:.2, emitConeAngle:.6, shape:'square', particleTime:3,
753
+ sizeStart:.1, sizeEnd:.1, speed:.09, angleSpeed:.15, damping:.96, gravity:-.004, fadeRate:.1,
754
+ randomness:.4, randomColorLinear:false, colorStartA:hsl(0,1,.5), colorStartB:hsl(.6,1,.6),
755
+ colorEndA:hsl(0,1,.5), colorEndB:hsl(.6,1,.6)}, [{name:'wobble', strength:.3}]);
756
+ add('splash', {emitRate:300, emitTime:.05, emitSize:.5, emitConeAngle:.6, shape:'soft', particleTime:.7,
757
+ sizeStart:.12, sizeEnd:.06, speed:.08, gravity:-.008, fadeRate:.2, randomness:.4,
758
+ colorStartA:hsl(.58,.8,.7,.8), colorStartB:hsl(.55,.6,.85,.8), colorEndA:hsl(.58,.8,.7,.2),
759
+ colorEndB:hsl(.55,.6,.85,.2)});
760
+ }