# @yagejs/particles

Depends on `@yagejs/core`, `@yagejs/renderer`. Pooled particle emitters.

## Setup

```ts yage-context="engine"
import { ParticlesPlugin } from "@yagejs/particles";
engine.use(new ParticlesPlugin());
```

## ParticleEmitterComponent

```ts yage-context="entity"
import { ParticleEmitterComponent } from "@yagejs/particles";
import { texture } from "@yagejs/renderer";

const particleTex = texture("assets/particle.png");

entity.add(
  new ParticleEmitterComponent({
    texture: particleTex, // TextureInput — a texture, handle, or asset key
    // shape: "softCircle",     // built-in shape, no asset needed
    maxParticles: 200, // default 100
    rate: 20, // particles/sec, default 10
    lifetime: [0.5, 1.5], // seconds (required)
    speed: [50, 150], // px/s
    angle: [-Math.PI, Math.PI], // radians
    scale: { start: 1, end: 0 }, // Lerped
    alpha: 1, // NumberRange or Lerped, like scale
    alphaFadeIn: 0.2, // fraction of each particle's life, 0–1
    alphaFadeOut: 0.3, // multiplies alpha, so a lerped alpha fades twice
    rotation: 0, // radians
    rotationSpeed: 0, // rad/s
    tint: 0xff6600,
    blendMode: "add", // whole-emitter, defaults to the layer's mode
    gravity: { x: 0, y: 200 }, // px/s²
    damping: 0, // 0–1
    spawnOffset: { x: [-10, 10], y: 0 }, // or { radius, angle } for a ring
    radialSpeed: -110, // px/s along the spawn offset; negative is inward
    simulationSpace: "world", // or "local" to follow the emitter
    layer: "effects",
  }),
);
```

NumberRange: `number | [min, max]`. Lerped: `{ start: NumberRange, end: NumberRange }`.

`texture` and `shape` are mutually exclusive — setting both is a type error.
Both are optional: `new ParticleEmitterComponent({ lifetime: 1 })` renders
white square particles with no asset. A string `texture` is an asset key and
resolves like every other key-based surface: an unloaded key throws and names
the key. The default
`"pixel"` is 1×1, so set `scale` (or a shape `size`) for a visible size; the
other shapes are 64px and already visible at `scale: 1`.

## Built-in shapes

```ts
import type { ShapeConfig as BaseShapeConfig } from "@yagejs/particles";
import type { TextureResource } from "@yagejs/renderer";

type ParticleShape =
  | "pixel" // white rectangle; 1×1 by default (shared Texture.WHITE)
  | "circle" // solid disc, ellipse on a non-square size
  | "softCircle" // disc fading to transparent at the edge
  | "diamond" // solid diamond
  | "softDiamond" // diamond fading to transparent — reads as a 4-point sparkle
  | "line"; // filled streak, 64×8 by default

type ShapeSize = number | [width: number, height: number];
interface ShapeConfig extends BaseShapeConfig {
  type: ParticleShape;
  size?: ShapeSize;
}

// EmitterConfig: shape?: ParticleShape | ShapeConfig (exclusive with texture)
declare function shapeTexture(
  shape: ParticleShape | ShapeConfig,
): TextureResource;
```

```ts
import type { TextureSource } from "@yagejs/particles";

const sources: TextureSource[] = [
  { shape: "softCircle" }, // 64×64
  { shape: { type: "softCircle", size: 16 } }, // 16×16 texture
  { shape: { type: "circle", size: [32, 16] } }, // ellipse
  { shape: { type: "line", size: [4, 32] } }, // vertical streak (rain)
];
```

Shapes are white — set `tint` to color them. `size` is the generated texture's
size in pixels, which at the default `scale: 1` is also the size a particle
covers on screen. Every distinct size generates and caches its own texture, so
keep to a few and vary per-particle size with `scale`. Default size is 64×64,
`line` 64×8, `pixel` 1×1. A non-square size stretches the shape into it; no
shape forces an aspect ratio. `pixel` and `line` fill their texture edge to
edge; the other four antialias their outline inside it, over one pixel, and
fill their texture instead once they get too thin for an outline (3px or less
on either axis). Every shape is visible at every size, down to 1×1. `line` at
its default is horizontal — either size it vertically or set `rotation` to aim
it along travel. A size must be a finite number above 0; anything else throws.

Each type+size pair is generated on first use and shared by every emitter
asking for it: never destroy the texture `shapeTexture` returns. Generation
writes an RGBA buffer directly, so it needs no DOM or renderer. A 1×1 `pixel` is
`Texture.WHITE` and generates nothing.

Control:

