@arcane-engine/runtime 0.1.0 → 0.2.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.
Files changed (47) hide show
  1. package/package.json +4 -2
  2. package/src/agent/protocol.ts +35 -1
  3. package/src/agent/types.ts +98 -13
  4. package/src/particles/emitter.test.ts +323 -0
  5. package/src/particles/emitter.ts +409 -0
  6. package/src/particles/index.ts +25 -0
  7. package/src/particles/types.ts +236 -0
  8. package/src/pathfinding/astar.ts +27 -0
  9. package/src/pathfinding/types.ts +39 -0
  10. package/src/physics/aabb.ts +55 -8
  11. package/src/rendering/animation.ts +73 -0
  12. package/src/rendering/audio.ts +29 -9
  13. package/src/rendering/camera.ts +28 -4
  14. package/src/rendering/input.ts +45 -9
  15. package/src/rendering/lighting.ts +29 -3
  16. package/src/rendering/loop.ts +16 -3
  17. package/src/rendering/sprites.ts +24 -1
  18. package/src/rendering/text.ts +52 -6
  19. package/src/rendering/texture.ts +22 -4
  20. package/src/rendering/tilemap.ts +36 -4
  21. package/src/rendering/types.ts +37 -19
  22. package/src/rendering/validate.ts +48 -3
  23. package/src/state/error.ts +21 -2
  24. package/src/state/observe.ts +40 -9
  25. package/src/state/prng.ts +88 -10
  26. package/src/state/query.ts +115 -15
  27. package/src/state/store.ts +42 -11
  28. package/src/state/transaction.ts +116 -12
  29. package/src/state/types.ts +31 -5
  30. package/src/systems/system.ts +77 -5
  31. package/src/systems/types.ts +52 -6
  32. package/src/testing/harness.ts +103 -5
  33. package/src/testing/mock-renderer.test.ts +16 -20
  34. package/src/tweening/chain.test.ts +191 -0
  35. package/src/tweening/chain.ts +103 -0
  36. package/src/tweening/easing.test.ts +134 -0
  37. package/src/tweening/easing.ts +288 -0
  38. package/src/tweening/helpers.test.ts +185 -0
  39. package/src/tweening/helpers.ts +166 -0
  40. package/src/tweening/index.ts +76 -0
  41. package/src/tweening/tween.test.ts +322 -0
  42. package/src/tweening/tween.ts +296 -0
  43. package/src/tweening/types.ts +134 -0
  44. package/src/ui/colors.ts +129 -0
  45. package/src/ui/index.ts +1 -0
  46. package/src/ui/primitives.ts +44 -5
  47. package/src/ui/types.ts +41 -2
