From a09922445f6516c2184ee59f717b4d24247643bf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:25:31 +0000 Subject: [PATCH] Particles: the constructor centres the spawn area, say so The particles skill said the spawn area is measured from the position given to the constructor, and told games to offset by half. The constructor centres the area on (x, y), so that advice moved every effect up and left by half its size. Only `pos` set afterwards is the area's top left corner. - skill: pass the centre to the constructor; offset by half only when moving `pos` later; symptom rows for both mistakes - TSDoc for the constructor and the `width` setting - tests pinning both behaviours down Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JnzwA2BZwXkRJLpNxCXBNt --- .../melonjs-particles-and-trails/SKILL.md | 21 ++++-- packages/melonjs/src/particles/emitter.ts | 10 ++- packages/melonjs/src/particles/settings.ts | 3 +- .../melonjs/tests/particle-spawn-area.spec.js | 69 +++++++++++++++++++ 4 files changed, 92 insertions(+), 11 deletions(-) create mode 100644 packages/melonjs/tests/particle-spawn-area.spec.js diff --git a/packages/melonjs/skills/melonjs-particles-and-trails/SKILL.md b/packages/melonjs/skills/melonjs-particles-and-trails/SKILL.md index c41e26991..85be55fca 100644 --- a/packages/melonjs/skills/melonjs-particles-and-trails/SKILL.md +++ b/packages/melonjs/skills/melonjs-particles-and-trails/SKILL.md @@ -146,15 +146,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 @@ -386,7 +392,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 1c20752e3..e11df41ca 100644 --- a/packages/melonjs/src/particles/emitter.ts +++ b/packages/melonjs/src/particles/emitter.ts @@ -370,11 +370,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 8f79e7b99..f11e320db 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..874cfd4ba --- /dev/null +++ b/packages/melonjs/tests/particle-spawn-area.spec.js @@ -0,0 +1,69 @@ +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) => p.pos.x); + const ys = emitter.getChildren().map((p) => 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); + }); +});