```ts yage-context="entity"
import { ParticleEmitterComponent } from "@yagejs/particles";

declare const x: number, y: number, aim: number; // world position, angle in radians
const emitter = entity.get(ParticleEmitterComponent);

emitter.emit(); // start continuous
emitter.stop(); // stop only emission started by emit()
const request = emitter.requestEmission(); // ParticleEmissionHandle
request.release(); // release only this request; idempotent
emitter.burst(50); // spawn at the entity's world position
emitter.burst(10, x, y); // burst at an explicit world position
emitter.burst(10, { angle: aim }); // these 10 particles only
emitter.burst(10, x, y, { tint: 0xff0000 }); // position and overrides together
emitter.configure({ rate: 40 }); // EmitterUpdateOptions — from now on
emitter.isEmitting; // boolean
emitter.activeCount; // number
emitter.blendMode = "add"; // BlendMode, read/write
```

Manual emission and temporary requests compose. `isEmitting` stays true while
`emit()` is active or at least one `ParticleEmissionHandle` is active.
`stop()` does not cancel requests, and releasing a request does not cancel
manual emission or other requests. Requests are transient and invalidated
when the emitter is destroyed.

## Changing an emitter at runtime

Two surfaces, answering two different questions.

**`configure(options: EmitterUpdateOptions)`** is "this emitter is different
from now on". It changes `lifetime`, `speed`, `angle`, `scale`, `alpha`,
`rotation`, `rotationSpeed`, `tint`, `spawnOffset`, `radialSpeed`, `rate`,
`gravity`, `damping`, `alphaFadeIn`, `alphaFadeOut` and `blendMode`. When a
change reaches a particle depends on where the emitter reads the option. The
spawn-time options — `lifetime`, `speed`, `angle`, `scale`, `alpha`, `rotation`,
`rotationSpeed`, `tint`, `spawnOffset` and `radialSpeed` — are resolved once per
particle, so a particle already in flight keeps what it was spawned with and the
next particle spawned uses the new value. `gravity`, `damping`, `alphaFadeIn`
and `alphaFadeOut` are read from the emitter every frame for every live
particle, so they reach particles already in flight on the next frame. `rate`
applies to the next frame of continuous emission, and `blendMode` is a property
of the container every particle is drawn in.

**`burst(count, overrides: BurstOverrides)`** is "these `count` particles are
different". It takes the spawn-time options — `lifetime`, `speed`, `angle`,
`scale`, `alpha`, `rotation`, `rotationSpeed`, `tint`, `spawnOffset`,
`radialSpeed` — and nothing else changes: neither the emitter's own
configuration nor any particle already alive. `gravity`, `damping`,
`alphaFadeIn` and `alphaFadeOut` are `configure` only, because a burst cannot
own a value the update reads from the emitter itself.

```ts
import type { ParticleEmitterComponent } from "@yagejs/particles";

declare const emitter: ParticleEmitterComponent;
declare const fistX: number, fistY: number, swing: number; // swing angle in radians

// A melee trail that follows the swing, while earlier particles hold theirs.
emitter.burst(2, fistX, fistY, { angle: [swing - 0.18, swing + 0.18] });
```

Fixed when the emitter is built, and absent from both types: `maxParticles`
and the texture source, which the particle pool allocates against; `layer`,
read once when the component is added; and `simulationSpace`. Naming one of
them in either method's options is a type error. TypeScript reports that error
only for an object literal written at the call site, so a spread or a variable
typed as `EmitterConfig` compiles and passes the option anyway. Each method
ignores any option outside its own type.
`configure({ ...ParticlePresets.fire() })` applies everything in the preset
except its `maxParticles` and `shape`.
An option passed as `undefined` leaves that setting unchanged.

Both methods check the whole merged configuration and throw on a bad value, the
same way construction does. A rejected `configure` leaves every previous value
in force — there is no partial application. Checking the merged object is also
what lets `configure({ radialSpeed })` pass on an emitter that already has a
`spawnOffset`.

**The emitter never reads a caller's object twice.** It copies the
configuration it is constructed with, and copies what `configure` is given,
nested values included: a `[min, max]` array, a `Lerped` pair, `gravity` and
`spawnOffset`. Changing either object afterwards changes nothing. A burst's
overrides are read during the call and not kept.

**An entity holds one component of a class**, so one entity has one emitter.
A second look that `configure` and burst overrides cannot cover — a different
texture, a different pool size — needs a second entity.

**`blendMode`** is per emitter — every particle it spawns blends the same way,
and the mode cannot vary particle by particle. Overlapping particles within one
emitter still accumulate, so `"add"` is what makes fire, sparks, and magic
brighten where they pile up. Same `BlendMode` values and same
`import "pixi.js/advanced-blend-modes"` requirement as the renderer's visual
components (see `renderer.md`).

