@flighthq/particles-formats 0.1.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 (99) hide show
  1. package/dist/detect.d.ts +18 -0
  2. package/dist/detect.d.ts.map +1 -0
  3. package/dist/detect.js +75 -0
  4. package/dist/detect.js.map +1 -0
  5. package/dist/formatRegistry.d.ts +60 -0
  6. package/dist/formatRegistry.d.ts.map +1 -0
  7. package/dist/formatRegistry.js +75 -0
  8. package/dist/formatRegistry.js.map +1 -0
  9. package/dist/index.d.ts +21 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +21 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/libgdxParse.d.ts +27 -0
  14. package/dist/libgdxParse.d.ts.map +1 -0
  15. package/dist/libgdxParse.js +316 -0
  16. package/dist/libgdxParse.js.map +1 -0
  17. package/dist/libgdxSchema.d.ts +68 -0
  18. package/dist/libgdxSchema.d.ts.map +1 -0
  19. package/dist/libgdxSchema.js +12 -0
  20. package/dist/libgdxSchema.js.map +1 -0
  21. package/dist/libgdxSerialize.d.ts +17 -0
  22. package/dist/libgdxSerialize.d.ts.map +1 -0
  23. package/dist/libgdxSerialize.js +295 -0
  24. package/dist/libgdxSerialize.js.map +1 -0
  25. package/dist/parseParticleConfig.d.ts +34 -0
  26. package/dist/parseParticleConfig.d.ts.map +1 -0
  27. package/dist/parseParticleConfig.js +96 -0
  28. package/dist/parseParticleConfig.js.map +1 -0
  29. package/dist/particleDesignerParse.d.ts +23 -0
  30. package/dist/particleDesignerParse.d.ts.map +1 -0
  31. package/dist/particleDesignerParse.js +214 -0
  32. package/dist/particleDesignerParse.js.map +1 -0
  33. package/dist/particleDesignerSchema.d.ts +51 -0
  34. package/dist/particleDesignerSchema.d.ts.map +1 -0
  35. package/dist/particleDesignerSchema.js +5 -0
  36. package/dist/particleDesignerSchema.js.map +1 -0
  37. package/dist/particleDesignerSerialize.d.ts +22 -0
  38. package/dist/particleDesignerSerialize.d.ts.map +1 -0
  39. package/dist/particleDesignerSerialize.js +209 -0
  40. package/dist/particleDesignerSerialize.js.map +1 -0
  41. package/dist/pixiParse.d.ts +18 -0
  42. package/dist/pixiParse.d.ts.map +1 -0
  43. package/dist/pixiParse.js +173 -0
  44. package/dist/pixiParse.js.map +1 -0
  45. package/dist/serializeResult.d.ts +15 -0
  46. package/dist/serializeResult.d.ts.map +1 -0
  47. package/dist/serializeResult.js +2 -0
  48. package/dist/serializeResult.js.map +1 -0
  49. package/dist/spineParse.d.ts +18 -0
  50. package/dist/spineParse.d.ts.map +1 -0
  51. package/dist/spineParse.js +263 -0
  52. package/dist/spineParse.js.map +1 -0
  53. package/dist/spineSchema.d.ts +41 -0
  54. package/dist/spineSchema.d.ts.map +1 -0
  55. package/dist/spineSchema.js +8 -0
  56. package/dist/spineSchema.js.map +1 -0
  57. package/dist/spineSerialize.d.ts +16 -0
  58. package/dist/spineSerialize.d.ts.map +1 -0
  59. package/dist/spineSerialize.js +108 -0
  60. package/dist/spineSerialize.js.map +1 -0
  61. package/dist/starlingPexParse.d.ts +29 -0
  62. package/dist/starlingPexParse.d.ts.map +1 -0
  63. package/dist/starlingPexParse.js +254 -0
  64. package/dist/starlingPexParse.js.map +1 -0
  65. package/dist/starlingPexSchema.d.ts +49 -0
  66. package/dist/starlingPexSchema.d.ts.map +1 -0
  67. package/dist/starlingPexSchema.js +15 -0
  68. package/dist/starlingPexSchema.js.map +1 -0
  69. package/dist/starlingPexSerialize.d.ts +20 -0
  70. package/dist/starlingPexSerialize.d.ts.map +1 -0
  71. package/dist/starlingPexSerialize.js +184 -0
  72. package/dist/starlingPexSerialize.js.map +1 -0
  73. package/dist/unityParse.d.ts +23 -0
  74. package/dist/unityParse.d.ts.map +1 -0
  75. package/dist/unityParse.js +347 -0
  76. package/dist/unityParse.js.map +1 -0
  77. package/dist/unitySchema.d.ts +109 -0
  78. package/dist/unitySchema.d.ts.map +1 -0
  79. package/dist/unitySchema.js +9 -0
  80. package/dist/unitySchema.js.map +1 -0
  81. package/dist/unitySerialize.d.ts +20 -0
  82. package/dist/unitySerialize.d.ts.map +1 -0
  83. package/dist/unitySerialize.js +148 -0
  84. package/dist/unitySerialize.js.map +1 -0
  85. package/package.json +38 -0
  86. package/src/detect.test.ts +99 -0
  87. package/src/formatRegistry.test.ts +118 -0
  88. package/src/libgdxParse.test.ts +309 -0
  89. package/src/libgdxSerialize.test.ts +279 -0
  90. package/src/parseParticleConfig.test.ts +447 -0
  91. package/src/particleDesignerParse.test.ts +279 -0
  92. package/src/particleDesignerSerialize.test.ts +113 -0
  93. package/src/pixiParse.test.ts +131 -0
  94. package/src/spineParse.test.ts +289 -0
  95. package/src/spineSerialize.test.ts +81 -0
  96. package/src/starlingPexParse.test.ts +154 -0
  97. package/src/starlingPexSerialize.test.ts +115 -0
  98. package/src/unityParse.test.ts +328 -0
  99. package/src/unitySerialize.test.ts +77 -0