@@ -0,0 +1,409 @@
1
+ /**
2
+ * Particle emitter implementation.
3
+ *
4
+ * Manages a global list of emitters. Call {@link updateParticles} once per frame
5
+ * to spawn new particles and advance existing ones. Then read particles via
6
+ * {@link getAllParticles} for rendering.
7
+ */
8
+
9
+ import type {
10
+ Particle,
11
+ Emitter,
12
+ EmitterConfig,
13
+ Affector,
14
+ } from "./types.ts";
15
+
16
+ /** Active emitters being updated each frame. */
17
+ const emitters: Emitter[] = [];
18
+
19
+ /** Counter for generating unique emitter IDs. */
20
+ let emitterIdCounter = 0;
21
+
22
+ /**
23
+ * Create a new particle emitter and add it to the global update list.
24
+ *
25
+ * The emitter immediately begins spawning particles according to its
26
+ * configuration (mode, rate, shape, etc.).
27
+ *
28
+ * @param config - Emitter configuration describing shape, mode, particle properties, and colors.
29
+ * @returns The created {@link Emitter} instance.
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * const sparks = createEmitter({
34
+ * shape: "point",
35
+ * x: 100, y: 100,
36
+ * mode: "burst",
37
+ * burstCount: 20,
38
+ * lifetime: [0.3, 0.8],
39
+ * velocityX: [-50, 50],
40
+ * velocityY: [-100, -20],
41
+ * startColor: { r: 1, g: 0.8, b: 0, a: 1 },
42
+ * endColor: { r: 1, g: 0, b: 0, a: 0 },
43
+ * textureId: sparkTexture,
44
+ * });
45
+ * ```
46
+ */
47
+ export function createEmitter(config: EmitterConfig): Emitter {
48
+ const emitter: Emitter = {
49
+ id: `emitter_${emitterIdCounter++}`,
50
+ config,
51
+ particles: [],
52
+ pool: [],
53
+ affectors: [],
54
+ emissionAccumulator: 0,
55
+ active: true,
56
+ used: false,
57
+ };
58
+
59
+ emitters.push(emitter);
60
+ return emitter;
61
+ }
62
+
63
+ /**
64
+ * Remove an emitter from the global update list.
65
+ * Its particles will no longer be updated or included in {@link getAllParticles}.
66
+ *
67
+ * @param emitter - The emitter to remove.
68
+ */
69
+ export function removeEmitter(emitter: Emitter): void {
70
+ const index = emitters.indexOf(emitter);
71
+ if (index !== -1) {
72
+ emitters.splice(index, 1);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Get a particle from the pool or create a new one
78
+ */
79
+ function getParticle(emitter: Emitter): Particle {
80
+ // Try to reuse from pool
81
+ for (const p of emitter.pool) {
82
+ if (!p.alive) {
83
+ p.alive = true;
84
+ return p;
85
+ }
86
+ }
87
+
88
+ // Create new particle
89
+ const particle: Particle = {
90
+ x: 0,
91
+ y: 0,
92
+ vx: 0,
93
+ vy: 0,
94
+ ax: 0,
95
+ ay: 0,
96
+ rotation: 0,
97
+ rotationSpeed: 0,
98
+ scale: 1,
99
+ scaleSpeed: 0,
100
+ color: { r: 1, g: 1, b: 1, a: 1 },
101
+ startColor: { r: 1, g: 1, b: 1, a: 1 },
102
+ endColor: { r: 1, g: 1, b: 1, a: 1 },
103
+ lifetime: 1,
104
+ age: 0,
105
+ alive: true,
106
+ textureId: 0,
107
+ };
108
+
109
+ emitter.pool.push(particle);
110
+ return particle;
111
+ }
112
+
113
+ /**
114
+ * Random number in range [min, max]
115
+ */
116
+ function randomRange(min: number, max: number): number {
117
+ return min + Math.random() * (max - min);
118
+ }
119
+
120
+ /**
121
+ * Interpolate between colors based on t (0 to 1)
122
+ */
123
+ function lerpColor(start: any, end: any, t: number) {
124
+ return {
125
+ r: start.r + (end.r - start.r) * t,
126
+ g: start.g + (end.g - start.g) * t,
127
+ b: start.b + (end.b - start.b) * t,
128
+ a: start.a + (end.a - start.a) * t,
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Spawn a single particle from an emitter
134
+ */
135
+ function spawnParticle(emitter: Emitter): void {
136
+ const { config } = emitter;
137
+
138
+ // Check max particles limit
139
+ const aliveCount = emitter.particles.filter((p) => p.alive).length;
140
+ if (config.maxParticles && aliveCount >= config.maxParticles) {
141
+ return;
142
+ }
143
+
144
+ const particle = getParticle(emitter);
145
+
146
+ // Set position based on emitter shape
147
+ switch (config.shape) {
148
+ case "point":
149
+ particle.x = config.x;
150
+ particle.y = config.y;
151
+ break;
152
+
153
+ case "line": {
154
+ const t = Math.random();
155
+ const x2 = config.shapeParams?.x2 ?? config.x;
156
+ const y2 = config.shapeParams?.y2 ?? config.y;
157
+ particle.x = config.x + (x2 - config.x) * t;
158
+ particle.y = config.y + (y2 - config.y) * t;
159
+ break;
160
+ }
161
+
162
+ case "area": {
163
+ const w = config.shapeParams?.width ?? 100;
164
+ const h = config.shapeParams?.height ?? 100;
165
+ particle.x = config.x + Math.random() * w;
166
+ particle.y = config.y + Math.random() * h;
167
+ break;
168
+ }
169
+
170
+ case "ring": {
171
+ const inner = config.shapeParams?.innerRadius ?? 0;
172
+ const outer = config.shapeParams?.outerRadius ?? 50;
173
+ const angle = Math.random() * Math.PI * 2;
174
+ const radius = randomRange(inner, outer);
175
+ particle.x = config.x + Math.cos(angle) * radius;
176
+ particle.y = config.y + Math.sin(angle) * radius;
177
+ break;
178
+ }
179
+ }
180
+
181
+ // Set velocity
182
+ particle.vx = randomRange(config.velocityX[0], config.velocityX[1]);
183
+ particle.vy = randomRange(config.velocityY[0], config.velocityY[1]);
184
+
185
+ // Set acceleration
186
+ if (config.accelerationX) {
187
+ particle.ax = randomRange(config.accelerationX[0], config.accelerationX[1]);
188
+ }
189
+ if (config.accelerationY) {
190
+ particle.ay = randomRange(config.accelerationY[0], config.accelerationY[1]);
191
+ }
192
+
193
+ // Set rotation
194
+ if (config.rotation) {
195
+ particle.rotation = randomRange(config.rotation[0], config.rotation[1]);
196
+ }
197
+ if (config.rotationSpeed) {
198
+ particle.rotationSpeed = randomRange(config.rotationSpeed[0], config.rotationSpeed[1]);
199
+ }
200
+
201
+ // Set scale
202
+ if (config.scale) {
203
+ particle.scale = randomRange(config.scale[0], config.scale[1]);
204
+ }
205
+ if (config.scaleSpeed) {
206
+ particle.scaleSpeed = randomRange(config.scaleSpeed[0], config.scaleSpeed[1]);
207
+ }
208
+
209
+ // Set colors
210
+ particle.startColor = { ...config.startColor };
211
+ particle.endColor = { ...config.endColor };
212
+ particle.color = { ...config.startColor };
213
+
214
+ // Set lifetime
215
+ particle.lifetime = randomRange(config.lifetime[0], config.lifetime[1]);
216
+ particle.age = 0;
217
+
218
+ // Set texture
219
+ particle.textureId = config.textureId;
220
+
221
+ // Add to active particles
222
+ if (!emitter.particles.includes(particle)) {
223
+ emitter.particles.push(particle);
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Emit particles based on emitter mode and rate
229
+ */
230
+ function emitParticles(emitter: Emitter, dt: number): void {
231
+ const { config, used } = emitter;
232
+
233
+ if (!emitter.active) return;
234
+
235
+ switch (config.mode) {
236
+ case "continuous": {
237
+ const rate = config.rate ?? 10;
238
+ emitter.emissionAccumulator += dt * rate;
239
+
240
+ while (emitter.emissionAccumulator >= 1) {
241
+ spawnParticle(emitter);
242
+ emitter.emissionAccumulator -= 1;
243
+ }
244
+ break;
245
+ }
246
+
247
+ case "burst": {
248
+ if (!used) {
249
+ const count = config.burstCount ?? 10;
250
+ for (let i = 0; i < count; i++) {
251
+ spawnParticle(emitter);
252
+ }
253
+ emitter.used = true;
254
+ }
255
+ break;
256
+ }
257
+
258
+ case "one-shot": {
259
+ if (!used) {
260
+ spawnParticle(emitter);
261
+ emitter.used = true;
262
+ }
263
+ break;
264
+ }
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Update a single particle
270
+ */
271
+ function updateParticle(particle: Particle, dt: number, affectors: Affector[]): void {
272
+ // Age the particle
273
+ particle.age += dt;
274
+
275
+ if (particle.age >= particle.lifetime) {
276
+ particle.alive = false;
277
+ return;
278
+ }
279
+
280
+ // Apply affectors
281
+ for (const affector of affectors) {
282
+ switch (affector.type) {
283
+ case "gravity":
284
+ case "wind": {
285
+ particle.ax += affector.forceX ?? 0;
286
+ particle.ay += affector.forceY ?? 0;
287
+ break;
288
+ }
289
+
290
+ case "attractor":
291
+ case "repulsor": {
292
+ const dx = (affector.centerX ?? 0) - particle.x;
293
+ const dy = (affector.centerY ?? 0) - particle.y;
294
+ const distSq = dx * dx + dy * dy;
295
+ const dist = Math.sqrt(distSq);
296
+
297
+ if (affector.radius === 0 || dist < affector.radius!) {
298
+ const strength = affector.strength ?? 100;
299
+ const force = (affector.type === "attractor" ? 1 : -1) * strength / Math.max(distSq, 1);
300
+ particle.ax += (dx / dist) * force;
301
+ particle.ay += (dy / dist) * force;
302
+ }
303
+ break;
304
+ }
305
+
306
+ case "turbulence": {
307
+ const turb = affector.turbulence ?? 10;
308
+ particle.ax += (Math.random() - 0.5) * turb;
309
+ particle.ay += (Math.random() - 0.5) * turb;
310
+ break;
311
+ }
312
+ }
313
+ }
314
+
315
+ // Update velocity
316
+ particle.vx += particle.ax * dt;
317
+ particle.vy += particle.ay * dt;
318
+
319
+ // Update position
320
+ particle.x += particle.vx * dt;
321
+ particle.y += particle.vy * dt;
322
+
323
+ // Update rotation
324
+ particle.rotation += particle.rotationSpeed * dt;
325
+
326
+ // Update scale
327
+ particle.scale += particle.scaleSpeed * dt;
328
+
329
+ // Interpolate color over lifetime
330
+ const t = particle.age / particle.lifetime;
331
+ particle.color = lerpColor(particle.startColor, particle.endColor, t);
332
+
333
+ // Reset acceleration (will be reapplied by affectors next frame)
334
+ particle.ax = 0;
335
+ particle.ay = 0;
336
+ }
337
+
338
+ /**
339
+ * Update all emitters and their particles by one frame.
340
+ *
341
+ * Spawns new particles based on each emitter's mode and rate, then
342
+ * advances all alive particles (velocity, position, rotation, scale,
343
+ * color interpolation, affectors, lifetime). Dead particles are marked
344
+ * `alive = false` and returned to the pool.
345
+ *
346
+ * Call this once per frame in your game loop.
347
+ *
348
+ * @param dt - Elapsed time since last frame in seconds. Must be >= 0.
349
+ */
350
+ export function updateParticles(dt: number): void {
351
+ for (const emitter of emitters) {
352
+ // Emit new particles
353
+ emitParticles(emitter, dt);
354
+
355
+ // Update existing particles
356
+ for (const particle of emitter.particles) {
357
+ if (particle.alive) {
358
+ updateParticle(particle, dt, emitter.affectors);
359
+ }
360
+ }
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Collect all alive particles from all active emitters.
366
+ *
367
+ * Use this each frame to get the particles to render (e.g., via drawSprite).
368
+ *
369
+ * @returns A new array of all alive {@link Particle} instances across all emitters.
370
+ */
371
+ export function getAllParticles(): Particle[] {
372
+ const result: Particle[] = [];
373
+ for (const emitter of emitters) {
374
+ for (const particle of emitter.particles) {
375
+ if (particle.alive) {
376
+ result.push(particle);
377
+ }
378
+ }
379
+ }
380
+ return result;
381
+ }
382
+
383
+ /**
384
+ * Add a particle affector to an emitter.
385
+ * Affectors modify particle acceleration each frame (gravity, wind, attraction, etc.).
386
+ *
387
+ * @param emitter - The emitter to add the affector to.
388
+ * @param affector - The affector configuration. See {@link Affector} for field details.
389
+ */
390
+ export function addAffector(emitter: Emitter, affector: Affector): void {
391
+ emitter.affectors.push(affector);
392
+ }
393
+
394
+ /**
395
+ * Remove all emitters and their particles from the global update list.
396
+ */
397
+ export function clearEmitters(): void {
398
+ emitters.length = 0;
399
+ }
400
+
401
+ /**
402
+ * Get the number of emitters currently in the global update list.
403
+ * Useful for debugging and testing.
404
+ *
405
+ * @returns Count of registered emitters (active or inactive).
406
+ */
407
+ export function getEmitterCount(): number {
408
+ return emitters.length;
409
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Particle system
3
+ *
4
+ * Provides particle emitters with various shapes, emission modes, and affectors.
5
+ */
6
+
7
+ export type {
8
+ Particle,
9
+ EmitterShape,
10
+ EmissionMode,
11
+ EmitterConfig,
12
+ AffectorType,
13
+ Affector,
14
+ Emitter,
15
+ } from "./types.ts";
16
+
17
+ export {
18
+ createEmitter,
19
+ removeEmitter,
20
+ updateParticles,
21
+ getAllParticles,
22
+ addAffector,
23
+ clearEmitters,
24
+ getEmitterCount,
25
+ } from "./emitter.ts";
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Particle system type definitions.
3
+ *
4
+ * Particles are small, short-lived visual elements (sparks, smoke, debris, etc.)
5
+ * spawned by {@link Emitter}s and optionally modified by {@link Affector}s.
6
+ */
7
+
8
+ import type { Color } from "../ui/types.ts";
9
+
10
+ /**
11
+ * A single particle managed by an emitter.
12
+ *
13
+ * Particles are pooled and reused. Check `alive` before rendering.
14
+ * Color interpolates from `startColor` to `endColor` over the particle's lifetime.
15
+ */
16
+ export interface Particle {
17
+ /** X position in world pixels. */
18
+ x: number;
19
+ /** Y position in world pixels. */
20
+ y: number;
21
+
22
+ /** X velocity in pixels per second. */
23
+ vx: number;
24
+ /** Y velocity in pixels per second. */
25
+ vy: number;
26
+
27
+ /** X acceleration in pixels per second squared. Reset each frame after affectors run. */
28
+ ax: number;
29
+ /** Y acceleration in pixels per second squared. Reset each frame after affectors run. */
30
+ ay: number;
31
+
32
+ /** Current rotation in radians. */
33
+ rotation: number;
34
+
35
+ /** Rotation speed in radians per second. */
36
+ rotationSpeed: number;
37
+
38
+ /** Current scale multiplier. 1.0 = original size. */
39
+ scale: number;
40
+
41
+ /** Scale change rate per second (positive = growing, negative = shrinking). */
42
+ scaleSpeed: number;
43
+
44
+ /** Current interpolated color (between startColor and endColor based on age/lifetime). */
45
+ color: Color;
46
+
47
+ /** Color at birth (age = 0). */
48
+ startColor: Color;
49
+
50
+ /** Color at death (age = lifetime). */
51
+ endColor: Color;
52
+
53
+ /** Total lifetime in seconds. The particle dies when age >= lifetime. */
54
+ lifetime: number;
55
+
56
+ /** Seconds since this particle was spawned. */
57
+ age: number;
58
+
59
+ /** Whether this particle is alive and should be updated/rendered. */
60
+ alive: boolean;
61
+
62
+ /** Texture ID used to render this particle via drawSprite(). */
63
+ textureId: number;
64
+ }
65
+
66
+ /**
67
+ * Shape of the emitter's spawn area.
68
+ * - `"point"` — all particles spawn at the emitter's (x, y).
69
+ * - `"line"` — particles spawn along a line from (x, y) to (x2, y2).
70
+ * - `"area"` — particles spawn randomly within a rectangle.
71
+ * - `"ring"` — particles spawn in an annular region between innerRadius and outerRadius.
72
+ */
73
+ export type EmitterShape = "point" | "line" | "area" | "ring";
74
+
75
+ /**
76
+ * How the emitter spawns particles.
77
+ * - `"continuous"` — spawns at a steady rate (particles/second) every frame.
78
+ * - `"burst"` — spawns `burstCount` particles all at once, then stops.
79
+ * - `"one-shot"` — spawns a single particle, then stops.
80
+ */
81
+ export type EmissionMode = "continuous" | "burst" | "one-shot";
82
+
83
+ /**
84
+ * Configuration for creating a particle emitter via {@link createEmitter}.
85
+ *
86
+ * Range fields like `lifetime`, `velocityX`, etc. are `[min, max]` tuples.
87
+ * Each spawned particle picks a random value within the range.
88
+ */
89
+ export interface EmitterConfig {
90
+ /** Shape of the spawn area. See {@link EmitterShape}. */
91
+ shape: EmitterShape;
92
+
93
+ /** X position of the emitter in world pixels. */
94
+ x: number;
95
+ /** Y position of the emitter in world pixels. */
96
+ y: number;
97
+
98
+ /** Shape-specific parameters. Which fields are used depends on `shape`. */
99
+ shapeParams?: {
100
+ /** End X for "line" shape. Default: same as emitter x. */
101
+ x2?: number;
102
+ /** End Y for "line" shape. Default: same as emitter y. */
103
+ y2?: number;
104
+
105
+ /** Width in pixels for "area" shape. Default: 100. */
106
+ width?: number;
107
+ /** Height in pixels for "area" shape. Default: 100. */
108
+ height?: number;
109
+
110
+ /** Inner radius in pixels for "ring" shape. Default: 0. */
111
+ innerRadius?: number;
112
+ /** Outer radius in pixels for "ring" shape. Default: 50. */
113
+ outerRadius?: number;
114
+ };
115
+
116
+ /** How particles are spawned. See {@link EmissionMode}. */
117
+ mode: EmissionMode;
118
+
119
+ /** Spawn rate in particles per second. Used when mode is "continuous". Default: 10. */
120
+ rate?: number;
121
+
122
+ /** Number of particles to spawn at once. Used when mode is "burst". Default: 10. */
123
+ burstCount?: number;
124
+
125
+ /** Particle lifetime range [min, max] in seconds. Each particle gets a random value in range. */
126
+ lifetime: [number, number];
127
+
128
+ /** Initial X velocity range [min, max] in pixels/second. */
129
+ velocityX: [number, number];
130
+ /** Initial Y velocity range [min, max] in pixels/second. */
131
+ velocityY: [number, number];
132
+
133
+ /** Initial X acceleration range [min, max] in pixels/second^2. Optional. */
134
+ accelerationX?: [number, number];
135
+ /** Initial Y acceleration range [min, max] in pixels/second^2. Optional. */
136
+ accelerationY?: [number, number];
137
+
138
+ /** Initial rotation range [min, max] in radians. Optional. */
139
+ rotation?: [number, number];
140
+
141
+ /** Rotation speed range [min, max] in radians/second. Optional. */
142
+ rotationSpeed?: [number, number];
143
+
144
+ /** Initial scale range [min, max]. 1.0 = original size. Optional. */
145
+ scale?: [number, number];
146
+
147
+ /** Scale change rate range [min, max] per second. Optional. */
148
+ scaleSpeed?: [number, number];
149
+
150
+ /** Color at particle birth. RGBA with components 0.0..1.0. */
151
+ startColor: Color;
152
+
153
+ /** Color at particle death. Interpolated linearly from startColor over lifetime. */
154
+ endColor: Color;
155
+
156
+ /** Texture ID for rendering particles. Obtain via loadTexture() or createSolidTexture(). */
157
+ textureId: number;
158
+
159
+ /** Maximum alive particles for this emitter. New particles are not spawned if at limit. Default: unlimited. */
160
+ maxParticles?: number;
161
+ }
162
+
163
+ /**
164
+ * Types of particle affectors that modify particle behavior each frame.
165
+ * - `"gravity"` — constant downward (or any direction) force.
166
+ * - `"wind"` — constant directional force (same as gravity, semantic distinction).
167
+ * - `"attractor"` — pulls particles toward a point.
168
+ * - `"repulsor"` — pushes particles away from a point.
169
+ * - `"turbulence"` — random jitter applied to acceleration each frame.
170
+ */
171
+ export type AffectorType = "gravity" | "wind" | "attractor" | "repulsor" | "turbulence";
172
+
173
+ /**
174
+ * A particle affector that modifies particle acceleration each frame.
175
+ * Attach to an emitter via {@link addAffector}.
176
+ *
177
+ * Which fields are used depends on `type`:
178
+ * - gravity/wind: `forceX`, `forceY`
179
+ * - attractor/repulsor: `centerX`, `centerY`, `strength`, `radius`
180
+ * - turbulence: `turbulence`
181
+ */
182
+ export interface Affector {
183
+ /** The type of force this affector applies. See {@link AffectorType}. */
184
+ type: AffectorType;
185
+
186
+ /** X component of force vector. Used by "gravity" and "wind". Default: 0. */
187
+ forceX?: number;
188
+ /** Y component of force vector. Used by "gravity" and "wind". Default: 0. */
189
+ forceY?: number;
190
+
191
+ /** X position of attraction/repulsion center. Used by "attractor" and "repulsor". Default: 0. */
192
+ centerX?: number;
193
+ /** Y position of attraction/repulsion center. Used by "attractor" and "repulsor". Default: 0. */
194
+ centerY?: number;
195
+
196
+ /** Force strength for attractor/repulsor. Higher = stronger pull/push. Default: 100. */
197
+ strength?: number;
198
+
199
+ /** Effect radius in pixels for attractor/repulsor. 0 = infinite range. Default: 0. */
200
+ radius?: number;
201
+
202
+ /** Turbulence intensity. Higher = more random jitter. Default: 10. */
203
+ turbulence?: number;
204
+ }
205
+
206
+ /**
207
+ * A particle emitter instance returned by {@link createEmitter}.
208
+ *
209
+ * Manages a pool of particles and spawns them according to its config.
210
+ * Updated each frame by {@link updateParticles}.
211
+ */
212
+ export interface Emitter {
213
+ /** Unique identifier, auto-generated as "emitter_0", "emitter_1", etc. */
214
+ id: string;
215
+
216
+ /** The emitter's configuration (shape, mode, ranges, colors, etc.). */
217
+ config: EmitterConfig;
218
+
219
+ /** All particles (alive and dead) managed by this emitter. */
220
+ particles: Particle[];
221
+
222
+ /** Object pool for particle reuse to reduce GC pressure. */
223
+ pool: Particle[];
224
+
225
+ /** Affectors that modify this emitter's particles each frame. */
226
+ affectors: Affector[];
227
+
228
+ /** Internal accumulator for continuous emission rate timing. */
229
+ emissionAccumulator: number;
230
+
231
+ /** Whether the emitter is actively spawning particles. Set to false to pause spawning. */
232
+ active: boolean;
233
+
234
+ /** Whether the emitter has fired (used by "burst" and "one-shot" modes to prevent re-firing). */
235
+ used: boolean;
236
+ }
@@ -76,6 +76,33 @@ function selectHeuristic(name: string): (dx: number, dy: number) => number {
76
76
 
77
77
  // --- A* ---
78
78
 
79
+ /**
80
+ * Find the shortest path between two tiles on a grid using A* search
81
+ * with a binary min-heap for the open set.
82
+ *
83
+ * Returns immediately if start equals goal, or if either endpoint is
84
+ * out of bounds / unwalkable. Stops early if `maxIterations` is exceeded.
85
+ *
86
+ * @param grid - The pathfinding grid with dimensions, walkability, and optional cost function.
87
+ * @param start - Starting tile position (integer coordinates).
88
+ * @param goal - Goal tile position (integer coordinates).
89
+ * @param options - Optional search parameters: diagonal movement, max iterations, heuristic.
90
+ * @returns A {@link PathResult} with `found`, `path`, `cost`, and `explored` count.
91
+ *
92
+ * @example
93
+ * ```ts
94
+ * const grid: PathGrid = {
95
+ * width: 10, height: 10,
96
+ * isWalkable: (x, y) => map[y][x] !== "wall",
97
+ * };
98
+ * const result = findPath(grid, { x: 0, y: 0 }, { x: 9, y: 9 }, { diagonal: true });
99
+ * if (result.found) {
100
+ * for (const step of result.path) {
101
+ * console.log(`Move to (${step.x}, ${step.y})`);
102
+ * }
103
+ * }
104
+ * ```
105
+ */
79
106
  export function findPath(
80
107
  grid: PathGrid,
81
108
  start: Vec2,