@basementuniverse/particles-2d 1.2.1 → 1.4.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.
- package/README.md +53 -0
- package/build/index.d.ts +23 -0
- package/build/index.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
Attractor,
|
|
22
22
|
ForceField,
|
|
23
23
|
Collider,
|
|
24
|
+
Sink,
|
|
24
25
|
} from '@basementuniverse/particles-2d';
|
|
25
26
|
```
|
|
26
27
|
|
|
@@ -48,6 +49,10 @@ particleSystem.forceFields.push(
|
|
|
48
49
|
particleSystem.colliders.push(
|
|
49
50
|
new Collider(/* See below for options */)
|
|
50
51
|
);
|
|
52
|
+
|
|
53
|
+
particleSystem.sinks.push(
|
|
54
|
+
new Sink(/* See below for options */)
|
|
55
|
+
);
|
|
51
56
|
```
|
|
52
57
|
|
|
53
58
|
4. Update and render the particle system in your game loop:
|
|
@@ -255,6 +260,8 @@ Particles can optionally have a trail effect.
|
|
|
255
260
|
useAttractors: boolean; // whether particles from this emitter should be affected by attractors
|
|
256
261
|
useForceFields: boolean; // whether particles from this emitter should be affected by force fields
|
|
257
262
|
useColliders: boolean; // whether particles from this emitter should be affected by colliders
|
|
263
|
+
useSinks: boolean; // whether particles from this emitter should be affected by sinks
|
|
264
|
+
maxSpeed: number; // maximum speed (velocity magnitude) for particles, use -1 for no limit
|
|
258
265
|
|
|
259
266
|
defaultUpdates: 'none' | 'all' | ParticleDefaultUpdateTypes;
|
|
260
267
|
update?: (system: ParticleSystem, dt: number) => void;
|
|
@@ -372,3 +379,49 @@ Collider geometry can be defined in various ways:
|
|
|
372
379
|
vertices: vec2[]; // array of vertices defining the polygon
|
|
373
380
|
}
|
|
374
381
|
```
|
|
382
|
+
|
|
383
|
+
## Sinks
|
|
384
|
+
|
|
385
|
+
Sinks destroy or fade out particles within range of the sink.
|
|
386
|
+
|
|
387
|
+
```ts
|
|
388
|
+
new Sink(
|
|
389
|
+
position: vec2, // { x: number, y: number }
|
|
390
|
+
range: number, // range of effect
|
|
391
|
+
strength: number, // how fast to accelerate particle aging (multiplier)
|
|
392
|
+
falloff: number, // distance-based effect gradient (higher = stronger at center)
|
|
393
|
+
mode: 'instant' | 'fade', // 'instant' destroys immediately, 'fade' accelerates aging
|
|
394
|
+
lifespan: number // use -1 for infinite lifespan
|
|
395
|
+
);
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
Sinks use age acceleration to destroy particles:
|
|
399
|
+
|
|
400
|
+
- **'instant' mode**: particles are immediately disposed when they enter the sink's range
|
|
401
|
+
- **'fade' mode**: particles age faster within the sink's range, causing them to naturally reach their lifespan and trigger any configured fade-out effects
|
|
402
|
+
|
|
403
|
+
The `strength` parameter determines how much faster particles age (e.g., `strength = 5` means particles age 5x faster). The `falloff` parameter creates a distance-based gradient, making the effect stronger near the center of the sink.
|
|
404
|
+
|
|
405
|
+
### Example Use Cases
|
|
406
|
+
|
|
407
|
+
```ts
|
|
408
|
+
// Gradual fade sink (respects particle fade settings)
|
|
409
|
+
system.sinks.push(new Sink(
|
|
410
|
+
{ x: 750, y: 50 }, // position at HUD element
|
|
411
|
+
60, // range
|
|
412
|
+
5, // 5x aging acceleration
|
|
413
|
+
0.8, // gentle falloff
|
|
414
|
+
'fade', // respect fade-out settings
|
|
415
|
+
3 // dispose sink after 3 seconds
|
|
416
|
+
));
|
|
417
|
+
|
|
418
|
+
// Instant destruction sink
|
|
419
|
+
system.sinks.push(new Sink(
|
|
420
|
+
{ x: 400, y: 300 },
|
|
421
|
+
40,
|
|
422
|
+
Infinity, // not used in instant mode
|
|
423
|
+
1,
|
|
424
|
+
'instant', // particles vanish immediately
|
|
425
|
+
-1 // permanent
|
|
426
|
+
));
|
|
427
|
+
```
|
package/build/index.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export declare class ParticleSystem {
|
|
|
15
15
|
attractors: Attractor[];
|
|
16
16
|
forceFields: ForceField[];
|
|
17
17
|
colliders: Collider[];
|
|
18
|
+
sinks: Sink[];
|
|
18
19
|
update(dt: number): void;
|
|
19
20
|
draw(context: CanvasRenderingContext2D): void;
|
|
20
21
|
}
|
|
@@ -35,6 +36,14 @@ export type ParticleOptions = {
|
|
|
35
36
|
* Should this particle be affected by colliders
|
|
36
37
|
*/
|
|
37
38
|
useColliders: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Should this particle be affected by sinks
|
|
41
|
+
*/
|
|
42
|
+
useSinks: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Maximum speed (velocity magnitude) for this particle. Use -1 for no limit.
|
|
45
|
+
*/
|
|
46
|
+
maxSpeed: number;
|
|
38
47
|
/**
|
|
39
48
|
* What kind of default update logic to apply. This can be 'all', 'none', or
|
|
40
49
|
* an array of specific updates to apply:
|
|
@@ -345,6 +354,20 @@ export declare class ForceField {
|
|
|
345
354
|
applyForce(particle: Particle, dt: number): void;
|
|
346
355
|
update(dt: number): void;
|
|
347
356
|
}
|
|
357
|
+
export declare class Sink {
|
|
358
|
+
position: vec2;
|
|
359
|
+
range: number;
|
|
360
|
+
strength: number;
|
|
361
|
+
falloff: number;
|
|
362
|
+
mode: 'instant' | 'fade';
|
|
363
|
+
lifespan: number;
|
|
364
|
+
age: number;
|
|
365
|
+
private _disposed;
|
|
366
|
+
constructor(position: vec2, range?: number, strength?: number, falloff?: number, mode?: 'instant' | 'fade', lifespan?: number);
|
|
367
|
+
get disposed(): boolean;
|
|
368
|
+
affect(particle: Particle, dt: number): void;
|
|
369
|
+
update(dt: number): void;
|
|
370
|
+
}
|
|
348
371
|
export type ColliderGeometry = {
|
|
349
372
|
type: 'circle';
|
|
350
373
|
position: vec2;
|
package/build/index.js
CHANGED
|
@@ -86,7 +86,7 @@ eval("/**\n * @overview A small vector and matrix library\n * @author Gordon Lar
|
|
|
86
86
|
/***/ ((__unused_webpack_module, exports, __webpack_require__) => {
|
|
87
87
|
|
|
88
88
|
"use strict";
|
|
89
|
-
eval("\nObject.defineProperty(exports, \"__esModule\", ({ value: true }));\nexports.Collider = exports.ForceField = exports.Attractor = exports.Emitter = exports.Particle = exports.ParticleSystem = void 0;\nconst canvas_helpers_1 = __webpack_require__(/*! @basementuniverse/canvas-helpers */ \"./node_modules/@basementuniverse/canvas-helpers/build/index.js\");\nconst _2d_1 = __webpack_require__(/*! @basementuniverse/intersection-helpers/2d */ \"./node_modules/@basementuniverse/intersection-helpers/build/2d/index.js\");\nconst utilities_1 = __webpack_require__(/*! @basementuniverse/intersection-helpers/utilities */ \"./node_modules/@basementuniverse/intersection-helpers/build/utilities/index.js\");\nconst parsecolor_1 = __webpack_require__(/*! @basementuniverse/parsecolor */ \"./node_modules/@basementuniverse/parsecolor/parsecolor.js\");\nconst utils_1 = __webpack_require__(/*! @basementuniverse/utils */ \"./node_modules/@basementuniverse/utils/utils.js\");\nconst vec_1 = __webpack_require__(/*! @basementuniverse/vec */ \"./node_modules/@basementuniverse/vec/vec.js\");\nfunction isVec2(value) {\n return typeof value === 'object' && 'x' in value && 'y' in value;\n}\nfunction isRandomRange(value) {\n return typeof value === 'object' && 'min' in value && 'max' in value;\n}\nfunction calculateRandomRange(range, integer = false) {\n const r = integer ? utils_1.randomIntBetween : utils_1.randomBetween;\n if (isVec2(range.min) && isVec2(range.max)) {\n return (0, vec_1.vec2)(r(range.min.x, range.max.x), r(range.min.y, range.max.y));\n }\n return r(range.min, range.max);\n}\nfunction colorToString(color) {\n var _a;\n return `rgba(${color.r}, ${color.g}, ${color.b}, ${(_a = color.a) !== null && _a !== void 0 ? _a : 1})`;\n}\nfunction isColorObject(color) {\n return (typeof color === 'object' && 'r' in color && 'g' in color && 'b' in color);\n}\nfunction prepareColor(color) {\n if (Array.isArray(color)) {\n return prepareColor(color[(0, utils_1.randomIntBetween)(0, color.length - 1)]);\n }\n if (isColorObject(color)) {\n return colorToString(color);\n }\n return color;\n}\nfunction makeTransparent(color) {\n if (isColorObject(color)) {\n return { ...color, a: 0 };\n }\n const parsed = (0, parsecolor_1.parseColor)(color);\n return { ...parsed, a: 0 };\n}\n// -----------------------------------------------------------------------------\n// Particle System\n// -----------------------------------------------------------------------------\nclass ParticleSystem {\n constructor() {\n this.particles = [];\n this.emitters = [];\n this.attractors = [];\n this.forceFields = [];\n this.colliders = [];\n }\n update(dt) {\n // Update particles\n this.particles.forEach(particle => {\n if (!particle.disposed) {\n particle.update(this, dt);\n }\n });\n this.particles = this.particles.filter(particle => !particle.disposed);\n // Update emitters\n this.emitters.forEach(emitter => {\n if (!emitter.disposed) {\n emitter.update(this, dt);\n }\n });\n this.emitters = this.emitters.filter(emitter => !emitter.disposed);\n // Update attractors\n this.attractors.forEach(attractor => {\n if (!attractor.disposed) {\n attractor.update(dt);\n }\n });\n this.attractors = this.attractors.filter(attractor => !attractor.disposed);\n // Update force fields\n this.forceFields.forEach(forceField => {\n if (!forceField.disposed) {\n forceField.update(dt);\n }\n });\n this.forceFields = this.forceFields.filter(forceField => !forceField.disposed);\n }\n draw(context) {\n this.particles.forEach(particle => {\n if (!particle.disposed) {\n particle.draw(this, context);\n }\n });\n }\n}\nexports.ParticleSystem = ParticleSystem;\n// -----------------------------------------------------------------------------\n// Particles\n// -----------------------------------------------------------------------------\nconst PARTICLE_DEFAULT_UPDATE_TYPES = [\n 'age',\n 'physics',\n 'direction',\n 'position',\n];\nconst PARTICLE_DEFAULT_DRAW_TYPES = ['transforms', 'fade', 'styles'];\nconst DEFAULT_PARTICLE_OPTIONS = {\n useAttractors: true,\n useForceFields: true,\n useColliders: true,\n defaultUpdates: 'all',\n defaultDraws: 'all',\n};\nfunction prepareDefaultUpdates(defaultUpdates) {\n if (!Array.isArray(defaultUpdates)) {\n return defaultUpdates === 'all' ? [...PARTICLE_DEFAULT_UPDATE_TYPES] : [];\n }\n return defaultUpdates.filter(update => PARTICLE_DEFAULT_UPDATE_TYPES.includes(update));\n}\nfunction prepareDefaultDraws(defaultDraws) {\n if (!Array.isArray(defaultDraws)) {\n return defaultDraws === 'all' ? [...PARTICLE_DEFAULT_DRAW_TYPES] : [];\n }\n return defaultDraws.filter(draw => PARTICLE_DEFAULT_DRAW_TYPES.includes(draw));\n}\nfunction prepareGlow(context, glow, actualColor = 'white') {\n context.shadowColor = actualColor;\n context.shadowBlur = glow.amount;\n context.shadowOffsetX = 0;\n context.shadowOffsetY = 0;\n}\nconst DEFAULT_PARTICLE_STYLE = {\n style: 'dot',\n color: 'white',\n};\nclass Particle {\n constructor(\n /**\n * Initial position of the particle\n */\n position, \n /**\n * Initial velocity of the particle\n */\n velocity, \n /**\n * Size of the particle. This is used differently based on the style:\n *\n * - 'dot' style: we use the maximum of x and y as the radius\n * - 'line' style: x is the length of the line, y is the line width\n * - 'radial' style: we use the maximum of x and y as the radius\n * - 'image' style: x and y are the width and height of the image\n */\n size, \n /**\n * Rotation of the particle in radians\n *\n * _(Note: not used for 'dot' and 'radial' styles)_\n *\n * If this is null, we calculate rotation based on velocity\n */\n rotation = null, \n /**\n * Lifespan of the particle in seconds\n */\n lifespan = 1, \n /**\n * Style options for the particle. This can be used to define a default\n * rendering style and associated settings for the style\n *\n * The style can be one of:\n *\n * - 'dot': a simple dot with a color and optional glow\n * - 'radial': a radial gradient with a color that fades to transparent\n * - 'line': a line segment with a color, optional glow, and optional\n * rotation (the rotation can be relative or absolute)\n * - 'image': an image with an optional rotation (the rotation can be\n * relative or absolute)\n *\n * If this is null, the particle will use the custom rendering hook if\n * provided, or it will be invisible if no custom rendering is provided\n *\n * Omit this field or set it to undefined to use the default style\n */\n style, \n /**\n * Provide custom update logic and rendering logic here\n */\n options) {\n var _a, _b, _c;\n this.position = position;\n this.velocity = velocity;\n this.size = size;\n this.rotation = rotation;\n this.lifespan = lifespan;\n this.age = 0;\n this.style = null;\n this.actualRotation = 0;\n this.actualColor = '#fff';\n this.actualColorTransparent = '#fff0';\n this.actualGlowColor = '#fff';\n this._disposed = false;\n this.trailPositions = [];\n if (style !== null) {\n this.style = Object.assign({}, DEFAULT_PARTICLE_STYLE, style !== null && style !== void 0 ? style : {});\n }\n this.options = Object.assign({}, DEFAULT_PARTICLE_OPTIONS, options !== null && options !== void 0 ? options : {});\n // Prepare colors\n if (this.style && 'color' in this.style) {\n this.actualColor = prepareColor(this.style.color);\n this.actualColorTransparent = colorToString(makeTransparent(this.actualColor));\n if ('glow' in this.style) {\n this.actualGlowColor = prepareColor((_b = (_a = this.style.glow) === null || _a === void 0 ? void 0 : _a.color) !== null && _b !== void 0 ? _b : 'white');\n }\n }\n // Initialize trail positions with current position if trail is enabled\n if ((_c = this.style) === null || _c === void 0 ? void 0 : _c.trail) {\n this.trailPositions.push((0, vec_1.vec2)(position));\n }\n }\n get disposed() {\n return this._disposed;\n }\n get normalisedLifeRemaining() {\n if (this.lifespan <= 0) {\n return 0;\n }\n return (0, utils_1.unlerp)(this.age, 0, this.lifespan);\n }\n update(system, dt) {\n var _a, _b;\n const defaultUpdates = prepareDefaultUpdates(this.options.defaultUpdates);\n // Optionally handle particle lifespan\n if (defaultUpdates.includes('age')) {\n this.age += dt;\n // Dispose the particle when its lifespan is reached\n if (this.age >= this.lifespan) {\n this._disposed = true;\n }\n }\n // Optionally handle particle physics, i.e. forces from attractors, force\n // fields, and colliders\n if (defaultUpdates.includes('physics')) {\n if (this.options.useAttractors) {\n system.attractors.forEach(attractor => {\n if (!attractor.disposed) {\n attractor.applyForce(this, dt);\n }\n });\n }\n if (this.options.useForceFields) {\n system.forceFields.forEach(forceField => {\n if (!forceField.disposed) {\n forceField.applyForce(this, dt);\n }\n });\n }\n if (this.options.useColliders) {\n system.colliders.forEach(collider => {\n collider.handleCollision(this);\n });\n }\n }\n // Call custom update hook if provided\n if (this.options.update) {\n this.options.update.bind(this)(system, dt);\n }\n // Update rotation and, if configured, calculate rotation based on velocity\n this.actualRotation = (_a = this.rotation) !== null && _a !== void 0 ? _a : 0;\n if (defaultUpdates.includes('direction') && this.rotation === null) {\n this.actualRotation = vec_1.vec2.rad(this.velocity);\n }\n // Optionally handle position integration over time\n if (defaultUpdates.includes('position')) {\n this.position = vec_1.vec2.add(this.position, vec_1.vec2.scale(this.velocity, dt));\n }\n // Update trail positions if trail is enabled\n if (((_b = this.style) === null || _b === void 0 ? void 0 : _b.trail) && defaultUpdates.includes('position')) {\n // Only add new position if we've moved far enough from the last position\n const lastPosition = this.trailPositions[this.trailPositions.length - 1];\n if (!lastPosition ||\n (0, _2d_1.distance)(lastPosition, this.position) >=\n Particle.MINIMUM_TRAIL_MOVEMENT_THRESHOLD) {\n this.trailPositions.push((0, vec_1.vec2)(this.position));\n // Keep only the most recent positions based on trail length\n while (this.trailPositions.length > this.style.trail.length) {\n this.trailPositions.shift();\n }\n }\n }\n }\n drawTrail(context, particleAlpha = 1) {\n var _a, _b, _c;\n if (!((_a = this.style) === null || _a === void 0 ? void 0 : _a.trail) || this.trailPositions.length < 2) {\n return;\n }\n const trail = this.style.trail;\n const segments = this.trailPositions.length - 1;\n // Determine trail color\n const trailColor = trail.color\n ? prepareColor(trail.color)\n : this.style.style !== 'image' && 'color' in this.style\n ? this.actualColor\n : null;\n if (!trailColor) {\n return; // No valid color available\n }\n // Determine base width\n const baseWidth = (_b = trail.width) !== null && _b !== void 0 ? _b : (this.style.style === 'line'\n ? this.size.y\n : Math.max(this.size.x, this.size.y));\n context.save();\n context.lineCap = 'round';\n context.lineJoin = 'round';\n // Draw trail segments\n const widthDecay = Math.min(1, (_c = trail.widthDecay) !== null && _c !== void 0 ? _c : 1);\n for (let i = 0; i < segments; i++) {\n const start = this.trailPositions[i];\n const end = this.trailPositions[i + 1];\n // Calculate width based on decay\n const progress = 1 - i / (segments - 1);\n const decayFactor = 1 - progress * widthDecay;\n const width = baseWidth * decayFactor;\n // Calculate segment alpha based on fade settings\n let alpha = 1;\n if (trail.segmentFade) {\n const fadeIn = trail.segmentFade.in\n ? Math.min(1, 1 - i / trail.segmentFade.in)\n : 1;\n const fadeOut = trail.segmentFade.out\n ? Math.min(1, 1 - (segments - i) / trail.segmentFade.out)\n : 1;\n alpha = Math.min(fadeIn, fadeOut);\n }\n // Draw segment\n context.beginPath();\n context.strokeStyle = trailColor;\n context.lineWidth = width;\n context.globalAlpha = alpha * particleAlpha;\n context.moveTo(start.x, start.y);\n context.lineTo(end.x, end.y);\n context.stroke();\n }\n context.restore();\n }\n draw(system, context) {\n var _a, _b, _c, _d, _e, _f;\n const defaultDraws = prepareDefaultDraws(this.options.defaultDraws);\n context.save();\n // Optionally handle fade in/out effects\n let fadeAlpha = 1;\n if (defaultDraws.includes('fade') && ((_a = this.style) === null || _a === void 0 ? void 0 : _a.fade)) {\n const fadeIn = this.style.fade.in === 0\n ? 1\n : (0, utils_1.clamp)((0, utils_1.unlerp)(0, this.style.fade.in, this.age), 0, 1);\n const fadeOut = this.style.fade.out === 0\n ? 1\n : (0, utils_1.clamp)((0, utils_1.unlerp)(this.lifespan, this.lifespan - this.style.fade.out, this.age), 0, 1);\n fadeAlpha = (0, utils_1.clamp)(fadeIn * fadeOut, 0, 1);\n }\n // Draw trail before applying particle transforms\n if (defaultDraws.includes('styles') && ((_b = this.style) === null || _b === void 0 ? void 0 : _b.trail)) {\n this.drawTrail(context, fadeAlpha);\n }\n // Optionally apply transforms\n if (defaultDraws.includes('transforms')) {\n context.translate(this.position.x, this.position.y);\n }\n context.globalAlpha = fadeAlpha;\n // Call custom pre-draw hook if provided\n if (this.options.preDraw) {\n this.options.preDraw.bind(this)(system, context);\n }\n // Optionally render one of the default styles if configured\n if (defaultDraws.includes('styles') && this.style !== null) {\n switch (this.style.style) {\n case 'dot':\n // Dot style renders a circle with a fill color\n if (this.style.glow) {\n prepareGlow(context, this.style.glow, this.actualGlowColor);\n }\n (0, canvas_helpers_1.circle)(context, (0, vec_1.vec2)(), Math.max(this.size.x, this.size.y) / 2, {\n fill: true,\n fillColor: this.actualColor,\n stroke: false,\n });\n break;\n case 'radial':\n // Radial style renders a radial gradient circle\n const size = Math.max(this.size.x, this.size.y) / 2;\n const gradient = context.createRadialGradient(0, 0, 0, 0, 0, size);\n const startColor = this.actualColor;\n const endColor = this.actualColorTransparent;\n gradient.addColorStop(0, startColor);\n gradient.addColorStop(1, endColor);\n context.fillStyle = gradient;\n context.beginPath();\n context.arc(0, 0, size, 0, Math.PI * 2);\n context.fill();\n context.closePath();\n break;\n case 'line':\n // Line style renders a line segment with a stroke color\n if (this.style.glow) {\n prepareGlow(context, this.style.glow, this.actualGlowColor);\n }\n const angle = ((_c = this.actualRotation) !== null && _c !== void 0 ? _c : 0) + ((_d = this.style.rotationOffset) !== null && _d !== void 0 ? _d : 0);\n const length = this.size.x;\n const lineWidth = this.size.y;\n const vector = vec_1.vec2.rot((0, vec_1.vec2)(length, 0), angle);\n (0, canvas_helpers_1.line)(context, vec_1.vec2.scale(vector, -0.5), vec_1.vec2.scale(vector, 0.5), {\n lineWidth,\n strokeColor: this.actualColor,\n });\n break;\n case 'image':\n // Image style renders an image with optional rotation\n if (defaultDraws.includes('transforms')) {\n const angle = ((_e = this.actualRotation) !== null && _e !== void 0 ? _e : 0) + ((_f = this.style.rotationOffset) !== null && _f !== void 0 ? _f : 0);\n context.rotate(angle);\n }\n context.drawImage(this.style.image, -this.size.x / 2, -this.size.y / 2, this.size.x, this.size.y);\n break;\n }\n }\n // Call custom post-draw hook if provided\n if (this.options.postDraw) {\n this.options.postDraw.bind(this)(system, context);\n }\n context.restore();\n }\n}\nexports.Particle = Particle;\nParticle.MINIMUM_TRAIL_MOVEMENT_THRESHOLD = 5;\nconst DEFAULT_EMITTER_OPTIONS = {\n particles: {\n position: 'uniform',\n speed: 0,\n direction: 0,\n size: (0, vec_1.vec2)(1),\n rotation: null,\n lifespan: 1,\n style: DEFAULT_PARTICLE_STYLE,\n options: DEFAULT_PARTICLE_OPTIONS,\n },\n emission: {\n type: 'rate',\n rate: 1,\n },\n};\nclass Emitter {\n constructor(position, size = (0, vec_1.vec2)(0, 0), lifespan = -1, options) {\n this.position = position;\n this.size = size;\n this.lifespan = lifespan;\n this.age = 0;\n this.totalParticlesEmitted = 0;\n this._disposed = false;\n this.currentRate = 0;\n this.lastRateChange = 0;\n this.particlesToEmit = 0;\n this.options = Object.assign({}, DEFAULT_EMITTER_OPTIONS, options !== null && options !== void 0 ? options : {});\n }\n get disposed() {\n return this._disposed;\n }\n update(system, dt) {\n // Handle emitter aging and dispose if we've reached the lifespan\n this.age += dt;\n if (this.lifespan !== -1 && this.age >= this.lifespan) {\n this._disposed = true;\n return;\n }\n // Handle particle emission based on the type of emission configured\n switch (this.options.emission.type) {\n case 'rate':\n // Rate mode emits particles continuously at a specified rate\n // Handle random rate changes\n this.lastRateChange += dt;\n if (this.currentRate <= 0 ||\n this.lastRateChange >= Emitter.RANDOM_RATE_CHANGE_INTERVAL) {\n this.lastRateChange = 0;\n // The actual emission rate can be a fixed value or a random range\n this.currentRate = isRandomRange(this.options.emission.rate)\n ? calculateRandomRange(this.options.emission.rate)\n : this.options.emission.rate;\n }\n // Accumulate a fractional number of particles to emit\n this.particlesToEmit += this.currentRate * dt;\n // Emit particles if we have enough to emit\n if (this.particlesToEmit >= 1) {\n // Get the whole number of particles to emit\n const n = Math.floor(this.particlesToEmit);\n // Subtract the number of particles, keeping the remainder\n this.particlesToEmit -= n;\n // Emit the particles\n this.emitParticles(system, n);\n this.totalParticlesEmitted += n;\n }\n break;\n case 'burst':\n // Burst mode emits a fixed or random number of particles at once (or\n // after a delay) and then immediately disposes the emitter\n if (!this.options.emission.delay ||\n this.age >= this.options.emission.delay) {\n // The number of particles to emit can be a fixed value or a random\n // range\n const n = isRandomRange(this.options.emission.n)\n ? calculateRandomRange(this.options.emission.n, true)\n : Math.ceil(this.options.emission.n);\n if (n > 0) {\n this.emitParticles(system, n);\n this.totalParticlesEmitted += n;\n // Keep trying to emit until we've emitted at least one particle\n this._disposed = true;\n }\n }\n break;\n case 'custom':\n // Custom mode allows for a custom function to determine how many\n // particles to emit on each update\n const n = Math.ceil(this.options.emission.f.bind(this)());\n if (n > 0) {\n this.emitParticles(system, n);\n this.totalParticlesEmitted += n;\n }\n break;\n }\n }\n emitParticles(system, n) {\n for (let i = 0; i < n; i++) {\n const particle = this.createParticle(system, i);\n if (particle) {\n system.particles.push(particle);\n }\n }\n }\n createParticle(system, n) {\n // Generate position\n let position;\n if ((0, utilities_1.vectorAlmostZero)(this.size)) {\n // Emitter size is zero, so use the exact emitter position\n position = (0, vec_1.vec2)(this.position);\n }\n else {\n switch (this.options.particles.position) {\n case 'uniform':\n // Uniform distribution within the emitter area\n position = (0, vec_1.vec2)((0, utils_1.randomIntBetween)(this.position.x - this.size.x / 2, this.position.x + this.size.x / 2), (0, utils_1.randomIntBetween)(this.position.y - this.size.y / 2, this.position.y + this.size.y / 2));\n break;\n case 'normal':\n // Normal distribution from the center of the emitter area\n position = (0, vec_1.vec2)((0, utils_1.cltRandomInt)(this.position.x - this.size.x / 2, this.position.x + this.size.x / 2), (0, utils_1.cltRandomInt)(this.position.y - this.size.y / 2, this.position.y + this.size.y / 2));\n break;\n default:\n if (typeof this.options.particles.position === 'function') {\n // Custom position function\n position = this.options.particles.position.bind(this)(n);\n }\n else {\n // Something went wrong, fall back to emitter position\n position = (0, vec_1.vec2)(this.position);\n }\n }\n }\n // Generate velocity\n let speed;\n if (typeof this.options.particles.speed === 'function') {\n // Custom speed function\n speed = this.options.particles.speed.bind(this)(n);\n }\n else if (isRandomRange(this.options.particles.speed)) {\n // Random speed range\n speed = calculateRandomRange(this.options.particles.speed, true);\n }\n else {\n // Fixed speed\n speed = this.options.particles.speed;\n }\n let direction;\n if (typeof this.options.particles.direction === 'function') {\n // Custom direction function\n direction = this.options.particles.direction.bind(this)(n);\n }\n else if (isRandomRange(this.options.particles.direction)) {\n // Random direction range\n direction = calculateRandomRange(this.options.particles.direction);\n }\n else {\n // Fixed direction\n direction = this.options.particles.direction;\n }\n const velocity = vec_1.vec2.rot((0, vec_1.vec2)(speed, 0), direction);\n // Generate size\n let size;\n if (typeof this.options.particles.size === 'function') {\n // Custom size function\n size = this.options.particles.size.bind(this)(n);\n }\n else if (isRandomRange(this.options.particles.size)) {\n // Random size range\n size = calculateRandomRange(this.options.particles.size);\n }\n else {\n // Fixed size\n size = this.options.particles.size;\n }\n // Generate rotation\n let rotation;\n if (this.options.particles.rotation === null) {\n rotation = null;\n }\n else if (typeof this.options.particles.rotation === 'function') {\n // Custom rotation function\n rotation = this.options.particles.rotation.bind(this)(n);\n }\n else if (isRandomRange(this.options.particles.rotation)) {\n // Random rotation range\n rotation = calculateRandomRange(this.options.particles.rotation);\n }\n else {\n // Fixed rotation\n rotation = this.options.particles.rotation;\n }\n // Generate lifespan\n let lifespan;\n if (typeof this.options.particles.lifespan === 'function') {\n // Custom lifespan function\n lifespan = this.options.particles.lifespan.bind(this)(n);\n }\n else if (isRandomRange(this.options.particles.lifespan)) {\n // Random lifespan range\n lifespan = calculateRandomRange(this.options.particles.lifespan);\n }\n else {\n // Fixed lifespan\n lifespan = this.options.particles.lifespan;\n }\n return new Particle(position, velocity, size, rotation, lifespan, this.options.particles.style, this.options.particles.options);\n }\n}\nexports.Emitter = Emitter;\nEmitter.RANDOM_RATE_CHANGE_INTERVAL = 1;\n// -----------------------------------------------------------------------------\n// Attractors\n// -----------------------------------------------------------------------------\nclass Attractor {\n constructor(position, range = 100, force = 1, falloff = 1, lifespan = -1) {\n this.position = position;\n this.range = range;\n this.force = force;\n this.falloff = falloff;\n this.lifespan = lifespan;\n this.age = 0;\n this._disposed = false;\n }\n get disposed() {\n return this._disposed;\n }\n applyForce(particle, dt) {\n // Calculate distance to the particle\n const d = (0, _2d_1.distance)(this.position, particle.position);\n if (d > this.range) {\n return; // Particle is out of range\n }\n // Prevent divide-by-zero with a small minimum distance\n const minDistance = 1;\n const safeDistance = Math.max(d, minDistance);\n // Calculate direction vector from particle to attractor\n const direction = vec_1.vec2.sub(this.position, particle.position);\n const normalizedDirection = vec_1.vec2.nor(direction);\n // Use configurable falloff instead of fixed inverse square law\n // Higher falloff values create steeper gradients (stronger close-range effects)\n // Lower falloff values create gentler gradients (more uniform force fields)\n const distanceFactor = 1 / Math.pow(safeDistance, this.falloff);\n // Apply smooth range falloff at the boundary\n const rangeFactor = d / this.range;\n const rangeFalloff = Math.max(0, 1 - rangeFactor * rangeFactor);\n // Calculate final force vector\n const finalForceStrength = this.force * distanceFactor * rangeFalloff;\n const forceVector = vec_1.vec2.scale(normalizedDirection, finalForceStrength);\n // Apply the force to the particle's velocity\n particle.velocity = vec_1.vec2.add(particle.velocity, vec_1.vec2.scale(forceVector, dt));\n }\n update(dt) {\n this.age += dt;\n // Dispose the attractor when its lifespan is reached\n if (this.lifespan !== -1 && this.age >= this.lifespan) {\n this._disposed = true;\n }\n }\n}\nexports.Attractor = Attractor;\n// -----------------------------------------------------------------------------\n// Forcefields\n// -----------------------------------------------------------------------------\nclass ForceField {\n constructor(force = (0, vec_1.vec2)(0, 0), lifespan = -1) {\n this.force = force;\n this.lifespan = lifespan;\n this.age = 0;\n this._disposed = false;\n }\n get disposed() {\n return this._disposed;\n }\n applyForce(particle, dt) {\n particle.velocity = vec_1.vec2.add(particle.velocity, vec_1.vec2.scale(this.force, dt));\n }\n update(dt) {\n this.age += dt;\n // Dispose the force field when its lifespan is reached\n if (this.lifespan !== -1 && this.age >= this.lifespan) {\n this._disposed = true;\n }\n }\n}\nexports.ForceField = ForceField;\nclass Collider {\n constructor(geometry, restitution = 0.5, friction = 0.5, randomness = 0) {\n this.geometry = geometry;\n this.restitution = restitution;\n this.friction = friction;\n this.randomness = randomness;\n }\n handleCollision(particle) {\n var _a, _b;\n // Broad phase: first check if the point is in the collider's AABB\n const geometryAABB = (0, _2d_1.aabb)(this.geometry);\n if (geometryAABB === null) {\n return; // Invalid polygon\n }\n if (!(0, _2d_1.pointInAABB)(particle.position, geometryAABB)) {\n return; // Particle is outside the collider's AABB\n }\n // Narrow phase: check if the particle collides with the collider geometry\n let collisionResult;\n switch (this.geometry.type) {\n case 'circle':\n collisionResult = (0, _2d_1.pointInCircle)(particle.position, {\n position: this.geometry.position,\n radius: this.geometry.radius,\n });\n break;\n case 'rectangle':\n collisionResult = (0, _2d_1.pointInRectangle)(particle.position, {\n position: this.geometry.position,\n size: this.geometry.size,\n rotation: (_a = this.geometry.rotation) !== null && _a !== void 0 ? _a : 0,\n });\n break;\n case 'polygon':\n collisionResult = (0, _2d_1.pointInPolygon)(particle.position, {\n vertices: this.geometry.vertices,\n });\n break;\n }\n if (collisionResult === null || !collisionResult.intersects) {\n return; // Invalid polygon or no intersection\n }\n // Handle the collision\n // The collider has a friction value which is used to reduce the particle's\n // velocity after the collision\n // The collider has a restitution value which is used to bounce the particle\n // off the collider surface\n const normal = (_b = collisionResult.normal) !== null && _b !== void 0 ? _b : (0, vec_1.vec2)(0, 0);\n const relativeVelocity = vec_1.vec2.sub(particle.velocity, (0, vec_1.vec2)(0, 0));\n const velocityAlongNormal = vec_1.vec2.dot(relativeVelocity, normal);\n if (velocityAlongNormal > 0) {\n return; // Particle is moving away from the collider, no collision\n }\n // Calculate the impulse to apply to the particle\n const impulseMagnitude = -(1 + this.restitution) * velocityAlongNormal;\n const impulse = vec_1.vec2.scale(normal, impulseMagnitude);\n // Apply the impulse to the particle's velocity\n particle.velocity = vec_1.vec2.add(particle.velocity, impulse);\n // Apply randomness to the particle's velocity\n if (this.randomness > 0) {\n // Get a random angle between -PI and PI, scaled by randomness\n const randomAngle = (0, utils_1.randomBetween)(-Math.PI * this.randomness, Math.PI * this.randomness);\n particle.velocity = vec_1.vec2.rot(particle.velocity, randomAngle);\n }\n // Apply friction to the particle's velocity\n const frictionImpulse = vec_1.vec2.scale(vec_1.vec2.sub(relativeVelocity, vec_1.vec2.scale(normal, velocityAlongNormal)), -this.friction);\n particle.velocity = vec_1.vec2.add(particle.velocity, frictionImpulse);\n }\n}\nexports.Collider = Collider;\n\n\n//# sourceURL=webpack://@basementuniverse/particles-2d/./index.ts?");
|
|
89
|
+
eval("\nObject.defineProperty(exports, \"__esModule\", ({ value: true }));\nexports.Collider = exports.Sink = exports.ForceField = exports.Attractor = exports.Emitter = exports.Particle = exports.ParticleSystem = void 0;\nconst canvas_helpers_1 = __webpack_require__(/*! @basementuniverse/canvas-helpers */ \"./node_modules/@basementuniverse/canvas-helpers/build/index.js\");\nconst _2d_1 = __webpack_require__(/*! @basementuniverse/intersection-helpers/2d */ \"./node_modules/@basementuniverse/intersection-helpers/build/2d/index.js\");\nconst utilities_1 = __webpack_require__(/*! @basementuniverse/intersection-helpers/utilities */ \"./node_modules/@basementuniverse/intersection-helpers/build/utilities/index.js\");\nconst parsecolor_1 = __webpack_require__(/*! @basementuniverse/parsecolor */ \"./node_modules/@basementuniverse/parsecolor/parsecolor.js\");\nconst utils_1 = __webpack_require__(/*! @basementuniverse/utils */ \"./node_modules/@basementuniverse/utils/utils.js\");\nconst vec_1 = __webpack_require__(/*! @basementuniverse/vec */ \"./node_modules/@basementuniverse/vec/vec.js\");\nfunction isVec2(value) {\n return typeof value === 'object' && 'x' in value && 'y' in value;\n}\nfunction isRandomRange(value) {\n return typeof value === 'object' && 'min' in value && 'max' in value;\n}\nfunction calculateRandomRange(range, integer = false) {\n const r = integer ? utils_1.randomIntBetween : utils_1.randomBetween;\n if (isVec2(range.min) && isVec2(range.max)) {\n return (0, vec_1.vec2)(r(range.min.x, range.max.x), r(range.min.y, range.max.y));\n }\n return r(range.min, range.max);\n}\nfunction colorToString(color) {\n var _a;\n return `rgba(${color.r}, ${color.g}, ${color.b}, ${(_a = color.a) !== null && _a !== void 0 ? _a : 1})`;\n}\nfunction isColorObject(color) {\n return (typeof color === 'object' && 'r' in color && 'g' in color && 'b' in color);\n}\nfunction prepareColor(color) {\n if (Array.isArray(color)) {\n return prepareColor(color[(0, utils_1.randomIntBetween)(0, color.length - 1)]);\n }\n if (isColorObject(color)) {\n return colorToString(color);\n }\n return color;\n}\nfunction makeTransparent(color) {\n if (isColorObject(color)) {\n return { ...color, a: 0 };\n }\n const parsed = (0, parsecolor_1.parseColor)(color);\n return { ...parsed, a: 0 };\n}\n// -----------------------------------------------------------------------------\n// Particle System\n// -----------------------------------------------------------------------------\nclass ParticleSystem {\n constructor() {\n this.particles = [];\n this.emitters = [];\n this.attractors = [];\n this.forceFields = [];\n this.colliders = [];\n this.sinks = [];\n }\n update(dt) {\n // Update particles\n this.particles.forEach(particle => {\n if (!particle.disposed) {\n particle.update(this, dt);\n }\n });\n this.particles = this.particles.filter(particle => !particle.disposed);\n // Update emitters\n this.emitters.forEach(emitter => {\n if (!emitter.disposed) {\n emitter.update(this, dt);\n }\n });\n this.emitters = this.emitters.filter(emitter => !emitter.disposed);\n // Update attractors\n this.attractors.forEach(attractor => {\n if (!attractor.disposed) {\n attractor.update(dt);\n }\n });\n this.attractors = this.attractors.filter(attractor => !attractor.disposed);\n // Update force fields\n this.forceFields.forEach(forceField => {\n if (!forceField.disposed) {\n forceField.update(dt);\n }\n });\n this.forceFields = this.forceFields.filter(forceField => !forceField.disposed);\n // Update sinks\n this.sinks.forEach(sink => {\n if (!sink.disposed) {\n sink.update(dt);\n }\n });\n this.sinks = this.sinks.filter(sink => !sink.disposed);\n }\n draw(context) {\n this.particles.forEach(particle => {\n if (!particle.disposed) {\n particle.draw(this, context);\n }\n });\n }\n}\nexports.ParticleSystem = ParticleSystem;\n// -----------------------------------------------------------------------------\n// Particles\n// -----------------------------------------------------------------------------\nconst PARTICLE_DEFAULT_UPDATE_TYPES = [\n 'age',\n 'physics',\n 'direction',\n 'position',\n];\nconst PARTICLE_DEFAULT_DRAW_TYPES = ['transforms', 'fade', 'styles'];\nconst DEFAULT_PARTICLE_OPTIONS = {\n useAttractors: true,\n useForceFields: true,\n useColliders: true,\n useSinks: true,\n maxSpeed: -1,\n defaultUpdates: 'all',\n defaultDraws: 'all',\n};\nfunction prepareDefaultUpdates(defaultUpdates) {\n if (!Array.isArray(defaultUpdates)) {\n return defaultUpdates === 'all' ? [...PARTICLE_DEFAULT_UPDATE_TYPES] : [];\n }\n return defaultUpdates.filter(update => PARTICLE_DEFAULT_UPDATE_TYPES.includes(update));\n}\nfunction prepareDefaultDraws(defaultDraws) {\n if (!Array.isArray(defaultDraws)) {\n return defaultDraws === 'all' ? [...PARTICLE_DEFAULT_DRAW_TYPES] : [];\n }\n return defaultDraws.filter(draw => PARTICLE_DEFAULT_DRAW_TYPES.includes(draw));\n}\nfunction prepareGlow(context, glow, actualColor = 'white') {\n context.shadowColor = actualColor;\n context.shadowBlur = glow.amount;\n context.shadowOffsetX = 0;\n context.shadowOffsetY = 0;\n}\nconst DEFAULT_PARTICLE_STYLE = {\n style: 'dot',\n color: 'white',\n};\nclass Particle {\n constructor(\n /**\n * Initial position of the particle\n */\n position, \n /**\n * Initial velocity of the particle\n */\n velocity, \n /**\n * Size of the particle. This is used differently based on the style:\n *\n * - 'dot' style: we use the maximum of x and y as the radius\n * - 'line' style: x is the length of the line, y is the line width\n * - 'radial' style: we use the maximum of x and y as the radius\n * - 'image' style: x and y are the width and height of the image\n */\n size, \n /**\n * Rotation of the particle in radians\n *\n * _(Note: not used for 'dot' and 'radial' styles)_\n *\n * If this is null, we calculate rotation based on velocity\n */\n rotation = null, \n /**\n * Lifespan of the particle in seconds\n */\n lifespan = 1, \n /**\n * Style options for the particle. This can be used to define a default\n * rendering style and associated settings for the style\n *\n * The style can be one of:\n *\n * - 'dot': a simple dot with a color and optional glow\n * - 'radial': a radial gradient with a color that fades to transparent\n * - 'line': a line segment with a color, optional glow, and optional\n * rotation (the rotation can be relative or absolute)\n * - 'image': an image with an optional rotation (the rotation can be\n * relative or absolute)\n *\n * If this is null, the particle will use the custom rendering hook if\n * provided, or it will be invisible if no custom rendering is provided\n *\n * Omit this field or set it to undefined to use the default style\n */\n style, \n /**\n * Provide custom update logic and rendering logic here\n */\n options) {\n var _a, _b, _c;\n this.position = position;\n this.velocity = velocity;\n this.size = size;\n this.rotation = rotation;\n this.lifespan = lifespan;\n this.age = 0;\n this.style = null;\n this.actualRotation = 0;\n this.actualColor = '#fff';\n this.actualColorTransparent = '#fff0';\n this.actualGlowColor = '#fff';\n this._disposed = false;\n this.trailPositions = [];\n if (style !== null) {\n this.style = Object.assign({}, DEFAULT_PARTICLE_STYLE, style !== null && style !== void 0 ? style : {});\n }\n this.options = Object.assign({}, DEFAULT_PARTICLE_OPTIONS, options !== null && options !== void 0 ? options : {});\n // Prepare colors\n if (this.style && 'color' in this.style) {\n this.actualColor = prepareColor(this.style.color);\n this.actualColorTransparent = colorToString(makeTransparent(this.actualColor));\n if ('glow' in this.style) {\n this.actualGlowColor = prepareColor((_b = (_a = this.style.glow) === null || _a === void 0 ? void 0 : _a.color) !== null && _b !== void 0 ? _b : 'white');\n }\n }\n // Initialize trail positions with current position if trail is enabled\n if ((_c = this.style) === null || _c === void 0 ? void 0 : _c.trail) {\n this.trailPositions.push((0, vec_1.vec2)(position));\n }\n }\n get disposed() {\n return this._disposed;\n }\n get normalisedLifeRemaining() {\n if (this.lifespan <= 0) {\n return 0;\n }\n return (0, utils_1.unlerp)(this.age, 0, this.lifespan);\n }\n update(system, dt) {\n var _a, _b;\n const defaultUpdates = prepareDefaultUpdates(this.options.defaultUpdates);\n // Optionally handle particle lifespan\n if (defaultUpdates.includes('age')) {\n this.age += dt;\n // Dispose the particle when its lifespan is reached\n if (this.age >= this.lifespan) {\n this._disposed = true;\n }\n }\n // Optionally handle particle physics, i.e. forces from attractors, force\n // fields, colliders, and sinks\n if (defaultUpdates.includes('physics')) {\n if (this.options.useAttractors) {\n system.attractors.forEach(attractor => {\n if (!attractor.disposed) {\n attractor.applyForce(this, dt);\n }\n });\n }\n if (this.options.useForceFields) {\n system.forceFields.forEach(forceField => {\n if (!forceField.disposed) {\n forceField.applyForce(this, dt);\n }\n });\n }\n if (this.options.useSinks) {\n system.sinks.forEach(sink => {\n if (!sink.disposed) {\n sink.affect(this, dt);\n }\n });\n }\n if (this.options.useColliders) {\n system.colliders.forEach(collider => {\n collider.handleCollision(this);\n });\n }\n // Cap velocity to maxSpeed if specified\n if (this.options.maxSpeed > 0) {\n const speed = vec_1.vec2.len(this.velocity);\n if (speed > this.options.maxSpeed) {\n this.velocity = vec_1.vec2.scale(vec_1.vec2.nor(this.velocity), this.options.maxSpeed);\n }\n }\n }\n // Call custom update hook if provided\n if (this.options.update) {\n this.options.update.bind(this)(system, dt);\n }\n // Update rotation and, if configured, calculate rotation based on velocity\n this.actualRotation = (_a = this.rotation) !== null && _a !== void 0 ? _a : 0;\n if (defaultUpdates.includes('direction') && this.rotation === null) {\n this.actualRotation = vec_1.vec2.rad(this.velocity);\n }\n // Optionally handle position integration over time\n if (defaultUpdates.includes('position')) {\n this.position = vec_1.vec2.add(this.position, vec_1.vec2.scale(this.velocity, dt));\n }\n // Update trail positions if trail is enabled\n if (((_b = this.style) === null || _b === void 0 ? void 0 : _b.trail) && defaultUpdates.includes('position')) {\n // Only add new position if we've moved far enough from the last position\n const lastPosition = this.trailPositions[this.trailPositions.length - 1];\n if (!lastPosition ||\n (0, _2d_1.distance)(lastPosition, this.position) >=\n Particle.MINIMUM_TRAIL_MOVEMENT_THRESHOLD) {\n this.trailPositions.push((0, vec_1.vec2)(this.position));\n // Keep only the most recent positions based on trail length\n while (this.trailPositions.length > this.style.trail.length) {\n this.trailPositions.shift();\n }\n }\n }\n }\n drawTrail(context, particleAlpha = 1) {\n var _a, _b, _c;\n if (!((_a = this.style) === null || _a === void 0 ? void 0 : _a.trail) || this.trailPositions.length < 2) {\n return;\n }\n const trail = this.style.trail;\n const segments = this.trailPositions.length - 1;\n // Determine trail color\n const trailColor = trail.color\n ? prepareColor(trail.color)\n : this.style.style !== 'image' && 'color' in this.style\n ? this.actualColor\n : null;\n if (!trailColor) {\n return; // No valid color available\n }\n // Determine base width\n const baseWidth = (_b = trail.width) !== null && _b !== void 0 ? _b : (this.style.style === 'line'\n ? this.size.y\n : Math.max(this.size.x, this.size.y));\n context.save();\n context.lineCap = 'round';\n context.lineJoin = 'round';\n // Draw trail segments\n const widthDecay = Math.min(1, (_c = trail.widthDecay) !== null && _c !== void 0 ? _c : 1);\n for (let i = 0; i < segments; i++) {\n const start = this.trailPositions[i];\n const end = this.trailPositions[i + 1];\n // Calculate width based on decay\n const progress = 1 - i / (segments - 1);\n const decayFactor = 1 - progress * widthDecay;\n const width = baseWidth * decayFactor;\n // Calculate segment alpha based on fade settings\n let alpha = 1;\n if (trail.segmentFade) {\n const fadeIn = trail.segmentFade.in\n ? Math.min(1, 1 - i / trail.segmentFade.in)\n : 1;\n const fadeOut = trail.segmentFade.out\n ? Math.min(1, 1 - (segments - i) / trail.segmentFade.out)\n : 1;\n alpha = Math.min(fadeIn, fadeOut);\n }\n // Draw segment\n context.beginPath();\n context.strokeStyle = trailColor;\n context.lineWidth = width;\n context.globalAlpha = alpha * particleAlpha;\n context.moveTo(start.x, start.y);\n context.lineTo(end.x, end.y);\n context.stroke();\n }\n context.restore();\n }\n draw(system, context) {\n var _a, _b, _c, _d, _e, _f;\n const defaultDraws = prepareDefaultDraws(this.options.defaultDraws);\n context.save();\n // Optionally handle fade in/out effects\n let fadeAlpha = 1;\n if (defaultDraws.includes('fade') && ((_a = this.style) === null || _a === void 0 ? void 0 : _a.fade)) {\n const fadeIn = this.style.fade.in === 0\n ? 1\n : (0, utils_1.clamp)((0, utils_1.unlerp)(0, this.style.fade.in, this.age), 0, 1);\n const fadeOut = this.style.fade.out === 0\n ? 1\n : (0, utils_1.clamp)((0, utils_1.unlerp)(this.lifespan, this.lifespan - this.style.fade.out, this.age), 0, 1);\n fadeAlpha = (0, utils_1.clamp)(fadeIn * fadeOut, 0, 1);\n }\n // Draw trail before applying particle transforms\n if (defaultDraws.includes('styles') && ((_b = this.style) === null || _b === void 0 ? void 0 : _b.trail)) {\n this.drawTrail(context, fadeAlpha);\n }\n // Optionally apply transforms\n if (defaultDraws.includes('transforms')) {\n context.translate(this.position.x, this.position.y);\n }\n context.globalAlpha = fadeAlpha;\n // Call custom pre-draw hook if provided\n if (this.options.preDraw) {\n this.options.preDraw.bind(this)(system, context);\n }\n // Optionally render one of the default styles if configured\n if (defaultDraws.includes('styles') && this.style !== null) {\n switch (this.style.style) {\n case 'dot':\n // Dot style renders a circle with a fill color\n if (this.style.glow) {\n prepareGlow(context, this.style.glow, this.actualGlowColor);\n }\n (0, canvas_helpers_1.circle)(context, (0, vec_1.vec2)(), Math.max(this.size.x, this.size.y) / 2, {\n fill: true,\n fillColor: this.actualColor,\n stroke: false,\n });\n break;\n case 'radial':\n // Radial style renders a radial gradient circle\n const size = Math.max(this.size.x, this.size.y) / 2;\n const gradient = context.createRadialGradient(0, 0, 0, 0, 0, size);\n const startColor = this.actualColor;\n const endColor = this.actualColorTransparent;\n gradient.addColorStop(0, startColor);\n gradient.addColorStop(1, endColor);\n context.fillStyle = gradient;\n context.beginPath();\n context.arc(0, 0, size, 0, Math.PI * 2);\n context.fill();\n context.closePath();\n break;\n case 'line':\n // Line style renders a line segment with a stroke color\n if (this.style.glow) {\n prepareGlow(context, this.style.glow, this.actualGlowColor);\n }\n const angle = ((_c = this.actualRotation) !== null && _c !== void 0 ? _c : 0) + ((_d = this.style.rotationOffset) !== null && _d !== void 0 ? _d : 0);\n const length = this.size.x;\n const lineWidth = this.size.y;\n const vector = vec_1.vec2.rot((0, vec_1.vec2)(length, 0), angle);\n (0, canvas_helpers_1.line)(context, vec_1.vec2.scale(vector, -0.5), vec_1.vec2.scale(vector, 0.5), {\n lineWidth,\n strokeColor: this.actualColor,\n });\n break;\n case 'image':\n // Image style renders an image with optional rotation\n if (defaultDraws.includes('transforms')) {\n const angle = ((_e = this.actualRotation) !== null && _e !== void 0 ? _e : 0) + ((_f = this.style.rotationOffset) !== null && _f !== void 0 ? _f : 0);\n context.rotate(angle);\n }\n context.drawImage(this.style.image, -this.size.x / 2, -this.size.y / 2, this.size.x, this.size.y);\n break;\n }\n }\n // Call custom post-draw hook if provided\n if (this.options.postDraw) {\n this.options.postDraw.bind(this)(system, context);\n }\n context.restore();\n }\n}\nexports.Particle = Particle;\nParticle.MINIMUM_TRAIL_MOVEMENT_THRESHOLD = 5;\nconst DEFAULT_EMITTER_OPTIONS = {\n particles: {\n position: 'uniform',\n speed: 0,\n direction: 0,\n size: (0, vec_1.vec2)(1),\n rotation: null,\n lifespan: 1,\n style: DEFAULT_PARTICLE_STYLE,\n options: DEFAULT_PARTICLE_OPTIONS,\n },\n emission: {\n type: 'rate',\n rate: 1,\n },\n};\nclass Emitter {\n constructor(position, size = (0, vec_1.vec2)(0, 0), lifespan = -1, options) {\n this.position = position;\n this.size = size;\n this.lifespan = lifespan;\n this.age = 0;\n this.totalParticlesEmitted = 0;\n this._disposed = false;\n this.currentRate = 0;\n this.lastRateChange = 0;\n this.particlesToEmit = 0;\n this.options = Object.assign({}, DEFAULT_EMITTER_OPTIONS, options !== null && options !== void 0 ? options : {});\n }\n get disposed() {\n return this._disposed;\n }\n update(system, dt) {\n // Handle emitter aging and dispose if we've reached the lifespan\n this.age += dt;\n if (this.lifespan !== -1 && this.age >= this.lifespan) {\n this._disposed = true;\n return;\n }\n // Handle particle emission based on the type of emission configured\n switch (this.options.emission.type) {\n case 'rate':\n // Rate mode emits particles continuously at a specified rate\n // Handle random rate changes\n this.lastRateChange += dt;\n if (this.currentRate <= 0 ||\n this.lastRateChange >= Emitter.RANDOM_RATE_CHANGE_INTERVAL) {\n this.lastRateChange = 0;\n // The actual emission rate can be a fixed value or a random range\n this.currentRate = isRandomRange(this.options.emission.rate)\n ? calculateRandomRange(this.options.emission.rate)\n : this.options.emission.rate;\n }\n // Accumulate a fractional number of particles to emit\n this.particlesToEmit += this.currentRate * dt;\n // Emit particles if we have enough to emit\n if (this.particlesToEmit >= 1) {\n // Get the whole number of particles to emit\n const n = Math.floor(this.particlesToEmit);\n // Subtract the number of particles, keeping the remainder\n this.particlesToEmit -= n;\n // Emit the particles\n this.emitParticles(system, n);\n this.totalParticlesEmitted += n;\n }\n break;\n case 'burst':\n // Burst mode emits a fixed or random number of particles at once (or\n // after a delay) and then immediately disposes the emitter\n if (!this.options.emission.delay ||\n this.age >= this.options.emission.delay) {\n // The number of particles to emit can be a fixed value or a random\n // range\n const n = isRandomRange(this.options.emission.n)\n ? calculateRandomRange(this.options.emission.n, true)\n : Math.ceil(this.options.emission.n);\n if (n > 0) {\n this.emitParticles(system, n);\n this.totalParticlesEmitted += n;\n // Keep trying to emit until we've emitted at least one particle\n this._disposed = true;\n }\n }\n break;\n case 'custom':\n // Custom mode allows for a custom function to determine how many\n // particles to emit on each update\n const n = Math.ceil(this.options.emission.f.bind(this)());\n if (n > 0) {\n this.emitParticles(system, n);\n this.totalParticlesEmitted += n;\n }\n break;\n }\n }\n emitParticles(system, n) {\n for (let i = 0; i < n; i++) {\n const particle = this.createParticle(system, i);\n if (particle) {\n system.particles.push(particle);\n }\n }\n }\n createParticle(system, n) {\n // Generate position\n let position;\n if ((0, utilities_1.vectorAlmostZero)(this.size)) {\n // Emitter size is zero, so use the exact emitter position\n position = (0, vec_1.vec2)(this.position);\n }\n else {\n switch (this.options.particles.position) {\n case 'uniform':\n // Uniform distribution within the emitter area\n position = (0, vec_1.vec2)((0, utils_1.randomIntBetween)(this.position.x - this.size.x / 2, this.position.x + this.size.x / 2), (0, utils_1.randomIntBetween)(this.position.y - this.size.y / 2, this.position.y + this.size.y / 2));\n break;\n case 'normal':\n // Normal distribution from the center of the emitter area\n position = (0, vec_1.vec2)((0, utils_1.cltRandomInt)(this.position.x - this.size.x / 2, this.position.x + this.size.x / 2), (0, utils_1.cltRandomInt)(this.position.y - this.size.y / 2, this.position.y + this.size.y / 2));\n break;\n default:\n if (typeof this.options.particles.position === 'function') {\n // Custom position function\n position = this.options.particles.position.bind(this)(n);\n }\n else {\n // Something went wrong, fall back to emitter position\n position = (0, vec_1.vec2)(this.position);\n }\n }\n }\n // Generate velocity\n let speed;\n if (typeof this.options.particles.speed === 'function') {\n // Custom speed function\n speed = this.options.particles.speed.bind(this)(n);\n }\n else if (isRandomRange(this.options.particles.speed)) {\n // Random speed range\n speed = calculateRandomRange(this.options.particles.speed, true);\n }\n else {\n // Fixed speed\n speed = this.options.particles.speed;\n }\n let direction;\n if (typeof this.options.particles.direction === 'function') {\n // Custom direction function\n direction = this.options.particles.direction.bind(this)(n);\n }\n else if (isRandomRange(this.options.particles.direction)) {\n // Random direction range\n direction = calculateRandomRange(this.options.particles.direction);\n }\n else {\n // Fixed direction\n direction = this.options.particles.direction;\n }\n const velocity = vec_1.vec2.rot((0, vec_1.vec2)(speed, 0), direction);\n // Generate size\n let size;\n if (typeof this.options.particles.size === 'function') {\n // Custom size function\n size = this.options.particles.size.bind(this)(n);\n }\n else if (isRandomRange(this.options.particles.size)) {\n // Random size range\n size = calculateRandomRange(this.options.particles.size);\n }\n else {\n // Fixed size\n size = this.options.particles.size;\n }\n // Generate rotation\n let rotation;\n if (this.options.particles.rotation === null) {\n rotation = null;\n }\n else if (typeof this.options.particles.rotation === 'function') {\n // Custom rotation function\n rotation = this.options.particles.rotation.bind(this)(n);\n }\n else if (isRandomRange(this.options.particles.rotation)) {\n // Random rotation range\n rotation = calculateRandomRange(this.options.particles.rotation);\n }\n else {\n // Fixed rotation\n rotation = this.options.particles.rotation;\n }\n // Generate lifespan\n let lifespan;\n if (typeof this.options.particles.lifespan === 'function') {\n // Custom lifespan function\n lifespan = this.options.particles.lifespan.bind(this)(n);\n }\n else if (isRandomRange(this.options.particles.lifespan)) {\n // Random lifespan range\n lifespan = calculateRandomRange(this.options.particles.lifespan);\n }\n else {\n // Fixed lifespan\n lifespan = this.options.particles.lifespan;\n }\n return new Particle(position, velocity, size, rotation, lifespan, this.options.particles.style, this.options.particles.options);\n }\n}\nexports.Emitter = Emitter;\nEmitter.RANDOM_RATE_CHANGE_INTERVAL = 1;\n// -----------------------------------------------------------------------------\n// Attractors\n// -----------------------------------------------------------------------------\nclass Attractor {\n constructor(position, range = 100, force = 1, falloff = 1, lifespan = -1) {\n this.position = position;\n this.range = range;\n this.force = force;\n this.falloff = falloff;\n this.lifespan = lifespan;\n this.age = 0;\n this._disposed = false;\n }\n get disposed() {\n return this._disposed;\n }\n applyForce(particle, dt) {\n // Calculate distance to the particle\n const d = (0, _2d_1.distance)(this.position, particle.position);\n if (d > this.range) {\n return; // Particle is out of range\n }\n // Prevent divide-by-zero with a small minimum distance\n const minDistance = 1;\n const safeDistance = Math.max(d, minDistance);\n // Calculate direction vector from particle to attractor\n const direction = vec_1.vec2.sub(this.position, particle.position);\n const normalizedDirection = vec_1.vec2.nor(direction);\n // Use configurable falloff instead of fixed inverse square law\n // Higher falloff values create steeper gradients (stronger close-range effects)\n // Lower falloff values create gentler gradients (more uniform force fields)\n const distanceFactor = 1 / Math.pow(safeDistance, this.falloff);\n // Apply smooth range falloff at the boundary\n const rangeFactor = d / this.range;\n const rangeFalloff = Math.max(0, 1 - rangeFactor * rangeFactor);\n // Calculate final force vector\n const finalForceStrength = this.force * distanceFactor * rangeFalloff;\n const forceVector = vec_1.vec2.scale(normalizedDirection, finalForceStrength);\n // Apply the force to the particle's velocity\n particle.velocity = vec_1.vec2.add(particle.velocity, vec_1.vec2.scale(forceVector, dt));\n }\n update(dt) {\n this.age += dt;\n // Dispose the attractor when its lifespan is reached\n if (this.lifespan !== -1 && this.age >= this.lifespan) {\n this._disposed = true;\n }\n }\n}\nexports.Attractor = Attractor;\n// -----------------------------------------------------------------------------\n// Forcefields\n// -----------------------------------------------------------------------------\nclass ForceField {\n constructor(force = (0, vec_1.vec2)(0, 0), lifespan = -1) {\n this.force = force;\n this.lifespan = lifespan;\n this.age = 0;\n this._disposed = false;\n }\n get disposed() {\n return this._disposed;\n }\n applyForce(particle, dt) {\n particle.velocity = vec_1.vec2.add(particle.velocity, vec_1.vec2.scale(this.force, dt));\n }\n update(dt) {\n this.age += dt;\n // Dispose the force field when its lifespan is reached\n if (this.lifespan !== -1 && this.age >= this.lifespan) {\n this._disposed = true;\n }\n }\n}\nexports.ForceField = ForceField;\n// -----------------------------------------------------------------------------\n// Sinks\n// -----------------------------------------------------------------------------\nclass Sink {\n constructor(position, range = 50, strength = 1, falloff = 1, mode = 'fade', lifespan = -1) {\n this.position = position;\n this.range = range;\n this.strength = strength;\n this.falloff = falloff;\n this.mode = mode;\n this.lifespan = lifespan;\n this.age = 0;\n this._disposed = false;\n }\n get disposed() {\n return this._disposed;\n }\n affect(particle, dt) {\n // Calculate distance to the particle\n const d = (0, _2d_1.distance)(this.position, particle.position);\n if (d > this.range) {\n return; // Particle is out of range\n }\n // Instant mode: immediately set particle age to its lifespan\n if (this.mode === 'instant') {\n particle.age = particle.lifespan;\n return;\n }\n // Fade mode: accelerate particle aging based on strength and falloff\n // Prevent divide-by-zero with a small minimum distance\n const minDistance = 1;\n const safeDistance = Math.max(d, minDistance);\n // Use configurable falloff to create distance-based effect gradient\n // Higher falloff values create steeper gradients (stronger at center)\n // Lower falloff values create gentler gradients (more uniform effect)\n const distanceFactor = 1 / Math.pow(safeDistance, this.falloff);\n // Apply smooth range falloff at the boundary\n const rangeFactor = d / this.range;\n const rangeFalloff = Math.max(0, 1 - rangeFactor * rangeFactor);\n // Calculate final aging multiplier\n const agingMultiplier = this.strength * distanceFactor * rangeFalloff;\n // Accelerate particle aging\n particle.age += agingMultiplier * dt;\n }\n update(dt) {\n this.age += dt;\n // Dispose the sink when its lifespan is reached\n if (this.lifespan !== -1 && this.age >= this.lifespan) {\n this._disposed = true;\n }\n }\n}\nexports.Sink = Sink;\nclass Collider {\n constructor(geometry, restitution = 0.5, friction = 0.5, randomness = 0) {\n this.geometry = geometry;\n this.restitution = restitution;\n this.friction = friction;\n this.randomness = randomness;\n }\n handleCollision(particle) {\n var _a, _b;\n // Broad phase: first check if the point is in the collider's AABB\n const geometryAABB = (0, _2d_1.aabb)(this.geometry);\n if (geometryAABB === null) {\n return; // Invalid polygon\n }\n if (!(0, _2d_1.pointInAABB)(particle.position, geometryAABB)) {\n return; // Particle is outside the collider's AABB\n }\n // Narrow phase: check if the particle collides with the collider geometry\n let collisionResult;\n switch (this.geometry.type) {\n case 'circle':\n collisionResult = (0, _2d_1.pointInCircle)(particle.position, {\n position: this.geometry.position,\n radius: this.geometry.radius,\n });\n break;\n case 'rectangle':\n collisionResult = (0, _2d_1.pointInRectangle)(particle.position, {\n position: this.geometry.position,\n size: this.geometry.size,\n rotation: (_a = this.geometry.rotation) !== null && _a !== void 0 ? _a : 0,\n });\n break;\n case 'polygon':\n collisionResult = (0, _2d_1.pointInPolygon)(particle.position, {\n vertices: this.geometry.vertices,\n });\n break;\n }\n if (collisionResult === null || !collisionResult.intersects) {\n return; // Invalid polygon or no intersection\n }\n // Handle the collision\n // The collider has a friction value which is used to reduce the particle's\n // velocity after the collision\n // The collider has a restitution value which is used to bounce the particle\n // off the collider surface\n const normal = (_b = collisionResult.normal) !== null && _b !== void 0 ? _b : (0, vec_1.vec2)(0, 0);\n const relativeVelocity = vec_1.vec2.sub(particle.velocity, (0, vec_1.vec2)(0, 0));\n const velocityAlongNormal = vec_1.vec2.dot(relativeVelocity, normal);\n if (velocityAlongNormal > 0) {\n return; // Particle is moving away from the collider, no collision\n }\n // Calculate the impulse to apply to the particle\n const impulseMagnitude = -(1 + this.restitution) * velocityAlongNormal;\n const impulse = vec_1.vec2.scale(normal, impulseMagnitude);\n // Apply the impulse to the particle's velocity\n particle.velocity = vec_1.vec2.add(particle.velocity, impulse);\n // Apply randomness to the particle's velocity\n if (this.randomness > 0) {\n // Get a random angle between -PI and PI, scaled by randomness\n const randomAngle = (0, utils_1.randomBetween)(-Math.PI * this.randomness, Math.PI * this.randomness);\n particle.velocity = vec_1.vec2.rot(particle.velocity, randomAngle);\n }\n // Apply friction to the particle's velocity\n const frictionImpulse = vec_1.vec2.scale(vec_1.vec2.sub(relativeVelocity, vec_1.vec2.scale(normal, velocityAlongNormal)), -this.friction);\n particle.velocity = vec_1.vec2.add(particle.velocity, frictionImpulse);\n }\n}\nexports.Collider = Collider;\n\n\n//# sourceURL=webpack://@basementuniverse/particles-2d/./index.ts?");
|
|
90
90
|
|
|
91
91
|
/***/ })
|
|
92
92
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basementuniverse/particles-2d",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "A component for animating and rendering particles in 2d games",
|
|
5
5
|
"author": "Gordon Larrigan <gordonlarrigan@gmail.com> (https://gordonlarrigan.com)",
|
|
6
6
|
"license": "MIT",
|