diff --git a/packages/melonjs/skills/melonjs-particles-and-trails/SKILL.md b/packages/melonjs/skills/melonjs-particles-and-trails/SKILL.md index 391d9b3b0..e847d0ba1 100644 --- a/packages/melonjs/skills/melonjs-particles-and-trails/SKILL.md +++ b/packages/melonjs/skills/melonjs-particles-and-trails/SKILL.md @@ -151,15 +151,21 @@ fountain and wrong for an explosion: additive sprites piled on one point clip all three channels there however well they are tinted, so a burst reads as one bright flash rather than as debris. -Give it the size of whatever broke. The area is measured from the emitter's own -position, NOT centred on it, so offset by half or the whole burst sits down and -to the right of its source: +Give it the size of whatever broke. The constructor centres the area on the +point it is given, so pass the centre of the source: ```js const area = radius * 2; -const emitter = new ParticleEmitter(x - area / 2, y - area / 2, { - width: area, height: area, -}); +const emitter = new ParticleEmitter(x, y, { width: area, height: area }); +``` + +After that, `pos` is the area's top-left corner, as on any renderable. To move +the emitter to a new source later, offset by half, or the burst sits down and +to the right of it: + +```js +emitter.pos.set(x - area / 2, y - area / 2); // re-aimed at (x, y) +emitter.burstParticles(); ``` Under a `Camera3d`, `depth` (20.13) gives the area its third side: each @@ -392,7 +398,8 @@ answer when you want discrete puffs rather than a continuous ribbon. | frame rate drops with many effects | an emitter per event instead of one re-aimed emitter | | a 3D burst is invisible under a `Camera3d` | before 20.8 a particle was culled at twice its depth; update | | the whole burst launches from one pixel | `width`/`height` are the spawn area and default to `1` | -| the burst sits down-right of its source | the spawn area starts at the emitter's position; offset by half | +| the burst sits down-right of its source | the emitter was moved by setting `pos` to the source: `pos` is the area's top-left corner, so offset by half (only the constructor centres it) | +| the burst sits up-left of its source | the constructor was given the top-left corner: it centres the area on (x, y), so pass the centre | | the middle of a burst goes white | additive sprites overlapping — `textureAdditive: false`, or spread them | | debris chunks look like squares | the default sprite is a filled square; pass your own `image` | | a ring tilts with the camera instead of staying on a surface | the launch axis is in WORLD axes, not the camera basis | diff --git a/packages/melonjs/src/particles/emitter.ts b/packages/melonjs/src/particles/emitter.ts index e163dbe7a..656982ddb 100644 --- a/packages/melonjs/src/particles/emitter.ts +++ b/packages/melonjs/src/particles/emitter.ts @@ -361,11 +361,15 @@ export default class ParticleEmitter extends Container { _spawnMap: Matrix3d | undefined; /** - * @param x - x position of the particle emitter - * @param y - y position of the particle emitter + * The spawn area (`settings.width` by `settings.height`) is centred on + * (x, y). After construction `pos` is the area's top left corner, as on + * any renderable: to move the emitter to a new point, offset by half the + * area. + * @param x - x position of the centre of the spawn area + * @param y - y position of the centre of the spawn area * @param [settings=ParticleEmitterSettings] - the settings for the particle emitter. * @example - * // Create a particle emitter at position 100, 100 + * // Create a particle emitter centred on 100, 100 * let emitter = new ParticleEmitter(100, 100, { * width: 16, * height : 16, diff --git a/packages/melonjs/src/particles/settings.ts b/packages/melonjs/src/particles/settings.ts index 26c5d23cc..ac3f00a18 100644 --- a/packages/melonjs/src/particles/settings.ts +++ b/packages/melonjs/src/particles/settings.ts @@ -11,7 +11,8 @@ import type ParticleEmitter from "./emitter.ts"; */ export interface ParticleEmitterSettings { /** - * Width of the particle spawn area. + * Width of the particle spawn area. The constructor centres the area on + * the point it is given; `pos` is then its top left corner. * @default 1 */ width: number; diff --git a/packages/melonjs/tests/particle-spawn-area.spec.js b/packages/melonjs/tests/particle-spawn-area.spec.js new file mode 100644 index 000000000..08832e552 --- /dev/null +++ b/packages/melonjs/tests/particle-spawn-area.spec.js @@ -0,0 +1,73 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { Application, boot, ParticleEmitter, video } from "../src/index.js"; + +/** + * Where the spawn area is. The constructor centres it on the point it is + * given; after that, `pos` is its top left corner, as for any renderable. + */ +describe("particle spawn area", () => { + let app; + + beforeAll(async () => { + boot(); + app = new Application(800, 600, { + parent: "screen", + renderer: video.CANVAS, + }); + await app.init(); + }); + + afterAll(() => { + app.destroy?.(); + }); + + const spawned = (emitter) => { + app.world.addChild(emitter); + emitter.burstParticles(200); + const xs = emitter.getChildren().map((p) => { + return p.pos.x; + }); + const ys = emitter.getChildren().map((p) => { + return p.pos.y; + }); + app.world.removeChildNow(emitter); + return { + minX: Math.min(...xs), + maxX: Math.max(...xs), + minY: Math.min(...ys), + maxY: Math.max(...ys), + }; + }; + + it("the constructor centres the area on (x, y)", () => { + const emitter = new ParticleEmitter(200, 150, { + width: 100, + height: 60, + referenceSpace: "world", + }); + expect(emitter.pos.x).toBe(150); + expect(emitter.pos.y).toBe(120); + const area = spawned(emitter); + expect(area.minX).toBeGreaterThanOrEqual(150); + expect(area.maxX).toBeLessThanOrEqual(250); + expect(area.minY).toBeGreaterThanOrEqual(120); + expect(area.maxY).toBeLessThanOrEqual(180); + // and it is spread around that centre, not piled in one corner + expect(area.minX).toBeLessThan(170); + expect(area.maxX).toBeGreaterThan(230); + }); + + it("moving pos afterwards places the area's top left corner", () => { + const emitter = new ParticleEmitter(0, 0, { + width: 100, + height: 60, + referenceSpace: "world", + }); + emitter.pos.set(300, 200); + const area = spawned(emitter); + expect(area.minX).toBeGreaterThanOrEqual(300); + expect(area.maxX).toBeLessThanOrEqual(400); + expect(area.minY).toBeGreaterThanOrEqual(200); + expect(area.maxY).toBeLessThanOrEqual(260); + }); +});