@@ -0,0 +1,289 @@
1
+ import {
2
+ createParticleEmitterConfig,
3
+ particleColorCurveFromKeyframes,
4
+ particleCurveFromKeyframes,
5
+ sampleParticleColorCurve,
6
+ sampleParticleCurve,
7
+ } from '@flighthq/particles';
8
+
9
+ import { parseSpineParticle, parseSpineParticleDocument } from './spineParse';
10
+ import { serializeSpineParticle } from './spineSerialize';
11
+
12
+ const SPARK_JSON = JSON.stringify({
13
+ name: 'spark',
14
+ maxParticles: 300,
15
+ continuous: true,
16
+ duration: -1,
17
+ emission: { low: 80, high: 120 },
18
+ life: { low: 400, high: 800 },
19
+ lifeOffset: { low: 0, high: 0 },
20
+ x: { low: 0, high: 0 },
21
+ y: { low: 0, high: 0 },
22
+ spawnShape: 'point',
23
+ spawnWidth: { low: 0, high: 0 },
24
+ spawnHeight: { low: 0, high: 0 },
25
+ velocity: { low: 50, high: 200 },
26
+ angle: { low: 60, high: 120 },
27
+ rotation: { low: 0, high: 360 },
28
+ wind: { low: 0, high: 0 },
29
+ gravity: { low: 200, high: 200 },
30
+ scale: { low: 0.5, high: 1.5 },
31
+ scaleEnd: { low: 0, high: 0 },
32
+ tint: [
33
+ { time: 0, color: 'ffaa00' },
34
+ { time: 1, color: 'ff0000' },
35
+ ],
36
+ alpha: [
37
+ { time: 0, alpha: 1 },
38
+ { time: 1, alpha: 0 },
39
+ ],
40
+ blendMode: 'additive',
41
+ premultiplied: false,
42
+ images: ['spark.png'],
43
+ });
44
+
45
+ describe('parseSpineParticle', () => {
46
+ describe('blend mode', () => {
47
+ it('maps additive blendMode to "add"', () => {
48
+ expect(parseSpineParticle(SPARK_JSON).blendMode).toBe('add');
49
+ });
50
+
51
+ it('maps normal blendMode to "normal"', () => {
52
+ const json = JSON.stringify({ ...JSON.parse(SPARK_JSON), blendMode: 'normal' });
53
+ expect(parseSpineParticle(json).blendMode).toBe('normal');
54
+ });
55
+
56
+ it('round-trips blendMode through serialize', () => {
57
+ const config = parseSpineParticle(SPARK_JSON);
58
+ const { document } = parseSpineParticleDocument(SPARK_JSON);
59
+ const config2 = parseSpineParticle(serializeSpineParticle(config, document));
60
+ expect(config2.blendMode).toBe('add');
61
+ });
62
+ });
63
+
64
+ describe('lightweight, returns config directly', () => {
65
+ it('returns a ParticleEmitterConfig (not a Parsed object)', () => {
66
+ const result = parseSpineParticle(SPARK_JSON);
67
+ expect(typeof result.maxParticles).toBe('number');
68
+ expect((result as unknown as Record<string, unknown>).document).toBeUndefined();
69
+ });
70
+
71
+ it('parses maxParticles', () => {
72
+ expect(parseSpineParticle(SPARK_JSON).maxParticles).toBe(300);
73
+ });
74
+
75
+ it('converts life range from ms to seconds', () => {
76
+ const c = parseSpineParticle(SPARK_JSON);
77
+ expect(c.lifetimeMin).toBeCloseTo(0.4);
78
+ expect(c.lifetimeMax).toBeCloseTo(0.8);
79
+ });
80
+
81
+ it('converts emission to spawnRate', () => {
82
+ expect(parseSpineParticle(SPARK_JSON).spawnRate).toBeCloseTo(100);
83
+ });
84
+
85
+ it('maps velocity to speedMin/Max', () => {
86
+ const c = parseSpineParticle(SPARK_JSON);
87
+ expect(c.speedMin).toBeCloseTo(50);
88
+ expect(c.speedMax).toBeCloseTo(200);
89
+ });
90
+
91
+ it('derives direction from angle midpoint and spread from range', () => {
92
+ const c = parseSpineParticle(SPARK_JSON);
93
+ expect(c.directionX).toBeCloseTo(0, 2);
94
+ expect(c.directionY).toBeCloseTo(-1, 2);
95
+ expect(c.spread).toBeCloseTo((30 * Math.PI) / 180, 3);
96
+ });
97
+
98
+ it('maps gravity', () => {
99
+ expect(parseSpineParticle(SPARK_JSON).gravityY).toBeCloseTo(200);
100
+ });
101
+
102
+ it('maps tint keyframes to colorStart/End', () => {
103
+ const c = parseSpineParticle(SPARK_JSON);
104
+ expect(c.colorStartR).toBeCloseTo(1);
105
+ expect(c.colorStartG).toBeCloseTo(0xaa / 255, 2);
106
+ expect(c.colorEndR).toBeCloseTo(1);
107
+ expect(c.colorEndG).toBeCloseTo(0);
108
+ });
109
+
110
+ it('maps alpha keyframes', () => {
111
+ const c = parseSpineParticle(SPARK_JSON);
112
+ expect(c.alphaStart).toBeCloseTo(1);
113
+ expect(c.alphaEnd).toBeCloseTo(0);
114
+ });
115
+
116
+ it('maps rotation range to rotationSpeedMin/Max', () => {
117
+ const c = parseSpineParticle(SPARK_JSON);
118
+ expect(c.rotationSpeedMin).toBeCloseTo(0);
119
+ expect(c.rotationSpeedMax).toBeCloseTo((360 * Math.PI) / 180, 3);
120
+ });
121
+
122
+ it('maps continuous=true to a looping emitter (emit forever)', () => {
123
+ const c = parseSpineParticle(SPARK_JSON);
124
+ expect(c.loop).toBe(true);
125
+ expect(c.duration).toBe(0);
126
+ });
127
+
128
+ it('maps continuous=false with a duration to a finite, non-looping emitter', () => {
129
+ const json = JSON.stringify({ ...JSON.parse(SPARK_JSON), continuous: false, duration: 2000 });
130
+ const c = parseSpineParticle(json);
131
+ expect(c.loop).toBe(false);
132
+ expect(c.duration).toBeCloseTo(2); // 2000 ms → 2 s
133
+ });
134
+ });
135
+
136
+ describe('malformed input', () => {
137
+ it('throws a clear, format-tagged error on invalid JSON', () => {
138
+ expect(() => parseSpineParticle('{not valid')).toThrow(/Invalid Spine particle JSON/);
139
+ });
140
+
141
+ it('throws a clear error when the root is not an object', () => {
142
+ expect(() => parseSpineParticle('null')).toThrow(/expected a JSON object/);
143
+ expect(() => parseSpineParticle('"a string"')).toThrow(/expected a JSON object/);
144
+ expect(() => parseSpineParticle('[1,2,3]')).toThrow(/expected a JSON object/);
145
+ });
146
+
147
+ it('falls back to safe channel values for an unparseable tint color (no NaN)', () => {
148
+ const c = parseSpineParticle(JSON.stringify({ tint: [{ time: 0, color: 'zzzzzz' }] }));
149
+ expect(Number.isFinite(c.colorStartR)).toBe(true);
150
+ expect(Number.isFinite(c.colorStartG)).toBe(true);
151
+ expect(Number.isFinite(c.colorStartB)).toBe(true);
152
+ });
153
+
154
+ it('falls back to defaults for an empty object rather than producing NaN', () => {
155
+ const c = parseSpineParticle('{}');
156
+ expect(Number.isFinite(c.lifetimeMin)).toBe(true);
157
+ expect(Number.isFinite(c.spawnRate)).toBe(true);
158
+ expect(Number.isFinite(c.colorStartR)).toBe(true);
159
+ });
160
+ });
161
+
162
+ describe('multi-stop timelines bake into curves', () => {
163
+ it('leaves curves null for a 2-stop tint/alpha (linear path)', () => {
164
+ const c = parseSpineParticle(SPARK_JSON); // tint/alpha each have 2 keyframes
165
+ expect(c.colorCurve).toBeNull();
166
+ expect(c.alphaCurve).toBeNull();
167
+ });
168
+
169
+ it('bakes a 3-stop tint timeline into colorCurve preserving the middle stop', () => {
170
+ const json = JSON.stringify({
171
+ ...JSON.parse(SPARK_JSON),
172
+ tint: [
173
+ { time: 0, color: 'ff0000' }, // red
174
+ { time: 0.5, color: '00ff00' }, // green
175
+ { time: 1, color: '0000ff' }, // blue
176
+ ],
177
+ });
178
+ const c = parseSpineParticle(json);
179
+ expect(c.colorCurve).not.toBeNull();
180
+ const out = [0, 0, 0];
181
+ sampleParticleColorCurve(out, 0, c.colorCurve!, 0.5);
182
+ expect(out[1]).toBeGreaterThan(0.8); // green dominant at mid-life
183
+ expect(out[0]).toBeLessThan(0.2);
184
+ });
185
+
186
+ it('bakes a 3-stop alpha timeline into alphaCurve', () => {
187
+ const json = JSON.stringify({
188
+ ...JSON.parse(SPARK_JSON),
189
+ alpha: [
190
+ { time: 0, alpha: 0 },
191
+ { time: 0.5, alpha: 1 }, // peak mid-life
192
+ { time: 1, alpha: 0 },
193
+ ],
194
+ });
195
+ const c = parseSpineParticle(json);
196
+ expect(c.alphaCurve).not.toBeNull();
197
+ expect(sampleParticleCurve(c.alphaCurve!, 0.5)).toBeGreaterThan(0.9);
198
+ expect(sampleParticleCurve(c.alphaCurve!, 0)).toBeLessThan(0.1);
199
+ });
200
+ });
201
+ });
202
+
203
+ describe('parseSpineParticleDocument', () => {
204
+ describe('full round-trip, returns { config, document }', () => {
205
+ it('returns the same config values as parseSpineParticle', () => {
206
+ const config = parseSpineParticle(SPARK_JSON);
207
+ const { config: loaded } = parseSpineParticleDocument(SPARK_JSON);
208
+ expect(loaded.maxParticles).toBe(config.maxParticles);
209
+ expect(loaded.gravityY).toBeCloseTo(config.gravityY, 5);
210
+ });
211
+
212
+ it('preserves name, blendMode, and images in document', () => {
213
+ const { document } = parseSpineParticleDocument(SPARK_JSON);
214
+ expect(document.name).toBe('spark');
215
+ expect(document.blendMode).toBe('additive');
216
+ expect(document.images[0]).toBe('spark.png');
217
+ });
218
+ });
219
+
220
+ describe('import warnings', () => {
221
+ it('has no warnings for a 2-keyframe tint/alpha effect', () => {
222
+ expect(parseSpineParticleDocument(SPARK_JSON).warnings).toEqual([]);
223
+ });
224
+
225
+ it('does NOT warn for multi-keyframe tint timelines (they are baked into curves)', () => {
226
+ const json = JSON.stringify({
227
+ ...JSON.parse(SPARK_JSON),
228
+ tint: [
229
+ { time: 0, color: 'ff0000' },
230
+ { time: 0.5, color: '00ff00' },
231
+ { time: 1, color: '0000ff' },
232
+ ],
233
+ });
234
+ const { warnings } = parseSpineParticleDocument(json);
235
+ expect(warnings.some((w) => w.includes('Tint'))).toBe(false);
236
+ });
237
+
238
+ it('warns about unsupported lifeOffset', () => {
239
+ const json = JSON.stringify({ ...JSON.parse(SPARK_JSON), lifeOffset: { low: 100, high: 200 } });
240
+ expect(parseSpineParticleDocument(json).warnings.some((w) => w.includes('lifeOffset'))).toBe(true);
241
+ });
242
+ });
243
+ });
244
+
245
+ describe('serializeSpineParticle', () => {
246
+ it('round-trips key config fields', () => {
247
+ const config = parseSpineParticle(SPARK_JSON);
248
+ const { document } = parseSpineParticleDocument(SPARK_JSON);
249
+ const config2 = parseSpineParticle(serializeSpineParticle(config, document));
250
+ expect(config2.maxParticles).toBe(config.maxParticles);
251
+ expect(config2.lifetimeMin).toBeCloseTo(config.lifetimeMin, 3);
252
+ expect(config2.speedMax).toBeCloseTo(config.speedMax, 2);
253
+ expect(config2.gravityY).toBeCloseTo(config.gravityY, 2);
254
+ });
255
+
256
+ it('preserves blendMode in round-trip', () => {
257
+ const config = parseSpineParticle(SPARK_JSON);
258
+ const { document } = parseSpineParticleDocument(SPARK_JSON);
259
+ expect(parseSpineParticleDocument(serializeSpineParticle(config, document)).document.blendMode).toBe('additive');
260
+ });
261
+
262
+ it('produces valid JSON', () => {
263
+ expect(() => JSON.parse(serializeSpineParticle(parseSpineParticle(SPARK_JSON)))).not.toThrow();
264
+ });
265
+ });
266
+
267
+ describe('serializeSpineParticle — curve round-trip', () => {
268
+ it('emits a baked color/alpha curve as a multi-stop timeline that re-imports identically', () => {
269
+ const colorCurve = particleColorCurveFromKeyframes([
270
+ { time: 0, r: 1, g: 0, b: 0 },
271
+ { time: 0.5, r: 0, g: 1, b: 0 },
272
+ { time: 1, r: 0, g: 0, b: 1 },
273
+ ]);
274
+ const alphaCurve = particleCurveFromKeyframes([
275
+ { time: 0, value: 0 },
276
+ { time: 0.5, value: 1 },
277
+ { time: 1, value: 0 },
278
+ ]);
279
+ const config = createParticleEmitterConfig({ colorCurve, alphaCurve });
280
+ const c2 = parseSpineParticle(serializeSpineParticle(config));
281
+ expect(c2.colorCurve).not.toBeNull();
282
+ expect(c2.alphaCurve).not.toBeNull();
283
+ const out = [0, 0, 0];
284
+ sampleParticleColorCurve(out, 0, c2.colorCurve!, 0.5);
285
+ expect(out[1]).toBeGreaterThan(0.8); // green still peaks mid-life
286
+ expect(sampleParticleCurve(c2.alphaCurve!, 0.5)).toBeGreaterThan(0.9);
287
+ expect(sampleParticleCurve(c2.alphaCurve!, 0)).toBeLessThan(0.1);
288
+ });
289
+ });
@@ -0,0 +1,81 @@
1
+ import { createParticleEmitterConfig } from '@flighthq/particles';
2
+
3
+ import { parseSpineParticle, parseSpineParticleDocument } from './spineParse';
4
+ import { serializeSpineParticle, serializeSpineParticleDocument } from './spineSerialize';
5
+
6
+ const SPARK_JSON = JSON.stringify({
7
+ name: 'spark',
8
+ maxParticles: 300,
9
+ continuous: true,
10
+ duration: -1,
11
+ emission: { low: 80, high: 120 },
12
+ life: { low: 400, high: 800 },
13
+ lifeOffset: { low: 0, high: 0 },
14
+ x: { low: 0, high: 0 },
15
+ y: { low: 0, high: 0 },
16
+ spawnShape: 'point',
17
+ spawnWidth: { low: 0, high: 0 },
18
+ spawnHeight: { low: 0, high: 0 },
19
+ velocity: { low: 50, high: 200 },
20
+ angle: { low: 60, high: 120 },
21
+ rotation: { low: 0, high: 0 },
22
+ wind: { low: 0, high: 0 },
23
+ gravity: { low: 0, high: 0 },
24
+ scale: { low: 0.5, high: 1.5 },
25
+ scaleEnd: { low: 0, high: 0 },
26
+ tint: [
27
+ { time: 0, color: 'ffffff' },
28
+ { time: 1, color: 'ff0000' },
29
+ ],
30
+ alpha: [
31
+ { time: 0, alpha: 1 },
32
+ { time: 1, alpha: 0 },
33
+ ],
34
+ blendMode: 'normal',
35
+ premultiplied: false,
36
+ images: ['spark.png'],
37
+ });
38
+
39
+ describe('serializeSpineParticle', () => {
40
+ it('produces valid JSON', () => {
41
+ const config = parseSpineParticle(SPARK_JSON);
42
+ expect(() => JSON.parse(serializeSpineParticle(config))).not.toThrow();
43
+ });
44
+
45
+ it('round-trips key config fields through parse → serialize → parse', () => {
46
+ const config = parseSpineParticle(SPARK_JSON);
47
+ const { document } = parseSpineParticleDocument(SPARK_JSON);
48
+ const json2 = serializeSpineParticle(config, document);
49
+ const config2 = parseSpineParticle(json2);
50
+ expect(config2.maxParticles).toBe(config.maxParticles);
51
+ expect(config2.lifetimeMin).toBeCloseTo(config.lifetimeMin, 3);
52
+ expect(config2.lifetimeMax).toBeCloseTo(config.lifetimeMax, 3);
53
+ });
54
+
55
+ it('preserves name from existing document', () => {
56
+ const config = parseSpineParticle(SPARK_JSON);
57
+ const { document } = parseSpineParticleDocument(SPARK_JSON);
58
+ const json2 = JSON.parse(serializeSpineParticle(config, document)) as Record<string, unknown>;
59
+ expect(json2.name).toBe('spark');
60
+ });
61
+ });
62
+
63
+ describe('serializeSpineParticleDocument', () => {
64
+ it('returns text and empty warnings for a default config', () => {
65
+ const config = createParticleEmitterConfig();
66
+ const result = serializeSpineParticleDocument(config);
67
+ expect(typeof result.text).toBe('string');
68
+ expect(JSON.parse(result.text)).toBeDefined();
69
+ expect(result.warnings).toEqual([]);
70
+ });
71
+ it('warns when config has emission bursts', () => {
72
+ const config = createParticleEmitterConfig({ burstCount: 5 });
73
+ const result = serializeSpineParticleDocument(config);
74
+ expect(result.warnings.some((w) => w.toLowerCase().includes('burst'))).toBe(true);
75
+ });
76
+ it('warns when config has color start variance', () => {
77
+ const config = createParticleEmitterConfig({ colorStartVarianceR: 0.1 });
78
+ const result = serializeSpineParticleDocument(config);
79
+ expect(result.warnings.some((w) => w.includes('colorStartVariance'))).toBe(true);
80
+ });
81
+ });
@@ -0,0 +1,154 @@
1
+ import { parseStarlingPex, parseStarlingPexDocument } from './starlingPexParse';
2
+
3
+ // Attribute-style PEX (the canonical Sparrow/Starling variant)
4
+ const FIRE_PEX_ATTR = `<?xml version="1.0" encoding="utf-8"?>
5
+ <particleEmitterConfig>
6
+ <attribute name="maxParticles" value="200"/>
7
+ <attribute name="emitterType" value="0"/>
8
+ <attribute name="duration" value="-1"/>
9
+ <attribute name="particleLifespan" value="1.5"/>
10
+ <attribute name="particleLifespanVariance" value="0.5"/>
11
+ <attribute name="speed" value="100"/>
12
+ <attribute name="speedVariance" value="20"/>
13
+ <attribute name="angle" value="90"/>
14
+ <attribute name="angleVariance" value="30"/>
15
+ <attribute name="gravityx" value="0"/>
16
+ <attribute name="gravityy" value="200"/>
17
+ <attribute name="sourcePositionVariancex" value="0"/>
18
+ <attribute name="sourcePositionVariancey" value="0"/>
19
+ <attribute name="startParticleSize" value="32"/>
20
+ <attribute name="startParticleSizeVariance" value="8"/>
21
+ <attribute name="finishParticleSize" value="8"/>
22
+ <attribute name="finishParticleSizeVariance" value="0"/>
23
+ <attribute name="startColor" red="1" green="0.5" blue="0" alpha="1"/>
24
+ <attribute name="startColorVariance" red="0" green="0" blue="0" alpha="0"/>
25
+ <attribute name="finishColor" red="1" green="0" blue="0" alpha="0"/>
26
+ <attribute name="finishColorVariance" red="0" green="0" blue="0" alpha="0"/>
27
+ <attribute name="rotationStart" value="0"/>
28
+ <attribute name="rotationStartVariance" value="0"/>
29
+ <attribute name="rotationEnd" value="0"/>
30
+ <attribute name="rotationEndVariance" value="0"/>
31
+ <attribute name="maxRadius" value="0"/>
32
+ <attribute name="maxRadiusVariance" value="0"/>
33
+ <attribute name="minRadius" value="0"/>
34
+ <attribute name="minRadiusVariance" value="0"/>
35
+ <attribute name="rotatePerSecond" value="0"/>
36
+ <attribute name="rotatePerSecondVariance" value="0"/>
37
+ <attribute name="radialAcceleration" value="0"/>
38
+ <attribute name="radialAccelVariance" value="0"/>
39
+ <attribute name="tangentialAcceleration" value="0"/>
40
+ <attribute name="tangentialAccelVariance" value="0"/>
41
+ <attribute name="blendFuncSource" value="770"/>
42
+ <attribute name="blendFuncDestination" value="771"/>
43
+ <attribute name="textureFileName" value="fire.png"/>
44
+ </particleEmitterConfig>`;
45
+
46
+ describe('parseStarlingPex', () => {
47
+ it('returns a ParticleEmitterConfig (not a ParseResult object)', () => {
48
+ const result = parseStarlingPex(FIRE_PEX_ATTR) as unknown as Record<string, unknown>;
49
+ expect(typeof result.maxParticles).toBe('number');
50
+ expect(result.document).toBeUndefined();
51
+ });
52
+ it('parses maxParticles from attribute-style PEX', () => {
53
+ expect(parseStarlingPex(FIRE_PEX_ATTR).maxParticles).toBe(200);
54
+ });
55
+ it('converts lifetime with variance to min/max', () => {
56
+ const c = parseStarlingPex(FIRE_PEX_ATTR);
57
+ expect(c.lifetimeMin).toBeCloseTo(1.0);
58
+ expect(c.lifetimeMax).toBeCloseTo(2.0);
59
+ });
60
+ it('converts speed with variance to min/max', () => {
61
+ const c = parseStarlingPex(FIRE_PEX_ATTR);
62
+ expect(c.speedMin).toBeCloseTo(80);
63
+ expect(c.speedMax).toBeCloseTo(120);
64
+ });
65
+ it('maps angle=90 to upward direction (-Y screen)', () => {
66
+ const c = parseStarlingPex(FIRE_PEX_ATTR);
67
+ expect(c.directionX).toBeCloseTo(0, 3);
68
+ expect(c.directionY).toBeCloseTo(-1, 3);
69
+ });
70
+ it('converts angleVariance to spread in radians', () => {
71
+ expect(parseStarlingPex(FIRE_PEX_ATTR).spread).toBeCloseTo((30 * Math.PI) / 180, 4);
72
+ });
73
+ it('maps gravity directly', () => {
74
+ const c = parseStarlingPex(FIRE_PEX_ATTR);
75
+ expect(c.gravityX).toBeCloseTo(0);
76
+ expect(c.gravityY).toBeCloseTo(200);
77
+ });
78
+ it('maps startColor/finishColor to colorStart/End', () => {
79
+ const c = parseStarlingPex(FIRE_PEX_ATTR);
80
+ expect(c.colorStartR).toBeCloseTo(1);
81
+ expect(c.colorStartG).toBeCloseTo(0.5);
82
+ expect(c.colorStartB).toBeCloseTo(0);
83
+ expect(c.colorEndR).toBeCloseTo(1);
84
+ expect(c.colorEndG).toBeCloseTo(0);
85
+ expect(c.colorEndB).toBeCloseTo(0);
86
+ });
87
+ it('maps startColor.alpha / finishColor.alpha to alphaStart/End', () => {
88
+ const c = parseStarlingPex(FIRE_PEX_ATTR);
89
+ expect(c.alphaStart).toBeCloseTo(1);
90
+ expect(c.alphaEnd).toBeCloseTo(0);
91
+ });
92
+ it('normalises scale by textureSize', () => {
93
+ const c = parseStarlingPex(FIRE_PEX_ATTR, { textureSize: 32 });
94
+ expect(c.scaleMin).toBeCloseTo(0.75);
95
+ expect(c.scaleMax).toBeCloseTo(1.25);
96
+ });
97
+ it('maps normal blend func (770, 771) to blendMode="normal"', () => {
98
+ expect(parseStarlingPex(FIRE_PEX_ATTR).blendMode).toBe('normal');
99
+ });
100
+ it('maps additive blend func (770, 1) to blendMode="add"', () => {
101
+ const pex = FIRE_PEX_ATTR.replace('blendFuncDestination" value="771"', 'blendFuncDestination" value="1"');
102
+ expect(parseStarlingPex(pex).blendMode).toBe('add');
103
+ });
104
+ it('maps duration=-1 to an infinite (looping) emitter', () => {
105
+ const c = parseStarlingPex(FIRE_PEX_ATTR);
106
+ expect(c.loop).toBe(true);
107
+ expect(c.duration).toBe(0);
108
+ });
109
+ it('uses point emitter shape when sourcePositionVariance is zero', () => {
110
+ expect(parseStarlingPex(FIRE_PEX_ATTR).emitterShape).toBe('point');
111
+ });
112
+ it('maps circle emitter shape when x=y variance', () => {
113
+ const pex = FIRE_PEX_ATTR.replace(
114
+ 'sourcePositionVariancex" value="0"',
115
+ 'sourcePositionVariancex" value="20"',
116
+ ).replace('sourcePositionVariancey" value="0"', 'sourcePositionVariancey" value="20"');
117
+ const c = parseStarlingPex(pex);
118
+ expect(c.emitterShape).toBe('circle');
119
+ expect(c.emitterRadius).toBeCloseTo(20);
120
+ });
121
+ it('throws a clear error for non-XML input', () => {
122
+ expect(() => parseStarlingPex('not xml')).toThrow(/Invalid Starling PEX/);
123
+ expect(() => parseStarlingPex('{}')).toThrow(/Invalid Starling PEX/);
124
+ });
125
+ });
126
+
127
+ describe('parseStarlingPexDocument', () => {
128
+ it('returns the same config values as parseStarlingPex', () => {
129
+ const config = parseStarlingPex(FIRE_PEX_ATTR, { textureSize: 32 });
130
+ const { config: loaded } = parseStarlingPexDocument(FIRE_PEX_ATTR, { textureSize: 32 });
131
+ expect(loaded.maxParticles).toBe(config.maxParticles);
132
+ expect(loaded.directionY).toBeCloseTo(config.directionY, 5);
133
+ expect(loaded.gravityY).toBeCloseTo(config.gravityY, 5);
134
+ });
135
+ it('preserves textureFileName in document', () => {
136
+ expect(parseStarlingPexDocument(FIRE_PEX_ATTR).document.textureFileName).toBe('fire.png');
137
+ });
138
+ it('preserves blend function in document', () => {
139
+ const { document } = parseStarlingPexDocument(FIRE_PEX_ATTR);
140
+ expect(document.blendFuncSource).toBe(770);
141
+ expect(document.blendFuncDestination).toBe(771);
142
+ });
143
+ it('has no warnings for a standard gravity emitter', () => {
144
+ expect(parseStarlingPexDocument(FIRE_PEX_ATTR).warnings).toEqual([]);
145
+ });
146
+ it('warns that a radial emitter is approximated', () => {
147
+ const pex = FIRE_PEX_ATTR.replace('emitterType" value="0"', 'emitterType" value="1"');
148
+ expect(parseStarlingPexDocument(pex).warnings.some((w) => w.toLowerCase().includes('radial'))).toBe(true);
149
+ });
150
+ it('warns about unsupported radial/tangential acceleration', () => {
151
+ const pex = FIRE_PEX_ATTR.replace('radialAcceleration" value="0"', 'radialAcceleration" value="50"');
152
+ expect(parseStarlingPexDocument(pex).warnings.some((w) => w.includes('radialAcceleration'))).toBe(true);
153
+ });
154
+ });
@@ -0,0 +1,115 @@
1
+ import { createParticleEmitterConfig } from '@flighthq/particles';
2
+
3
+ import { parseStarlingPex, parseStarlingPexDocument } from './starlingPexParse';
4
+ import { serializeStarlingPex, serializeStarlingPexDocument } from './starlingPexSerialize';
5
+
6
+ // Attribute-style PEX (the canonical Sparrow/Starling variant)
7
+ const FIRE_PEX_ATTR = `<?xml version="1.0" encoding="utf-8"?>
8
+ <particleEmitterConfig>
9
+ <attribute name="maxParticles" value="200"/>
10
+ <attribute name="emitterType" value="0"/>
11
+ <attribute name="duration" value="-1"/>
12
+ <attribute name="particleLifespan" value="1.5"/>
13
+ <attribute name="particleLifespanVariance" value="0.5"/>
14
+ <attribute name="speed" value="100"/>
15
+ <attribute name="speedVariance" value="20"/>
16
+ <attribute name="angle" value="90"/>
17
+ <attribute name="angleVariance" value="30"/>
18
+ <attribute name="gravityx" value="0"/>
19
+ <attribute name="gravityy" value="200"/>
20
+ <attribute name="sourcePositionVariancex" value="0"/>
21
+ <attribute name="sourcePositionVariancey" value="0"/>
22
+ <attribute name="startParticleSize" value="32"/>
23
+ <attribute name="startParticleSizeVariance" value="8"/>
24
+ <attribute name="finishParticleSize" value="8"/>
25
+ <attribute name="finishParticleSizeVariance" value="0"/>
26
+ <attribute name="startColor" red="1" green="0.5" blue="0" alpha="1"/>
27
+ <attribute name="startColorVariance" red="0" green="0" blue="0" alpha="0"/>
28
+ <attribute name="finishColor" red="1" green="0" blue="0" alpha="0"/>
29
+ <attribute name="finishColorVariance" red="0" green="0" blue="0" alpha="0"/>
30
+ <attribute name="rotationStart" value="0"/>
31
+ <attribute name="rotationStartVariance" value="0"/>
32
+ <attribute name="rotationEnd" value="0"/>
33
+ <attribute name="rotationEndVariance" value="0"/>
34
+ <attribute name="maxRadius" value="0"/>
35
+ <attribute name="maxRadiusVariance" value="0"/>
36
+ <attribute name="minRadius" value="0"/>
37
+ <attribute name="minRadiusVariance" value="0"/>
38
+ <attribute name="rotatePerSecond" value="0"/>
39
+ <attribute name="rotatePerSecondVariance" value="0"/>
40
+ <attribute name="radialAcceleration" value="0"/>
41
+ <attribute name="radialAccelVariance" value="0"/>
42
+ <attribute name="tangentialAcceleration" value="0"/>
43
+ <attribute name="tangentialAccelVariance" value="0"/>
44
+ <attribute name="blendFuncSource" value="770"/>
45
+ <attribute name="blendFuncDestination" value="771"/>
46
+ <attribute name="textureFileName" value="fire.png"/>
47
+ </particleEmitterConfig>`;
48
+
49
+ describe('serializeStarlingPex', () => {
50
+ it('round-trips key config fields', () => {
51
+ const config = parseStarlingPex(FIRE_PEX_ATTR, { textureSize: 32 });
52
+ const { document } = parseStarlingPexDocument(FIRE_PEX_ATTR, { textureSize: 32 });
53
+ const xml = serializeStarlingPex(config, document, { textureSize: 32 });
54
+ const config2 = parseStarlingPex(xml, { textureSize: 32 });
55
+ expect(config2.maxParticles).toBe(config.maxParticles);
56
+ expect(config2.directionX).toBeCloseTo(config.directionX, 3);
57
+ expect(config2.gravityY).toBeCloseTo(config.gravityY, 1);
58
+ expect(config2.colorStartR).toBeCloseTo(config.colorStartR, 3);
59
+ expect(config2.alphaEnd).toBeCloseTo(config.alphaEnd, 3);
60
+ });
61
+ it('preserves textureFileName in round-trip', () => {
62
+ const config = parseStarlingPex(FIRE_PEX_ATTR);
63
+ const { document } = parseStarlingPexDocument(FIRE_PEX_ATTR);
64
+ expect(serializeStarlingPex(config, document)).toContain('fire.png');
65
+ });
66
+ it('produces valid XML with particleEmitterConfig root', () => {
67
+ const config = parseStarlingPex(FIRE_PEX_ATTR);
68
+ const xml = serializeStarlingPex(config);
69
+ expect(xml).toContain('<?xml version="1.0"');
70
+ expect(xml).toContain('<particleEmitterConfig>');
71
+ expect(xml).toContain('</particleEmitterConfig>');
72
+ });
73
+ it('produces XML parseable by parseStarlingPex for a fresh config', () => {
74
+ const config = createParticleEmitterConfig({ maxParticles: 50, blendMode: 'add' });
75
+ const xml = serializeStarlingPex(config);
76
+ const c2 = parseStarlingPex(xml);
77
+ expect(c2.maxParticles).toBe(50);
78
+ expect(c2.blendMode).toBe('add');
79
+ });
80
+ it('maps blendMode="normal" to blendFuncDestination 771', () => {
81
+ const config = createParticleEmitterConfig({ blendMode: 'normal' });
82
+ const xml = serializeStarlingPex(config);
83
+ expect(xml).toContain('blendFuncDestination" value="771"');
84
+ });
85
+ it('maps blendMode="add" to blendFuncDestination 1', () => {
86
+ const config = createParticleEmitterConfig({ blendMode: 'add' });
87
+ const xml = serializeStarlingPex(config);
88
+ expect(xml).toContain('blendFuncDestination" value="1"');
89
+ });
90
+ });
91
+
92
+ describe('serializeStarlingPexDocument', () => {
93
+ it('returns text and empty warnings for a default config', () => {
94
+ const config = createParticleEmitterConfig();
95
+ const result = serializeStarlingPexDocument(config);
96
+ expect(typeof result.text).toBe('string');
97
+ expect(result.text).toContain('<particleEmitterConfig>');
98
+ expect(result.warnings).toEqual([]);
99
+ });
100
+ it('warns when multiply/screen blend mode cannot be represented', () => {
101
+ const config = createParticleEmitterConfig({ blendMode: 'multiply' });
102
+ const result = serializeStarlingPexDocument(config);
103
+ expect(result.warnings.some((w) => w.toLowerCase().includes('multiply'))).toBe(true);
104
+ });
105
+ it('warns when config has emission bursts', () => {
106
+ const config = createParticleEmitterConfig({ burstCount: 5 });
107
+ const result = serializeStarlingPexDocument(config);
108
+ expect(result.warnings.some((w) => w.toLowerCase().includes('burst'))).toBe(true);
109
+ });
110
+ it('has no warnings for add blend mode (supported)', () => {
111
+ const config = createParticleEmitterConfig({ blendMode: 'add' });
112
+ const result = serializeStarlingPexDocument(config);
113
+ expect(result.warnings.filter((w) => w.toLowerCase().includes('blend'))).toEqual([]);
114
+ });
115
+ });