Continuous emission and a no-argument `burst` both spawn at the entity's
`Transform.worldPosition`, so a child entity emits where it is drawn, not at
its parent's origin. Each particle is drawn centred on its spawn point and
`rotationSpeed` turns it about its own centre — for a shape and for your own
texture alike.

The emitter's container follows the entity's position, so a layer sort reads
it like any other visual: `ySort` keys an emitter off its entity, and
`ySortBy` reads a depth offset you set on `emitter.container`.

**`simulationSpace`** decides what happens to particles already in flight when
the emitter moves. `"world"` (default) leaves them where they were drawn —
a torch trail stays behind the walking torch. `"local"` carries them with the
emitter, which is what an aura, a shield, or a charge-up glow needs. Position
only: the emitter's rotation and scale are not applied.

**A ring spawn plus `radialSpeed`** makes particles fly outward from, or
converge on, where they spawned:

```ts
import { ParticleEmitterComponent } from "@yagejs/particles";

new ParticleEmitterComponent({
  lifetime: 0.4,
  spawnOffset: { radius: 42 }, // start on a ring (add `angle` for an arc)
  radialSpeed: -110, // px/s inward; positive flies outward
});
```

`radialSpeed` adds to the velocity `speed` and `angle` produce, so the two
compose. It needs a `spawnOffset` — a particle at the emitter's origin has no
outward direction — and a particle whose offset resolves to exactly (0, 0)
takes no radial term.

**`alphaFadeIn` and `alphaFadeOut`** are fractions of each particle's own
lifetime, both 0-1 and both 0 by default. They multiply whatever `alpha`
produces rather than replacing it, so `alpha: { start: 0, end: 1 }` plus
`alphaFadeIn: 0.2` fades in twice, once from each. A fade-in makes a particle spawn at 0 alpha
instead of popping in, which is what an ambient emitter spread over an area
needs. Fractions that add up to more than 1 overlap and multiply in the middle, so
alpha never reaches the value `alpha` asked for. `scale` has no envelope.

```ts
import type { EmitterConfig } from "@yagejs/particles";

const ambient: EmitterConfig = {
  lifetime: [1, 2],
  alpha: 0.6,
  alphaFadeIn: 0.15,
  alphaFadeOut: 0.4,
};
```

**Numeric config is checked at construction.** Every number in the config must
be finite, `lifetime` above 0, `damping` between 0 and 1, `alphaFadeIn` and
`alphaFadeOut` between 0 and 1, `rate` at least 0, and `maxParticles` a whole
number at least 0. A value outside its range throws a plain `Error` naming the
option and the value, before the emitter allocates anything.

**An emitter needs a `Transform` on the same entity.** `ParticleSystem` queries
`[Transform, ParticleEmitterComponent]`, so without one the emitter never runs:
no continuous emission, and `burst` particles stay frozen forever. The first
`emit()` or `burst()` on such an entity logs a warning once.

## ParticlePresets

```ts yage-context="object-member"
import { ParticlePresets } from "@yagejs/particles";
import type { EmitterConfig } from "@yagejs/particles";
import type { TextureInput } from "@yagejs/renderer";

fire(textureOrKey?: TextureInput): EmitterConfig    // warm, upward, shrinking
smoke(textureOrKey?: TextureInput): EmitterConfig   // slow, expanding, fading
sparks(textureOrKey?: TextureInput): EmitterConfig  // fast, short, gravity
rain(textureOrKey?: TextureInput): EmitterConfig    // downward, uniform
```

```ts yage-group="presets"
import { ParticleEmitterComponent, ParticlePresets } from "@yagejs/particles";
import { texture } from "@yagejs/renderer";

const myTex = texture("assets/particle.png");

new ParticleEmitterComponent(ParticlePresets.fire()); // zero assets
new ParticleEmitterComponent(ParticlePresets.fire(myTex)); // your own art
```

With no argument each preset falls back to its own built-in shape, sized to the
effect: `fire` `softCircle` 32, `smoke` `softCircle` 40, `sparks` `line` 10×3,
`rain` `line` 2×20. The on-screen particle size lives in that `size`, so preset
`scale` values are animation and variation centred on 1 — with your texture the
effect animates it at its natural size.

Spreading overrides anything except the texture source:

```ts yage-group="presets"
import type { EmitterConfig } from "@yagejs/particles";

// ok
const blueFire: EmitterConfig = {
  ...ParticlePresets.fire(),
  rate: 50,
  tint: 0x00ccff,
};
// type error: two sources
// yage-expect-error TS2375
const twoSources: EmitterConfig = { ...ParticlePresets.fire(), texture: myTex };
// pass the source as the argument
const ownArt: EmitterConfig = { ...ParticlePresets.fire(myTex), rate: 50 };
```
